diff --git a/apps/backend/controllers/requestLine.controller.ts b/apps/backend/controllers/requestLine.controller.ts index 821e464..99c9595 100644 --- a/apps/backend/controllers/requestLine.controller.ts +++ b/apps/backend/controllers/requestLine.controller.ts @@ -1,29 +1,39 @@ import { RequestHandler } from 'express'; import * as RequestLineService from '../services/requestLine.service.js'; import * as AnonymousDeviceService from '../services/anonymousDevice.service.js'; -// Force CI rebuild after main merge +import { processRequest, parseOnly, getConfig, isParsingEnabled } from '../services/requestLine/index.js'; +import { searchLibrary } from '../services/library.service.js'; export type RequestLineBody = { message: string; + skipSlack?: boolean; + skipParsing?: boolean; }; export type RegisterDeviceBody = { deviceId: string; }; +export type LibrarySearchQuery = { + artist?: string; + title?: string; + query?: string; + limit?: string; +}; + // Message validation constants const MESSAGE_MIN_LENGTH = 1; const MESSAGE_MAX_LENGTH = 500; /** - * Register an anonymous device and receive a JWT token. + * Legacy device registration endpoint (deprecated). + * Redirects clients to use the better-auth anonymous sign-in endpoint. * POST /request/register */ -export const registerDevice: RequestHandler = async (req, res, next) => { - const requestId = `reg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const startTime = Date.now(); +export const registerDevice: RequestHandler = async (req, res) => { + const authUrl = process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth'; - console.log(`[${requestId}] Device registration request:`, { + console.log('Legacy /request/register endpoint called - redirecting to better-auth', { method: req.method, url: req.originalUrl, ip: req.ip, @@ -31,72 +41,23 @@ export const registerDevice: RequestHandler timestamp: new Date().toISOString(), }); - const { deviceId } = req.body; - - // Validate deviceId - if (!deviceId || typeof deviceId !== 'string') { - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration failed: missing deviceId`, { responseTime: `${responseTime}ms` }); - res.status(400).json({ message: 'deviceId is required' }); - return; - } - - if (!AnonymousDeviceService.isValidDeviceId(deviceId)) { - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration failed: invalid deviceId format`, { responseTime: `${responseTime}ms` }); - res.status(400).json({ message: 'Invalid deviceId format. Must be a valid UUID.' }); - return; - } - - try { - // Register or retrieve device - const result = await AnonymousDeviceService.registerDevice(deviceId); - - if (!result) { - // Device is blocked - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration rejected: device blocked`, { deviceId, responseTime: `${responseTime}ms` }); - res.status(403).json({ message: 'Device has been blocked' }); - return; - } - - // Generate token - const tokenResult = await AnonymousDeviceService.generateToken(deviceId); - - const responseTime = Date.now() - startTime; - console.log(`[${requestId}] Registration successful:`, { - deviceId, - isNew: result.isNew, - responseTime: `${responseTime}ms`, - }); - - res.status(200).json({ - token: tokenResult.token, - expiresAt: tokenResult.expiresAt.toISOString(), - }); - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - const responseTime = Date.now() - startTime; - console.error(`[${requestId}] Registration error:`, { - error: error.message, - stack: error.stack, - responseTime: `${responseTime}ms`, - }); - next(e); - } + res.status(301).json({ + message: 'This endpoint is deprecated. Use POST /auth/sign-in/anonymous for registration.', + endpoint: `${authUrl}/sign-in/anonymous`, + }); }; export const submitRequestLine: RequestHandler = async (req, res, next) => { const logId = `rl-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const startTime = Date.now(); - const deviceId = req.anonymousDevice?.deviceId || 'unknown'; + const userId = req.user?.id || 'unknown'; // Log incoming request console.log(`[${logId}] Request line received:`, { method: req.method, url: req.originalUrl, ip: req.ip, - deviceId, + userId, userAgent: req.get('User-Agent'), messageLength: req.body.message?.length || 0, timestamp: new Date().toISOString(), @@ -151,33 +112,176 @@ export const submitRequestLine: RequestHandler } try { - const result = await RequestLineService.submitRequestLine(trimmedMessage); + // Use enhanced service if AI parsing is available, otherwise fall back to simple Slack post + const config = getConfig(); + + if (isParsingEnabled(config)) { + // Use enhanced pipeline with AI parsing, library search, and artwork + const result = await processRequest({ + message: trimmedMessage, + skipSlack: req.body.skipSlack, + skipParsing: req.body.skipParsing, + }); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Request completed successfully (enhanced):`, { + statusCode: 200, + responseTime: `${responseTime}ms`, + messageLength: trimmedMessage.length, + userId, + searchType: result.searchType, + libraryResultsCount: result.libraryResults.length, + hasArtwork: !!result.artwork?.artworkUrl, + parsed: { + isRequest: result.parsed.isRequest, + messageType: result.parsed.messageType, + hasArtist: !!result.parsed.artist, + hasAlbum: !!result.parsed.album, + hasSong: !!result.parsed.song, + }, + }); + + res.status(200).json(result); + } else { + // Fall back to simple Slack post (legacy behavior) + const result = await RequestLineService.submitRequestLine(trimmedMessage); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Request completed successfully (legacy):`, { + statusCode: 200, + responseTime: `${responseTime}ms`, + messageLength: trimmedMessage.length, + userId, + slackResponse: result, + }); + + res.status(200).json({ + success: true, + message: 'Request line submitted successfully', + result, + }); + } + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const responseTime = Date.now() - startTime; + + console.error(`[${logId}] Request failed:`, { + statusCode: 500, + responseTime: `${responseTime}ms`, + error: error.message, + stack: error.stack, + messageLength: req.body.message?.length || 0, + userId, + }); + + next(e); + } +}; + +/** + * Parse a message only (for debugging). + * POST /request/parse + */ +export const parseMessage: RequestHandler = async (req, res, next) => { + const logId = `parse-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const startTime = Date.now(); + + console.log(`[${logId}] Parse request received:`, { + method: req.method, + url: req.originalUrl, + ip: req.ip, + messageLength: req.body.message?.length || 0, + timestamp: new Date().toISOString(), + }); + + const message = req.body.message?.trim(); + if (!message) { + res.status(400).json({ message: 'Message is required' }); + return; + } + + try { + const parsed = await parseOnly(message); + + const responseTime = Date.now() - startTime; + console.log(`[${logId}] Parse completed:`, { + responseTime: `${responseTime}ms`, + parsed: { + isRequest: parsed.isRequest, + messageType: parsed.messageType, + hasArtist: !!parsed.artist, + hasAlbum: !!parsed.album, + hasSong: !!parsed.song, + }, + }); + + res.status(200).json({ success: true, parsed }); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const responseTime = Date.now() - startTime; + + console.error(`[${logId}] Parse failed:`, { + responseTime: `${responseTime}ms`, + error: error.message, + }); + + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * Search the library. + * GET /library/search + */ +export const searchLibraryEndpoint: RequestHandler = async ( + req, + res, + next +) => { + const logId = `search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const startTime = Date.now(); + + const { artist, title, query, limit } = req.query; + const limitNum = limit ? parseInt(limit, 10) : 5; + + console.log(`[${logId}] Library search request:`, { + method: req.method, + url: req.originalUrl, + ip: req.ip, + artist, + title, + query, + limit: limitNum, + timestamp: new Date().toISOString(), + }); + + if (!artist && !title && !query) { + res.status(400).json({ message: 'At least one of artist, title, or query is required' }); + return; + } + + try { + const results = await searchLibrary(query, artist, title, limitNum); const responseTime = Date.now() - startTime; - console.log(`[${logId}] Request completed successfully:`, { - statusCode: 200, + console.log(`[${logId}] Search completed:`, { responseTime: `${responseTime}ms`, - messageLength: trimmedMessage.length, - deviceId, - slackResponse: result, + resultsCount: results.length, }); res.status(200).json({ success: true, - message: 'Request line submitted successfully', - result, + results, + total: results.length, + query: { artist, title, query, limit: limitNum }, }); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); const responseTime = Date.now() - startTime; - console.error(`[${logId}] Request failed:`, { - statusCode: 500, + console.error(`[${logId}] Search failed:`, { responseTime: `${responseTime}ms`, error: error.message, - stack: error.stack, - messageLength: req.body.message?.length || 0, - deviceId, }); next(e); diff --git a/apps/backend/middleware/anonymousAuth.ts b/apps/backend/middleware/anonymousAuth.ts index 01f922f..e98f9a2 100644 --- a/apps/backend/middleware/anonymousAuth.ts +++ b/apps/backend/middleware/anonymousAuth.ts @@ -1,75 +1,68 @@ import { Request, Response, NextFunction, RequestHandler } from 'express'; -import * as AnonymousDeviceService from '../services/anonymousDevice.service.js'; -import { AnonymousDevice } from '@wxyc/database'; +import { auth } from '@wxyc/authentication'; +import { fromNodeHeaders } from 'better-auth/node'; +import { recordActivity } from '../services/activityTracking.service.js'; -// Extend Express Request to include device info +// Extend Express Request to include user info declare global { namespace Express { interface Request { - anonymousDevice?: AnonymousDevice; + user?: { + id: string; + email: string; + name: string; + isAnonymous?: boolean; + banned?: boolean; + banReason?: string | null; + banExpires?: Date | null; + [key: string]: unknown; + }; } } } /** - * Middleware that requires anonymous device authentication via JWT. - * Extracts Bearer token from Authorization header, validates it, - * and attaches the device info to the request. + * Middleware that requires authentication via better-auth session. + * Extracts Bearer token from Authorization header, validates the session, + * and attaches the user info to the request. * - * If the token is valid but nearing expiration, adds refresh headers: - * - X-Refresh-Token: The new token - * - X-Token-Expires-At: The new expiration date + * Also checks if the user is banned and records activity. */ export const requireAnonymousAuth: RequestHandler = async ( req: Request, res: Response, next: NextFunction ): Promise => { - const authHeader = req.headers.authorization; + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); - if (!authHeader) { - res.status(401).json({ message: 'Authorization header required' }); - return; - } - - // Extract Bearer token - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { - res.status(401).json({ message: 'Invalid authorization format. Expected: Bearer ' }); - return; - } - - const token = parts[1]; - - // Validate token and device - const result = await AnonymousDeviceService.validateTokenAndDevice(token); + if (!session?.user) { + res.status(401).json({ message: 'Authentication required' }); + return; + } - if (!result.valid) { - switch (result.error) { - case 'invalid_token': - case 'expired_token': - res.status(401).json({ message: 'Invalid or expired token' }); - return; - case 'blocked': - res.status(403).json({ message: 'Device has been blocked', reason: result.device?.blockedReason }); - return; - case 'not_found': - res.status(401).json({ message: 'Device not found. Please register first.' }); - return; - default: - res.status(401).json({ message: 'Authentication failed' }); - return; + // Check if user is banned (better-auth admin plugin) + if (session.user.banned) { + res.status(403).json({ + message: 'Access denied', + reason: session.user.banReason || 'Account suspended', + }); + return; } - } - // Attach device to request - req.anonymousDevice = result.device; + // Attach user to request + req.user = session.user as Express.Request['user']; - // Add refresh headers if token needs refresh - if (result.needsRefresh && result.newToken) { - res.setHeader('X-Refresh-Token', result.newToken.token); - res.setHeader('X-Token-Expires-At', result.newToken.expiresAt.toISOString()); - } + // Record activity (fire and forget) + recordActivity(session.user.id).catch((error) => { + console.error('Failed to record activity:', error); + }); - next(); + next(); + } catch (error) { + console.error('Auth validation error:', error); + res.status(401).json({ message: 'Invalid or expired token' }); + } }; diff --git a/apps/backend/middleware/rateLimiting.ts b/apps/backend/middleware/rateLimiting.ts new file mode 100644 index 0000000..c407a0b --- /dev/null +++ b/apps/backend/middleware/rateLimiting.ts @@ -0,0 +1,95 @@ +import rateLimit, { Options, MemoryStore } from 'express-rate-limit'; +import { Request, Response, NextFunction } from 'express'; + +// Environment-based configuration +const isTestEnv = process.env.NODE_ENV === 'test' || process.env.USE_MOCK_SERVICES === 'true'; +const enableRateLimitInTest = process.env.TEST_RATE_LIMITING === 'true'; + +// Configurable limits via environment variables (useful for testing) +const REGISTRATION_WINDOW_MS = parseInt(process.env.RATE_LIMIT_REGISTRATION_WINDOW_MS || '3600000', 10); // 1 hour default +const REGISTRATION_MAX = parseInt(process.env.RATE_LIMIT_REGISTRATION_MAX || '5', 10); +const REQUEST_WINDOW_MS = parseInt(process.env.RATE_LIMIT_REQUEST_WINDOW_MS || '900000', 10); // 15 min default +const REQUEST_MAX = parseInt(process.env.RATE_LIMIT_REQUEST_MAX || '10', 10); + +// Shared stores so we can reset them in tests +const registrationStore = new MemoryStore(); +const songRequestStore = new MemoryStore(); + +/** + * Reset all rate limit stores. Only works in test environment. + * Call this in beforeEach/afterEach to get a clean slate. + */ +export const resetRateLimitStores = (): void => { + if (isTestEnv) { + registrationStore.resetAll(); + songRequestStore.resetAll(); + } +}; + +// Pass-through middleware for test environments (when rate limiting is disabled) +const passThrough = (_req: Request, _res: Response, next: NextFunction) => next(); + +// Determine if rate limiting should be active +const shouldEnableRateLimiting = !isTestEnv || enableRateLimitInTest; + +/** + * Rate limiter for device registration endpoint. + * Limits registrations per IP address. + * + * Configurable via environment: + * - RATE_LIMIT_REGISTRATION_WINDOW_MS (default: 3600000 = 1 hour) + * - RATE_LIMIT_REGISTRATION_MAX (default: 5) + * + * Disabled in test environment unless TEST_RATE_LIMITING=true + */ +export const registrationRateLimit = shouldEnableRateLimiting + ? rateLimit({ + windowMs: REGISTRATION_WINDOW_MS, + max: REGISTRATION_MAX, + standardHeaders: true, + legacyHeaders: false, + store: registrationStore, + handler: (_req: Request, res: Response) => { + res.status(429).json({ + message: 'Too many registration attempts. Please try again later.', + retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000), + }); + }, + }) + : passThrough; + +/** + * Rate limiter for song request endpoint. + * Limits requests per user ID. + * + * Configurable via environment: + * - RATE_LIMIT_REQUEST_WINDOW_MS (default: 900000 = 15 minutes) + * - RATE_LIMIT_REQUEST_MAX (default: 10) + * + * Disabled in test environment unless TEST_RATE_LIMITING=true + */ +export const songRequestRateLimit = shouldEnableRateLimiting + ? rateLimit({ + windowMs: REQUEST_WINDOW_MS, + max: REQUEST_MAX, + standardHeaders: true, + legacyHeaders: false, + store: songRequestStore, + keyGenerator: (req: Request) => { + // Use user ID if available (set by anonymousAuth middleware) + if (req.user?.id) { + return req.user.id; + } + // Fall back to 'unknown' - this shouldn't happen since auth middleware runs first + return 'unknown'; + }, + handler: (_req: Request, res: Response) => { + res.status(429).json({ + message: 'Too many requests. Please wait before submitting more song requests.', + retryAfter: Math.ceil(REQUEST_WINDOW_MS / 1000), + }); + }, + // Skip validation for keyGenerator since we're using device ID, not IP + validate: { xForwardedForHeader: false } as Partial, + }) + : passThrough; diff --git a/apps/backend/package.json b/apps/backend/package.json index b8333eb..fe111c2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,9 +18,13 @@ "@wxyc/database": "*", "async-mutex": "^0.5.0", "aws-jwt-verify": "^5.1.0", + "axios": "^1.7.0", "cors": "^2.8.5", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "groq-sdk": "^0.5.0", "jose": "^6.1.3", + "lru-cache": "^10.2.0", "node-fetch": "^3.3.2", "node-ssh": "^13.2.1", "postgres": "^3.4.4", diff --git a/apps/backend/routes/library.route.ts b/apps/backend/routes/library.route.ts index 6dbd987..f7f5ea9 100644 --- a/apps/backend/routes/library.route.ts +++ b/apps/backend/routes/library.route.ts @@ -1,9 +1,19 @@ 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( "/", requirePermissions({ catalog: ["read"] }), diff --git a/apps/backend/routes/requestLine.route.ts b/apps/backend/routes/requestLine.route.ts index 20cdcf7..8fef27a 100644 --- a/apps/backend/routes/requestLine.route.ts +++ b/apps/backend/routes/requestLine.route.ts @@ -1,11 +1,17 @@ import { Router } from 'express'; import * as requestLineController from '../controllers/requestLine.controller.js'; import { requireAnonymousAuth } from '../middleware/anonymousAuth.js'; +import { registrationRateLimit, songRequestRateLimit } from '../middleware/rateLimiting.js'; export const request_line_route = Router(); // Device registration - get token for anonymous requests -request_line_route.post('/register', requestLineController.registerDevice); +// Rate limited by IP address +request_line_route.post('/register', registrationRateLimit, requestLineController.registerDevice); // Request Line - song requests from listeners (requires anonymous auth) -request_line_route.post('/', requireAnonymousAuth, requestLineController.submitRequestLine); +// Rate limited by device ID after authentication +request_line_route.post('/', requireAnonymousAuth, songRequestRateLimit, requestLineController.submitRequestLine); + +// Parse only - for debugging AI parser (requires anonymous auth) +request_line_route.post('/parse', requireAnonymousAuth, requestLineController.parseMessage); diff --git a/apps/backend/services/activityTracking.service.ts b/apps/backend/services/activityTracking.service.ts new file mode 100644 index 0000000..86419dc --- /dev/null +++ b/apps/backend/services/activityTracking.service.ts @@ -0,0 +1,41 @@ +import { db, user_activity } from '@wxyc/database'; +import { eq, sql } from 'drizzle-orm'; + +/** + * Records activity for a user, incrementing their request count and updating lastSeenAt. + * Uses upsert to handle both new and existing users. + * + * @param userId - The user ID to record activity for + */ +export async function recordActivity(userId: string): Promise { + await db + .insert(user_activity) + .values({ + userId, + requestCount: 1, + lastSeenAt: new Date(), + createdAt: new Date(), + }) + .onConflictDoUpdate({ + target: user_activity.userId, + set: { + requestCount: sql`${user_activity.requestCount} + 1`, + lastSeenAt: new Date(), + }, + }); +} + +/** + * Gets activity data for a user. + * + * @param userId - The user ID to get activity for + * @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); + return result[0] || null; +} diff --git a/apps/backend/services/ai/index.ts b/apps/backend/services/ai/index.ts new file mode 100644 index 0000000..72da3fb --- /dev/null +++ b/apps/backend/services/ai/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for AI services. + */ + +export { parseRequest, isParserAvailable, resetGroqClient } from './parser.service.js'; +export { SYSTEM_PROMPT, USER_PROMPT_TEMPLATE, formatUserPrompt } from './prompts.js'; diff --git a/apps/backend/services/ai/parser.service.ts b/apps/backend/services/ai/parser.service.ts new file mode 100644 index 0000000..b5c0007 --- /dev/null +++ b/apps/backend/services/ai/parser.service.ts @@ -0,0 +1,124 @@ +/** + * AI Parser Service - Groq LLM integration for parsing song requests. + * + * Ported from request-parser services/parser.py + */ + +import Groq from 'groq-sdk'; +import { MessageType, ParsedRequest } from '../requestLine/types.js'; +import { SYSTEM_PROMPT, formatUserPrompt } from './prompts.js'; +import { getConfig } from '../requestLine/config.js'; + +/** + * Groq client singleton. + */ +let _groqClient: Groq | null = null; + +/** + * Get or create the Groq client. + */ +function getGroqClient(): Groq { + if (!_groqClient) { + const config = getConfig(); + if (!config.groqApiKey) { + throw new Error('GROQ_API_KEY is not configured'); + } + _groqClient = new Groq({ apiKey: config.groqApiKey }); + } + return _groqClient; +} + +/** + * Reset the Groq client (useful for testing). + */ +export function resetGroqClient(): void { + _groqClient = null; +} + +/** + * Raw response from the AI parser. + */ +interface RawParsedResponse { + song?: string | null; + album?: string | null; + artist?: string | null; + is_request?: boolean; + message_type?: string; +} + +/** + * Validate and normalize the message type from AI response. + */ +function normalizeMessageType(type: string | undefined): MessageType { + if (!type) return MessageType.OTHER; + const normalized = type.toLowerCase(); + switch (normalized) { + case 'request': + return MessageType.REQUEST; + case 'dj_message': + return MessageType.DJ_MESSAGE; + case 'feedback': + return MessageType.FEEDBACK; + default: + return MessageType.OTHER; + } +} + +/** + * Parse a listener message and extract song request metadata. + * + * @param message - The raw listener message + * @returns Parsed request with extracted metadata + * @throws Error if Groq API fails or returns invalid response + */ +export async function parseRequest(message: string): Promise { + const config = getConfig(); + + console.log(`[AI Parser] Parsing message: ${message.slice(0, 100)}...`); + + const client = getGroqClient(); + + try { + const response = await client.chat.completions.create({ + model: config.groqModel, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: formatUserPrompt(message) }, + ], + response_format: { type: 'json_object' }, + temperature: 0.1, + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('Empty response from Groq'); + } + + const parsed: RawParsedResponse = JSON.parse(content); + console.log(`[AI Parser] Raw parsed response:`, JSON.stringify(parsed)); + + return { + song: parsed.song ?? null, + album: parsed.album ?? null, + artist: parsed.artist ?? null, + isRequest: parsed.is_request ?? false, + messageType: normalizeMessageType(parsed.message_type), + rawMessage: message, + }; + } catch (error) { + if (error instanceof SyntaxError) { + console.error(`[AI Parser] Failed to parse JSON response:`, error); + throw new Error(`Invalid JSON response from Groq: ${error.message}`); + } + console.error(`[AI Parser] Error parsing request:`, error); + throw error; + } +} + +/** + * Check if AI parsing is available. + */ +export function isParserAvailable(): boolean { + const config = getConfig(); + return !!config.groqApiKey; +} diff --git a/apps/backend/services/ai/prompts.ts b/apps/backend/services/ai/prompts.ts new file mode 100644 index 0000000..cecadf0 --- /dev/null +++ b/apps/backend/services/ai/prompts.ts @@ -0,0 +1,47 @@ +/** + * System prompts for AI parsing. + * + * Ported from request-parser services/parser.py + */ + +/** + * System prompt for the song request parser. + */ +export const SYSTEM_PROMPT = `You are a parser for a radio station's song request system. Extract structured metadata from listener messages. + +For each message, determine: +1. **song**: The specific song title requested, or null if not specified (e.g., "any song by X") +2. **album**: The album name, or null if not specified +3. **artist**: The artist/band name, or null if not specified +4. **is_request**: true if the listener wants the DJ to play something, false otherwise +5. **message_type**: One of: + - "request": A song/artist/album request + - "dj_message": Conversational message to the DJ (may also contain a request) + - "feedback": Thanks, complaints, technical issues + - "other": Unclassifiable + +Guidelines: +- Normalize artist/song/album names to proper title case +- Preserve intentional stylization like asterisks, numbers, or special characters in artist/song/album names (e.g., "Quix*o*tic" stays "Quix*o*tic", "P!nk" stays "P!nk", "deadmau5" stays "deadmau5") +- Ignore parenthetical asides like "(rip Mani)" or "(2021 remaster)" +- Correct obvious typos when you can confidently identify the intended artist/song, but don't remove intentional special characters +- If someone says "anything by X" or "any song off Y album", that's still a request +- A message can be both a dj_message AND contain a request (is_request: true) +- Terse messages like "song title. artist name.", "song - artist", or "song title, artist name" should extract both song and artist +- When in doubt about whether something is a song title or album, prefer treating it as a song title + +Respond with valid JSON only, no markdown formatting.`; + +/** + * Template for the user prompt. + */ +export const USER_PROMPT_TEMPLATE = `Parse this message: + +{message}`; + +/** + * Format the user prompt with the message. + */ +export function formatUserPrompt(message: string): string { + return USER_PROMPT_TEMPLATE.replace('{message}', message); +} diff --git a/apps/backend/services/artwork/finder.ts b/apps/backend/services/artwork/finder.ts new file mode 100644 index 0000000..7591a5c --- /dev/null +++ b/apps/backend/services/artwork/finder.ts @@ -0,0 +1,162 @@ +/** + * ArtworkFinder - Orchestrates artwork search across multiple providers. + * + * Ported from request-parser artwork/finder.py + */ + +import { ArtworkProvider, discogsProvider } from './providers/index.js'; +import { + ArtworkRequest, + ArtworkResponse, + ArtworkSearchResult, + EnrichedLibraryResult, +} from '../requestLine/types.js'; +import { isCompilationArtist } from '../requestLine/matching/index.js'; +import { getConfig } from '../requestLine/config.js'; + +/** + * Orchestrates artwork search across multiple providers. + */ +export class ArtworkFinder { + private providers: ArtworkProvider[]; + + constructor(providers?: ArtworkProvider[]) { + // Default to Discogs provider + this.providers = providers || [discogsProvider]; + } + + /** + * Find artwork for the given request. + * + * Tries each provider in order and returns the best result + * based on confidence score. + */ + async find(request: ArtworkRequest): Promise { + if (!request.song && !request.album && !request.artist) { + console.warn('[ArtworkFinder] Empty request - no fields to search'); + return this.emptyResponse(); + } + + const allResults: ArtworkSearchResult[] = []; + + for (const provider of this.providers) { + try { + const results = await provider.search(request); + allResults.push(...results); + console.log(`[ArtworkFinder] Provider ${provider.name} returned ${results.length} results`); + } catch (error) { + console.error(`[ArtworkFinder] Provider ${provider.name} failed:`, error); + continue; + } + } + + if (allResults.length === 0) { + console.log('[ArtworkFinder] No artwork found from any provider'); + return this.emptyResponse(); + } + + // Sort by confidence and return the best match + allResults.sort((a, b) => b.confidence - a.confidence); + const best = allResults[0]; + + console.log( + `[ArtworkFinder] Best match: ${best.artist} - ${best.album} ` + + `(confidence: ${best.confidence.toFixed(2)}, source: ${best.source})` + ); + + return { + artworkUrl: best.artworkUrl, + releaseUrl: best.releaseUrl, + album: best.album, + artist: best.artist, + source: best.source, + confidence: best.confidence, + }; + } + + /** + * Create an empty artwork response. + */ + private emptyResponse(): ArtworkResponse { + return { + artworkUrl: null, + releaseUrl: null, + album: null, + artist: null, + source: null, + confidence: 0, + }; + } +} + +/** + * Singleton finder instance. + */ +let _finder: ArtworkFinder | null = null; + +/** + * Get the artwork finder instance. + */ +export function getArtworkFinder(): ArtworkFinder { + if (!_finder) { + _finder = new ArtworkFinder(); + } + return _finder; +} + +/** + * Reset the artwork finder (useful for testing). + */ +export function resetArtworkFinder(): void { + _finder = null; +} + +/** + * Fetch artwork for multiple library items in parallel. + * + * @param items - List of library items + * @param discogsTitles - Optional map of item ID to Discogs album title + * @returns List of [item, artwork] tuples + */ +export async function fetchArtworkForItems( + items: EnrichedLibraryResult[], + discogsTitles?: Map +): Promise> { + const config = getConfig(); + + if (!config.enableArtworkLookup) { + return items.map((item) => [item, null]); + } + + const finder = getArtworkFinder(); + const discogsTitlesMap = discogsTitles || new Map(); + + 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; + + // For compilations, simplify artist to "Various" for Discogs lookup + // Library formats like "Various Artists - Rock - C" won't match Discogs + let artist = item.artist; + if (isCompilationArtist(artist)) { + artist = 'Various'; + } + + const result = await finder.find({ + album: album || undefined, + artist: artist || undefined, + }); + + return result; + } catch (error) { + console.warn(`[ArtworkFinder] Lookup failed for ${item.title}:`, error); + return null; + } + }; + + const artworkResults = await Promise.all(items.map(fetchOne)); + return items.map((item, index) => [item, artworkResults[index]]); +} diff --git a/apps/backend/services/artwork/index.ts b/apps/backend/services/artwork/index.ts new file mode 100644 index 0000000..14d09d4 --- /dev/null +++ b/apps/backend/services/artwork/index.ts @@ -0,0 +1,12 @@ +/** + * Barrel export for artwork services. + */ + +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/base.ts b/apps/backend/services/artwork/providers/base.ts new file mode 100644 index 0000000..c8c768e --- /dev/null +++ b/apps/backend/services/artwork/providers/base.ts @@ -0,0 +1,23 @@ +/** + * Base interface for artwork providers. + * + * Ported from request-parser artwork/providers/base.py + */ + +import { ArtworkRequest, ArtworkSearchResult } from '../../requestLine/types.js'; + +/** + * Interface for artwork providers. + */ +export interface ArtworkProvider { + /** Provider name for attribution */ + readonly name: string; + + /** + * Search for album artwork matching the request. + * + * @param request - The artwork request containing song/album/artist info + * @returns List of search results, ordered by relevance. Empty list if no results found. + */ + search(request: ArtworkRequest): Promise; +} diff --git a/apps/backend/services/artwork/providers/discogs.ts b/apps/backend/services/artwork/providers/discogs.ts new file mode 100644 index 0000000..74142f1 --- /dev/null +++ b/apps/backend/services/artwork/providers/discogs.ts @@ -0,0 +1,144 @@ +/** + * Discogs artwork provider. + * + * Delegates to DiscogsService for API calls to avoid code duplication. + * Ported from request-parser artwork/providers/discogs.py + */ + +import { ArtworkProvider } from './base.js'; +import { ArtworkRequest, ArtworkSearchResult } from '../../requestLine/types.js'; +import { DiscogsService, isDiscogsAvailable } from '../../discogs/index.js'; +import { calculateConfidence } from '../../requestLine/matching/index.js'; + +/** + * Artwork provider using the Discogs API. + */ +export class DiscogsProvider implements ArtworkProvider { + readonly name = 'discogs'; + + /** + * Search Discogs for album artwork. + */ + async search(request: ArtworkRequest): Promise { + if (!isDiscogsAvailable()) { + console.warn('[DiscogsProvider] Discogs token not configured'); + return []; + } + + // Check if there's anything to search for + if (!request.artist && !request.album && !request.song) { + console.warn('[DiscogsProvider] No searchable fields in request'); + return []; + } + + // Delegate to service + const response = await DiscogsService.search({ + artist: request.artist, + album: request.album, + track: request.song, + }); + + // Convert results to ArtworkSearchResult format + const results: ArtworkSearchResult[] = []; + for (const item of response.results) { + // Skip results without artwork + if (!item.artworkUrl || item.artworkUrl.includes('spacer.gif')) { + continue; + } + + // Calculate confidence score for this result + const confidence = calculateConfidence( + request.artist, + request.album, + item.artist || '', + item.album || '' + ); + + results.push({ + artworkUrl: item.artworkUrl, + releaseUrl: item.releaseUrl, + album: item.album || '', + artist: item.artist || '', + source: this.name, + confidence, + }); + } + + // Sort by confidence + results.sort((a, b) => b.confidence - a.confidence); + return results; + } + + /** + * Search Discogs for a track and return the album name. + */ + async searchTrack(track: string, artist?: string): Promise { + if (!isDiscogsAvailable()) { + return null; + } + + const result = await DiscogsService.searchTrack(track, artist); + return result.album; + } + + /** + * Search Discogs for ALL releases containing a track. + * + * For Various Artists / compilation releases, validates the tracklist + * to ensure the track by the artist actually exists on the release. + * + * @returns List of [artist, album] tuples for releases containing the track. + */ + async searchReleasesByTrack( + track: string, + artist?: string, + limit = 20 + ): Promise> { + if (!isDiscogsAvailable()) { + return []; + } + + const response = await DiscogsService.searchReleasesByTrack(track, artist, limit); + + // If searching with artist, validate compilation releases + const releases: Array<[string, string]> = []; + 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 + ); + if (!isValid) { + console.log( + `[DiscogsProvider] Skipping '${releaseInfo.album}' - track/artist not validated on release` + ); + continue; + } + } + + releases.push([releaseInfo.artist, releaseInfo.album]); + } + + return releases; + } + + /** + * Validate that a track by an artist exists on a release. + */ + async validateTrackOnRelease( + releaseId: number, + track: string, + artist: string + ): Promise { + if (!isDiscogsAvailable()) { + return false; + } + + return DiscogsService.validateTrackOnRelease(releaseId, track, artist); + } +} + +// Singleton instance +export const discogsProvider = new DiscogsProvider(); diff --git a/apps/backend/services/artwork/providers/index.ts b/apps/backend/services/artwork/providers/index.ts new file mode 100644 index 0000000..b093a6f --- /dev/null +++ b/apps/backend/services/artwork/providers/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for artwork providers. + */ + +export type { ArtworkProvider } from './base.js'; +export { DiscogsProvider, discogsProvider } from './discogs.js'; diff --git a/apps/backend/services/discogs/cache.ts b/apps/backend/services/discogs/cache.ts new file mode 100644 index 0000000..1b95257 --- /dev/null +++ b/apps/backend/services/discogs/cache.ts @@ -0,0 +1,131 @@ +/** + * LRU Cache utilities for Discogs API responses. + * + * Uses lru-cache for TTL-based caching to prevent rate limiting + * and improve performance. + * + * Ported from request-parser discogs/cache.py + */ + +import { LRUCache } from 'lru-cache'; +import crypto from 'crypto'; +import { getConfig } from '../requestLine/config.js'; + +/** + * Generate a deterministic cache key from function name and arguments. + */ +export function makeCacheKey(funcName: string, args: unknown[]): string { + const keyData = { + fn: funcName, + args: args, + }; + const keyString = JSON.stringify(keyData, Object.keys(keyData).sort()); + return crypto.createHash('md5').update(keyString).digest('hex'); +} + +/** + * Cache instances for different types of Discogs requests. + */ +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 { + if (!trackCache) { + const config = getConfig(); + trackCache = new LRUCache({ + max: config.discogsCacheMaxSize, + ttl: config.discogsCacheTtlTrack * 1000, // Convert seconds to ms + }); + } + return trackCache; +} + +/** + * Get or create the release metadata cache. + */ +export function getReleaseCache(): LRUCache { + if (!releaseCache) { + const config = getConfig(); + // Release cache uses half the maxsize since entries are larger + releaseCache = new LRUCache({ + max: Math.floor(config.discogsCacheMaxSize / 2), + ttl: config.discogsCacheTtlRelease * 1000, + }); + } + return releaseCache; +} + +/** + * Get or create the general search cache. + */ +export function getSearchCache(): LRUCache { + if (!searchCache) { + const config = getConfig(); + searchCache = new LRUCache({ + max: config.discogsCacheMaxSize, + ttl: config.discogsCacheTtlSearch * 1000, + }); + } + return searchCache; +} + +/** + * Clear all caches. + */ +export function clearAllCaches(): void { + trackCache?.clear(); + releaseCache?.clear(); + searchCache?.clear(); +} + +/** + * Reset all caches (recreate with fresh config). + */ +export function resetAllCaches(): void { + trackCache = null; + releaseCache = null; + searchCache = null; +} + +/** + * Create a cached version of an async function. + * + * @param cache - LRU cache to use + * @param funcName - Function name for cache key + * @param fn - Async function to cache + */ +export function cached( + cache: LRUCache, + funcName: string, + fn: (...args: unknown[]) => Promise +): (...args: unknown[]) => Promise { + return async (...args: unknown[]): Promise => { + const key = makeCacheKey(funcName, args); + + // Check cache + const cached = cache.get(key); + if (cached !== undefined) { + 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 T; + } + + // Cache miss - call function + console.log(`[Discogs Cache] Miss for ${funcName}`); + const result = await fn(...args); + + // Don't cache null/undefined results + if (result !== null && result !== undefined) { + cache.set(key, result); + } + + return result as T & { cached?: boolean }; + }; +} diff --git a/apps/backend/services/discogs/client.ts b/apps/backend/services/discogs/client.ts new file mode 100644 index 0000000..d04e7f1 --- /dev/null +++ b/apps/backend/services/discogs/client.ts @@ -0,0 +1,285 @@ +/** + * Shared Discogs API HTTP client with rate limiting. + * + * Implements a token bucket rate limiter (60 requests/minute) to comply + * with Discogs API limits and prevent 429 errors. Used by DiscogsService + * and available for direct use by other services (e.g., metadata service). + * + * Test mode: When USE_MOCK_SERVICES=true, API calls return mock responses + * without hitting the real Discogs API. + */ + +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { getConfig } from '../requestLine/config.js'; + +const DISCOGS_API_BASE = 'https://api.discogs.com'; +const USER_AGENT = 'WXYCBackendService/1.0'; + +/** + * Check if mock services are enabled (for testing). + */ +function isMockMode(): boolean { + return process.env.USE_MOCK_SERVICES === 'true'; +} + +/** + * Token bucket rate limiter. + * + * Allows bursts of requests up to the bucket capacity, then throttles + * to maintain the target rate over time. + */ +class TokenBucketRateLimiter { + private tokens: number; + private lastRefill: number; + private readonly capacity: number; + private readonly refillRate: number; // tokens per millisecond + + constructor(requestsPerMinute: number) { + this.capacity = requestsPerMinute; + this.tokens = requestsPerMinute; + this.lastRefill = Date.now(); + // Convert requests/minute to tokens/millisecond + this.refillRate = requestsPerMinute / 60000; + } + + /** + * Refill tokens based on elapsed time. + */ + private refill(): void { + const now = Date.now(); + const elapsed = now - this.lastRefill; + const tokensToAdd = elapsed * this.refillRate; + this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd); + this.lastRefill = now; + } + + /** + * Acquire a token, waiting if necessary. + * Returns a promise that resolves when a token is available. + */ + async acquire(): Promise { + this.refill(); + + if (this.tokens >= 1) { + this.tokens -= 1; + return; + } + + // Calculate wait time until a token is available + const waitTime = Math.ceil((1 - this.tokens) / this.refillRate); + console.log(`[Discogs Rate Limiter] Throttled, waiting ${waitTime}ms for token`); + await this.sleep(waitTime); + + // Refill and take token + this.refill(); + this.tokens -= 1; + console.log(`[Discogs Rate Limiter] Token acquired after wait (${Math.floor(this.tokens)} remaining)`); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Get current token count (for debugging/monitoring). + */ + getTokenCount(): number { + this.refill(); + return this.tokens; + } + + /** + * Reset the rate limiter to full capacity. + */ + reset(): void { + this.tokens = this.capacity; + this.lastRefill = Date.now(); + } +} + +/** + * Discogs API client configuration. + */ +export interface DiscogsClientConfig { + apiKey: string; + apiSecret: string; + requestsPerMinute?: number; +} + +/** + * Shared Discogs HTTP client with built-in rate limiting. + */ +class DiscogsClient { + private axiosInstance: AxiosInstance | null = null; + private rateLimiter: TokenBucketRateLimiter | null = null; + private currentApiKey: string | null = null; + private currentApiSecret: string | null = null; + private requestsPerMinute: number = 60; + + /** + * Initialize or reinitialize the client with credentials. + */ + private ensureClient(): AxiosInstance { + const config = getConfig(); + + if (!config.discogsApiKey || !config.discogsApiSecret) { + throw new Error('DISCOGS_API_KEY and DISCOGS_API_SECRET are required'); + } + + // Reinitialize if credentials changed + if ( + !this.axiosInstance || + this.currentApiKey !== config.discogsApiKey || + this.currentApiSecret !== config.discogsApiSecret + ) { + this.currentApiKey = config.discogsApiKey; + this.currentApiSecret = config.discogsApiSecret; + + this.axiosInstance = axios.create({ + baseURL: DISCOGS_API_BASE, + timeout: 10000, + headers: { + Authorization: `Discogs key=${config.discogsApiKey}, secret=${config.discogsApiSecret}`, + 'User-Agent': USER_AGENT, + }, + }); + + // Initialize rate limiter if not already done + if (!this.rateLimiter) { + this.rateLimiter = new TokenBucketRateLimiter(this.requestsPerMinute); + } + + console.log('[Discogs Client] Initialized with rate limiter'); + } + + return this.axiosInstance; + } + + /** + * Make a rate-limited GET request to the Discogs API. + * + * In mock mode (USE_MOCK_SERVICES=true), returns empty mock responses + * without hitting the real API. + */ + async get(url: string, config?: AxiosRequestConfig): Promise> { + // Mock mode for testing + if (isMockMode()) { + console.log(`[Discogs Client] Mock mode - would request: ${url}`); + return { + data: this.getMockResponse(url), + status: 200, + statusText: 'OK', + headers: {}, + config: config as AxiosRequestConfig, + } as AxiosResponse; + } + + const client = this.ensureClient(); + + // Acquire rate limit token before making request + await this.rateLimiter!.acquire(); + + return client.get(url, config); + } + + /** + * Generate mock responses based on the URL pattern. + */ + private getMockResponse(url: string): T { + if (url.startsWith('/releases/')) { + return { + id: 123, + title: 'Mock Release', + artists: [{ id: 1, name: 'Mock Artist' }], + year: 2024, + genres: ['Electronic'], + styles: ['Ambient'], + tracklist: [], + images: [], + } as T; + } + if (url.startsWith('/masters/')) { + return { + id: 456, + title: 'Mock Master', + artists: [{ id: 1, name: 'Mock Artist' }], + year: 2024, + } as T; + } + if (url.startsWith('/artists/')) { + return { + id: 1, + name: 'Mock Artist', + profile: 'A mock artist for testing', + } as T; + } + if (url.includes('/database/search')) { + return { + results: [], + pagination: { page: 1, pages: 0, per_page: 50, items: 0 }, + } as T; + } + return {} as T; + } + + /** + * Get the underlying Axios instance (for advanced use cases). + * Note: Direct use bypasses rate limiting. + */ + getAxiosInstance(): AxiosInstance { + return this.ensureClient(); + } + + /** + * Check if the client is configured and ready. + */ + isConfigured(): boolean { + const config = getConfig(); + return !!(config.discogsApiKey && config.discogsApiSecret); + } + + /** + * Get current rate limiter token count (for monitoring). + */ + getRateLimitTokens(): number { + return this.rateLimiter?.getTokenCount() ?? 0; + } + + /** + * Reset the client state. Call in beforeEach/afterEach for clean test state. + */ + reset(): void { + this.axiosInstance = null; + this.rateLimiter = null; + this.currentApiKey = null; + this.currentApiSecret = null; + } +} + +/** + * Shared Discogs client singleton. + */ +export const discogsClient = new DiscogsClient(); + +/** + * Reset the Discogs client. Only intended for use in tests. + * Call this in beforeEach/afterEach to get a clean slate. + */ +export function resetDiscogsClient(): void { + discogsClient.reset(); +} + +/** + * Parse Discogs title format 'Artist - Album' into components. + * Exported as a shared utility. + */ +export function parseTitle(title: string): { artist: string; album: string } { + if (title.includes(' - ')) { + const parts = title.split(' - '); + return { + artist: parts[0].trim(), + album: parts.slice(1).join(' - ').trim(), + }; + } + return { artist: '', album: title }; +} diff --git a/apps/backend/services/discogs/discogs.service.ts b/apps/backend/services/discogs/discogs.service.ts new file mode 100644 index 0000000..3937e98 --- /dev/null +++ b/apps/backend/services/discogs/discogs.service.ts @@ -0,0 +1,545 @@ +/** + * Discogs API Service with caching. + * + * Provides track search, release lookup, and artwork discovery. + * Ported from request-parser discogs/service.py + */ + +import axios from 'axios'; +import { getConfig } from '../requestLine/config.js'; +import { getTrackCache, getReleaseCache, getSearchCache, makeCacheKey } from './cache.js'; +import { calculateConfidence, isCompilationArtist } from '../requestLine/matching/index.js'; +import { + DiscogsTrackAlbumResponse, + DiscogsReleaseInfo, + DiscogsTrackReleasesResponse, + DiscogsReleaseMetadata, + DiscogsSearchRequest, + DiscogsSearchResult, + DiscogsSearchResponse, + DiscogsTrackItem, +} from '../requestLine/types.js'; +import { + RawDiscogsSearchResult, + RawDiscogsRelease, + RawDiscogsSearchResponse, + RawDiscogsMaster, + RawDiscogsArtist, +} from './types.js'; +import { discogsClient, parseTitle } from './client.js'; + +/** + * Discogs API service singleton. + * + * Uses the shared discogsClient for rate-limited HTTP requests. + */ +class DiscogsServiceClass { + /** + * Search for a track and return the album that contains it. + */ + async searchTrack(track: string, artist?: string): Promise { + const cache = getTrackCache(); + const cacheKey = makeCacheKey('searchTrack', [track, artist]); + + const cached = cache.get(cacheKey) as DiscogsTrackAlbumResponse | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for searchTrack: ${track}`); + return { ...cached, cached: true }; + } + + const params: Record = { + type: 'release', + track: track, + per_page: 5, + }; + if (artist) { + params.artist = artist; + } + + console.log(`[Discogs] Searching for track: ${track}, artist: ${artist}`); + + try { + const response = await discogsClient.get('/database/search', { params }); + + const results = response.data.results || []; + if (results.length > 0) { + const result = results[0]; + const { artist: resultArtist, album } = parseTitle(result.title); + const releaseId = result.id; + const releaseUrl = `https://www.discogs.com/release/${releaseId}`; + + console.log(`[Discogs] Found album '${album}' for track '${track}'`); + const trackResponse: DiscogsTrackAlbumResponse = { + album, + artist: resultArtist, + releaseId, + releaseUrl, + cached: false, + }; + cache.set(cacheKey, trackResponse); + return trackResponse; + } + + return { album: null, artist: null, releaseId: null, releaseUrl: null, cached: false }; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else { + console.error('[Discogs] Track search failed:', error); + } + return { album: null, artist: null, releaseId: null, releaseUrl: null, cached: false }; + } + } + + /** + * Search for ALL releases containing a track. + */ + async searchReleasesByTrack( + track: string, + artist?: string, + limit = 20 + ): Promise { + const cache = getTrackCache(); + const cacheKey = makeCacheKey('searchReleasesByTrack', [track, artist, limit]); + + const cached = cache.get(cacheKey) as DiscogsTrackReleasesResponse | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for searchReleasesByTrack: ${track}`); + return { ...cached, cached: true }; + } + + const releases: DiscogsReleaseInfo[] = []; + const seenAlbums = new Set(); + + // First search with track parameter + const params: Record = { + type: 'release', + track: track, + per_page: limit, + }; + if (artist) { + params.artist = artist; + } + + console.log(`[Discogs] Searching for releases with track: '${track}', artist: ${artist}`); + + try { + const response = await discogsClient.get('/database/search', { params }); + + for (const result of response.data.results || []) { + const releaseInfo = this.processSearchResult(result, seenAlbums); + if (releaseInfo) { + releases.push(releaseInfo); + } + } + + console.log(`[Discogs] Track search found ${releases.length} releases`); + + // Supplement with keyword search if few results + if (releases.length < 3) { + const queryParts = [track]; + if (artist) { + queryParts.push(artist); + } + + const keywordParams: Record = { + type: 'release', + q: queryParts.join(' '), + per_page: limit, + }; + + console.log(`[Discogs] Supplementing with keyword search: '${keywordParams.q}'`); + const keywordResponse = await discogsClient.get('/database/search', { + params: keywordParams, + }); + + for (const result of keywordResponse.data.results || []) { + const releaseInfo = this.processSearchResult(result, seenAlbums); + if (releaseInfo) { + releases.push(releaseInfo); + } + } + + console.log(`[Discogs] After keyword search: ${releases.length} total releases`); + } + + const trackResponse: DiscogsTrackReleasesResponse = { + track, + artist: artist || null, + releases: releases.slice(0, limit), + total: releases.length, + cached: false, + }; + + cache.set(cacheKey, trackResponse); + return trackResponse; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else { + console.error('[Discogs] Search failed:', error); + } + return { + track, + artist: artist || null, + releases: [], + total: 0, + cached: false, + }; + } + } + + /** + * Process a single search result into a DiscogsReleaseInfo. + */ + private processSearchResult( + result: RawDiscogsSearchResult, + seenAlbums: Set + ): DiscogsReleaseInfo | null { + const { artist, album } = parseTitle(result.title); + + if (!album) { + return null; + } + + const albumKey = album.toLowerCase(); + if (seenAlbums.has(albumKey)) { + return null; + } + + seenAlbums.add(albumKey); + + return { + album, + artist, + releaseId: result.id, + releaseUrl: `https://www.discogs.com/release/${result.id}`, + isCompilation: isCompilationArtist(artist), + }; + } + + /** + * Get full release metadata by ID. + */ + async getRelease(releaseId: number): Promise { + const cache = getReleaseCache(); + const cacheKey = makeCacheKey('getRelease', [releaseId]); + + const cached = cache.get(cacheKey) as DiscogsReleaseMetadata | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for getRelease: ${releaseId}`); + return { ...cached, cached: true }; + } + + try { + const response = await discogsClient.get(`/releases/${releaseId}`); + const data = response.data; + + // Extract artists + const artistName = data.artists?.[0]?.name || ''; + + // Extract labels + const labelName = data.labels?.[0]?.name || null; + + // Extract tracklist + const tracklist: DiscogsTrackItem[] = (data.tracklist || []).map((t) => ({ + position: t.position || '', + title: t.title || '', + duration: t.duration, + })); + + // Extract artwork + const artworkUrl = data.images?.[0]?.uri || null; + + const releaseMetadata: DiscogsReleaseMetadata = { + releaseId, + title: data.title || '', + artist: artistName, + year: data.year || null, + label: labelName, + genres: data.genres || [], + styles: data.styles || [], + tracklist, + artworkUrl, + releaseUrl: `https://www.discogs.com/release/${releaseId}`, + cached: false, + }; + + cache.set(cacheKey, releaseMetadata); + return releaseMetadata; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else { + console.error(`[Discogs] Failed to fetch release ${releaseId}:`, error); + } + return null; + } + } + + /** + * General release search for artwork discovery. + */ + async search(request: DiscogsSearchRequest, limit = 5): Promise { + const cache = getSearchCache(); + const cacheKey = makeCacheKey('search', [request, limit]); + + const cached = cache.get(cacheKey) as DiscogsSearchResponse | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for search`); + return { ...cached, cached: true }; + } + + const params = this.buildSearchParams(request, limit); + if (Object.keys(params).length === 2) { + // Only has type and per_page + console.warn('[Discogs] No searchable fields in request'); + return { results: [], total: 0, cached: false }; + } + + console.log(`[Discogs] Searching with params:`, params); + + try { + 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) + ) { + const queryParts: string[] = []; + if (request.artist) queryParts.push(request.artist); + if (request.album) queryParts.push(request.album); + + const fallbackParams = { + type: 'release', + per_page: limit, + q: queryParts.join(' '), + }; + + console.log(`[Discogs] Strict search empty, trying fuzzy query:`, fallbackParams); + response = await discogsClient.get('/database/search', { + params: fallbackParams, + }); + } + + const results: DiscogsSearchResult[] = []; + for (const item of response.data.results || []) { + let coverUrl = item.thumb || null; + if (coverUrl && coverUrl.includes('spacer.gif')) { + coverUrl = null; + } + + const { artist, album } = parseTitle(item.title); + + const confidence = calculateConfidence( + request.artist, + request.album, + artist, + album + ); + + const releaseUrl = `https://www.discogs.com/release/${item.id}`; + + results.push({ + album: album || null, + artist: artist || null, + releaseId: item.id, + releaseUrl, + artworkUrl: coverUrl, + confidence, + }); + } + + // Sort by confidence + results.sort((a, b) => b.confidence - a.confidence); + + const searchResponse: DiscogsSearchResponse = { + results, + total: results.length, + cached: false, + }; + + cache.set(cacheKey, searchResponse); + return searchResponse; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else { + console.error('[Discogs] Search failed:', error); + } + return { results: [], total: 0, cached: false }; + } + } + + /** + * Build search params using Discogs-specific fields. + */ + private buildSearchParams( + request: DiscogsSearchRequest, + limit: number + ): Record { + const params: Record = { + type: 'release', + per_page: limit, + }; + + if (request.artist) { + params.artist = request.artist; + } + if (request.album) { + params.release_title = request.album; + } else if (request.track) { + params.release_title = request.track; + } + + return params; + } + + /** + * Validate that a track by an artist exists on a release. + */ + async validateTrackOnRelease( + releaseId: number, + track: string, + artist: string + ): Promise { + const release = await this.getRelease(releaseId); + if (!release) { + return false; + } + + const trackLower = track.toLowerCase(); + const artistLower = artist.toLowerCase(); + + for (const item of release.tracklist) { + const itemTitle = item.title.toLowerCase(); + // Check if track title matches + if (!trackLower.includes(itemTitle) && !itemTitle.includes(trackLower)) { + continue; + } + + // For single-artist releases, check release artist + let releaseArtist = release.artist.toLowerCase(); + // Remove Discogs numbering like "(2)" + releaseArtist = releaseArtist.split('(')[0].trim(); + + if (artistLower.includes(releaseArtist) || releaseArtist.includes(artistLower)) { + console.log( + `[Discogs] Validated: '${track}' by '${artist}' found on release ${releaseId}` + ); + return true; + } + } + + console.log(`[Discogs] Track '${track}' by '${artist}' NOT found on release ${releaseId}`); + return false; + } + + /** + * Search for releases by artist (for song-as-artist fallback). + */ + async searchReleasesByArtist( + artist: string, + limit = 10 + ): Promise> { + const params = { + type: 'release', + artist: artist, + per_page: limit, + }; + + try { + const response = await discogsClient.get('/database/search', { params }); + + const releases: Array<{ artist: string; album: string }> = []; + for (const result of response.data.results || []) { + const { artist: resultArtist, album } = parseTitle(result.title); + if (album) { + releases.push({ artist: resultArtist, album }); + } + } + + return releases; + } catch (error) { + console.error('[Discogs] Artist releases search failed:', error); + return []; + } + } + + /** + * Get master release by ID. + * Masters represent the canonical version of a release across all pressings. + */ + async getMaster(masterId: number): Promise { + const cache = getReleaseCache(); + const cacheKey = makeCacheKey('getMaster', [masterId]); + + const cached = cache.get(cacheKey) as RawDiscogsMaster | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for getMaster: ${masterId}`); + return cached; + } + + try { + const response = await discogsClient.get(`/masters/${masterId}`); + const data = response.data; + + console.log(`[Discogs] Fetched master: ${data.title} (${masterId})`); + cache.set(cacheKey, data); + return data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else if (axios.isAxiosError(error) && error.response?.status === 404) { + console.log(`[Discogs] Master ${masterId} not found`); + } else { + console.error(`[Discogs] Failed to fetch master ${masterId}:`, error); + } + return null; + } + } + + /** + * Get artist by ID. + */ + async getArtist(artistId: number): Promise { + const cache = getReleaseCache(); + const cacheKey = makeCacheKey('getArtist', [artistId]); + + const cached = cache.get(cacheKey) as RawDiscogsArtist | undefined; + if (cached) { + console.log(`[Discogs] Cache hit for getArtist: ${artistId}`); + return cached; + } + + try { + const response = await discogsClient.get(`/artists/${artistId}`); + const data = response.data; + + console.log(`[Discogs] Fetched artist: ${data.name} (${artistId})`); + cache.set(cacheKey, data); + return data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 429) { + console.warn('[Discogs] Rate limit hit'); + } else if (axios.isAxiosError(error) && error.response?.status === 404) { + console.log(`[Discogs] Artist ${artistId} not found`); + } else { + console.error(`[Discogs] Failed to fetch artist ${artistId}:`, error); + } + return null; + } + } +} + +// Singleton instance +export const DiscogsService = new DiscogsServiceClass(); + +/** + * Check if Discogs service is available. + */ +export function isDiscogsAvailable(): boolean { + const config = getConfig(); + return !!(config.discogsApiKey && config.discogsApiSecret); +} diff --git a/apps/backend/services/discogs/index.ts b/apps/backend/services/discogs/index.ts new file mode 100644 index 0000000..3c1650e --- /dev/null +++ b/apps/backend/services/discogs/index.ts @@ -0,0 +1,14 @@ +/** + * Barrel export for Discogs service. + */ + +export { DiscogsService, isDiscogsAvailable } from './discogs.service.js'; +export { discogsClient, parseTitle, resetDiscogsClient } from './client.js'; +export { + getTrackCache, + getReleaseCache, + getSearchCache, + clearAllCaches, + resetAllCaches, +} from './cache.js'; +export * from './types.js'; diff --git a/apps/backend/services/discogs/types.ts b/apps/backend/services/discogs/types.ts new file mode 100644 index 0000000..08cde4b --- /dev/null +++ b/apps/backend/services/discogs/types.ts @@ -0,0 +1,106 @@ +/** + * Type definitions for Discogs API responses. + * + * Ported from request-parser discogs/models.py + */ + +export { + DiscogsTrackItem, + DiscogsTrackAlbumResponse, + DiscogsReleaseInfo, + DiscogsTrackReleasesResponse, + DiscogsReleaseMetadata, + DiscogsSearchRequest, + DiscogsSearchResult, + DiscogsSearchResponse, +} from '../requestLine/types.js'; + +/** + * Raw Discogs API search result. + */ +export interface RawDiscogsSearchResult { + id: number; + title: string; + thumb?: string; + cover_image?: string; + type?: string; + year?: string; + country?: string; + format?: string[]; + label?: string[]; + genre?: string[]; + style?: string[]; + resource_url?: string; +} + +/** + * Raw Discogs API release response. + */ +export interface RawDiscogsRelease { + id: number; + title: string; + artists?: Array<{ name: string; id: number }>; + year?: number; + labels?: Array<{ name: string; id: number }>; + genres?: string[]; + styles?: string[]; + tracklist?: Array<{ + position: string; + title: string; + duration?: string; + artists?: Array<{ name: string }>; + }>; + images?: Array<{ uri: string; type: string }>; +} + +/** + * Discogs API search response structure. + */ +export interface RawDiscogsSearchResponse { + pagination?: { + page: number; + pages: number; + per_page: number; + items: number; + }; + results: RawDiscogsSearchResult[]; +} + +/** + * Raw Discogs API master release response. + * Masters represent the canonical version of a release across all pressings. + */ +export interface RawDiscogsMaster { + id: number; + title: string; + year?: number; + artists?: Array<{ id: number; name: string }>; + images?: Array<{ type: string; uri: string; width?: number; height?: number }>; + genres?: string[]; + styles?: string[]; + tracklist?: Array<{ + position: string; + title: string; + duration?: string; + }>; + main_release?: number; + main_release_url?: string; + versions_url?: string; + num_for_sale?: number; + lowest_price?: number; +} + +/** + * Raw Discogs API artist response. + */ +export interface RawDiscogsArtist { + id: number; + name: string; + profile?: string; + urls?: string[]; + images?: Array<{ type: string; uri: string; width?: number; height?: number }>; + members?: Array<{ id: number; name: string; active?: boolean }>; + groups?: Array<{ id: number; name: string; active?: boolean }>; + namevariations?: string[]; + releases_url?: string; +} diff --git a/apps/backend/services/library.service.ts b/apps/backend/services/library.service.ts index e8ae054..a62615b 100644 --- a/apps/backend/services/library.service.ts +++ b/apps/backend/services/library.service.ts @@ -13,7 +13,10 @@ import { library, library_artist_view, rotation, + LibraryArtistViewEntry, } from "@wxyc/database"; +import { LibraryResult, EnrichedLibraryResult, enrichLibraryResult } from './requestLine/types.js'; +import { extractSignificantWords } from './requestLine/matching/index.js'; export const getFormatsFromDB = async () => { const formats = await db @@ -220,3 +223,244 @@ export const isISODate = (date: string): boolean => { const regex = /^\d{4}-\d{2}-\d{2}$/; return date.match(regex) !== null; }; + +// ============================================================================= +// Request Line Enhanced Search Functions +// ============================================================================= + +/** + * Convert a library_artist_view row to LibraryResult. + */ +function viewRowToLibraryResult(row: LibraryArtistViewEntry): LibraryResult { + return { + id: row.id, + title: row.album_title, + artist: row.artist_name, + codeLetters: row.code_letters, + codeArtistNumber: row.code_artist_number, + codeNumber: row.code_number, + genre: row.genre_name, + format: row.format_name, + }; +} + +/** + * Search the library catalog with flexible query options. + * + * Uses PostgreSQL pg_trgm for fuzzy matching. Supports: + * - Combined artist + album/song queries + * - Artist-only queries + * - Album/title-only queries + * + * @param query - Free text search query (artist and/or album) + * @param artist - Artist name filter + * @param title - Album/title filter + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchLibrary( + query?: string, + artist?: string, + title?: string, + limit = 5 +): Promise { + let results: LibraryArtistViewEntry[] = []; + + if (query) { + // Full text search using pg_trgm similarity + const searchQuery = sql` + SELECT *, + similarity(${library_artist_view.artist_name}, ${query}) as artist_sim, + similarity(${library_artist_view.album_title}, ${query}) as album_sim + FROM ${library_artist_view} + WHERE ${library_artist_view.artist_name} % ${query} + OR ${library_artist_view.album_title} % ${query} + ORDER BY GREATEST( + similarity(${library_artist_view.artist_name}, ${query}), + similarity(${library_artist_view.album_title}, ${query}) + ) DESC + LIMIT ${limit} + `; + + const response = await db.execute(searchQuery); + results = response.rows as LibraryArtistViewEntry[]; + + // If no results with trigram, try LIKE fallback with significant words + if (results.length === 0) { + const words = extractSignificantWords(query); + if (words.length > 0) { + // Build LIKE conditions for each word + const likeConditions = words + .map((w) => `(artist_name ILIKE '%${w}%' OR album_title ILIKE '%${w}%')`) + .join(' AND '); + + const fallbackQuery = sql.raw(` + SELECT * FROM wxyc_schema.library_artist_view + WHERE ${likeConditions} + LIMIT ${limit} + `); + + const fallbackResponse = await db.execute(fallbackQuery); + results = fallbackResponse.rows 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[]; + } + + return results.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Find a similar artist name in the library using fuzzy matching. + * + * Useful for correcting typos or spelling variants (e.g., "Color" vs "Colour"). + * + * @param artistName - Artist name to match + * @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 { + // Use pg_trgm similarity function to find close matches + const query = sql` + SELECT DISTINCT artist_name, + similarity(artist_name, ${artistName}) as sim + FROM ${library_artist_view} + WHERE similarity(artist_name, ${artistName}) > ${threshold} + ORDER BY sim DESC + LIMIT 1 + `; + + const response = await db.execute(query); + const rows = response.rows as Array<{ artist_name: string; sim: number }>; + + if (rows.length > 0) { + const match = rows[0]; + // Only return if it's actually different (i.e., a correction) + if (match.artist_name.toLowerCase() !== artistName.toLowerCase()) { + console.log( + `[Library] Corrected artist '${artistName}' to '${match.artist_name}' (similarity: ${match.sim.toFixed(2)})` + ); + return match.artist_name; + } + } + + return null; +} + +/** + * Search for albums by title with fuzzy matching. + * + * Useful for cross-referencing Discogs album titles with the library. + * + * @param albumTitle - Album title to search for + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchAlbumsByTitle( + albumTitle: string, + limit = 5 +): Promise { + const query = sql` + SELECT *, + similarity(${library_artist_view.album_title}, ${albumTitle}) as sim + FROM ${library_artist_view} + WHERE ${library_artist_view.album_title} % ${albumTitle} + ORDER BY sim DESC + LIMIT ${limit} + `; + + const response = await db.execute(query); + const rows = response.rows as LibraryArtistViewEntry[]; + + // If no trigram matches, try keyword search + if (rows.length === 0) { + 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 fallbackQuery = sql.raw(` + SELECT * FROM wxyc_schema.library_artist_view + WHERE ${likeConditions} + LIMIT ${limit} + `); + + const fallbackResponse = await db.execute(fallbackQuery); + return (fallbackResponse.rows as LibraryArtistViewEntry[]).map((row) => + enrichLibraryResult(viewRowToLibraryResult(row)) + ); + } + } + + return rows.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Search the library for releases by a specific artist. + * + * @param artistName - Artist name to search for + * @param limit - Maximum results to return + * @returns Array of enriched library results + */ +export async function searchByArtist( + artistName: string, + limit = 5 +): Promise { + const query = sql` + SELECT *, + similarity(${library_artist_view.artist_name}, ${artistName}) as sim + FROM ${library_artist_view} + WHERE ${library_artist_view.artist_name} % ${artistName} + ORDER BY sim DESC + LIMIT ${limit} + `; + + const response = await db.execute(query); + const rows = response.rows as LibraryArtistViewEntry[]; + + return rows.map((row) => enrichLibraryResult(viewRowToLibraryResult(row))); +} + +/** + * Filter library results to only include those matching the artist. + * + * Requires the searched artist name to appear at the START of the result's + * artist field (case-insensitive). This prevents false positives like + * "Toy" matching "Chew Toy" while still allowing "Various" to match + * "Various Artists - Rock - D". + * + * @param results - List of library items from search + * @param artist - Artist name to filter by + * @returns Filtered list containing only items where artist matches + */ +export function filterResultsByArtist( + results: EnrichedLibraryResult[], + artist: string | null | undefined +): EnrichedLibraryResult[] { + if (!artist) { + return results; + } + + const artistLower = artist.toLowerCase(); + const filtered = results.filter((item) => { + const itemArtist = (item.artist || '').toLowerCase(); + // Check if result's artist starts with searched artist + return itemArtist.startsWith(artistLower); + }); + + if (filtered.length < results.length) { + console.log( + `[Library] Filtered ${results.length} results to ${filtered.length} matching artist '${artist}'` + ); + } + + return filtered; +} diff --git a/apps/backend/services/requestLine.service.ts b/apps/backend/services/requestLine.service.ts index 600f095..8051cee 100644 --- a/apps/backend/services/requestLine.service.ts +++ b/apps/backend/services/requestLine.service.ts @@ -17,6 +17,11 @@ export const submitRequestLine = async ( ): Promise<{ success: boolean; message?: string; statusCode?: number; response?: string }> => { // Use mock in test environments if (process.env.USE_MOCK_SERVICES === 'true') { + // Allow simulating Slack failures in test mode + if (process.env.SIMULATE_SLACK_FAILURE === 'true') { + console.log('[RequestLine Service] Mock mode - simulating Slack failure'); + return { success: false, statusCode: 500, response: 'Mock: Simulated Slack failure' }; + } console.log('[RequestLine Service] Mock mode - would send to Slack:', message); return { success: true, message: 'Mock: Message sent to Slack successfully' }; } diff --git a/apps/backend/services/requestLine/config.ts b/apps/backend/services/requestLine/config.ts new file mode 100644 index 0000000..a428b22 --- /dev/null +++ b/apps/backend/services/requestLine/config.ts @@ -0,0 +1,110 @@ +/** + * Configuration for Request Line NLP + Library Search feature. + * + * All configuration is loaded from environment variables with sensible defaults. + */ + +export interface RequestLineConfig { + // AI Parsing (Required - requests fail without this) + groqApiKey: string | undefined; + groqModel: string; + + // Discogs (Optional - artwork/compilation search degrades gracefully) + discogsApiKey: string | undefined; + discogsApiSecret: string | undefined; + discogsCacheTtlTrack: number; + discogsCacheTtlRelease: number; + discogsCacheTtlSearch: number; + discogsCacheMaxSize: number; + + // Feature Flags + enableArtworkLookup: boolean; + enableLibrarySearch: boolean; + + // Search behavior + maxSearchResults: number; + artistSimilarityThreshold: number; +} + +/** + * Load configuration from environment variables. + */ +export function loadConfig(): RequestLineConfig { + return { + // AI Parsing + groqApiKey: process.env.GROQ_API_KEY, + groqModel: process.env.GROQ_MODEL || 'llama-3.1-8b-instant', + + // Discogs + discogsApiKey: process.env.DISCOGS_API_KEY, + discogsApiSecret: process.env.DISCOGS_API_SECRET, + discogsCacheTtlTrack: parseInt(process.env.DISCOGS_CACHE_TTL_TRACK || '3600', 10), // 1 hour + discogsCacheTtlRelease: parseInt(process.env.DISCOGS_CACHE_TTL_RELEASE || '14400', 10), // 4 hours + discogsCacheTtlSearch: parseInt(process.env.DISCOGS_CACHE_TTL_SEARCH || '3600', 10), // 1 hour + discogsCacheMaxSize: parseInt(process.env.DISCOGS_CACHE_MAX_SIZE || '1000', 10), + + // Feature Flags + enableArtworkLookup: process.env.ENABLE_ARTWORK_LOOKUP !== 'false', + enableLibrarySearch: process.env.ENABLE_LIBRARY_SEARCH !== 'false', + + // Search behavior + maxSearchResults: parseInt(process.env.MAX_SEARCH_RESULTS || '5', 10), + artistSimilarityThreshold: parseFloat(process.env.ARTIST_SIMILARITY_THRESHOLD || '0.85'), + }; +} + +/** + * Validate that required configuration is present. + * Returns an array of error messages for missing/invalid config. + */ +export function validateConfig(config: RequestLineConfig): string[] { + const errors: string[] = []; + + // AI parsing is mandatory according to the plan + if (!config.groqApiKey) { + errors.push('GROQ_API_KEY is required for AI parsing'); + } + + // 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'); + } + + return errors; +} + +/** + * Check if AI parsing is available. + */ +export function isParsingEnabled(config: RequestLineConfig): boolean { + return !!config.groqApiKey; +} + +/** + * Check if Discogs integration is available. + */ +export function isDiscogsEnabled(config: RequestLineConfig): boolean { + return !!(config.discogsApiKey && config.discogsApiSecret); +} + +/** + * Singleton config instance. + */ +let _config: RequestLineConfig | null = null; + +/** + * Get the configuration, loading it if necessary. + */ +export function getConfig(): RequestLineConfig { + if (!_config) { + _config = loadConfig(); + } + return _config; +} + +/** + * Reset the configuration (useful for testing). + */ +export function resetConfig(): void { + _config = null; +} diff --git a/apps/backend/services/requestLine/index.ts b/apps/backend/services/requestLine/index.ts new file mode 100644 index 0000000..0d29734 --- /dev/null +++ b/apps/backend/services/requestLine/index.ts @@ -0,0 +1,18 @@ +/** + * Barrel export for Request Line services. + */ + +// Main orchestration +export { processRequest, parseOnly } from './requestLine.enhanced.service.js'; + +// Types +export * from './types.js'; + +// Config +export { getConfig, loadConfig, validateConfig, isParsingEnabled, isDiscogsEnabled } from './config.js'; + +// Matching utilities +export * from './matching/index.js'; + +// Search pipeline +export { executeSearchPipeline, getSearchTypeFromState } from './search/index.js'; diff --git a/apps/backend/services/requestLine/matching/ambiguous.ts b/apps/backend/services/requestLine/matching/ambiguous.ts new file mode 100644 index 0000000..c759fb3 --- /dev/null +++ b/apps/backend/services/requestLine/matching/ambiguous.ts @@ -0,0 +1,51 @@ +/** + * Ambiguous format detection for "X - Y" or "X. Y" patterns. + * + * Ported from request-parser core/matching.py + */ + +/** + * Result of detecting an ambiguous format. + */ +export interface AmbiguousParts { + part1: string; + part2: string; +} + +/** + * Detect if message has ambiguous 'X - Y' or 'X. Y' format. + * + * These formats are ambiguous because they could be interpreted as either: + * - Artist: X, Title: Y + * - Title: X, Artist: Y + * + * @param rawMessage - The original request message + * @returns Object with part1 and part2 if ambiguous format detected, null otherwise + */ +export function detectAmbiguousFormat(rawMessage: string): AmbiguousParts | null { + // Check for "X - Y" pattern (with spaces around dash) + if (rawMessage.includes(' - ')) { + const parts = rawMessage.split(' - '); + if (parts.length >= 2) { + const part1 = parts[0].trim(); + const part2 = parts.slice(1).join(' - ').trim(); + if (part1 && part2) { + return { part1, part2 }; + } + } + } + + // Check for "X. Y" pattern (period followed by space) + if (rawMessage.includes('. ')) { + const parts = rawMessage.split('. '); + if (parts.length >= 2) { + const part1 = parts[0].trim(); + const part2 = parts.slice(1).join('. ').trim(); + if (part1 && part2) { + return { part1, part2 }; + } + } + } + + return null; +} diff --git a/apps/backend/services/requestLine/matching/compilation.ts b/apps/backend/services/requestLine/matching/compilation.ts new file mode 100644 index 0000000..22564bc --- /dev/null +++ b/apps/backend/services/requestLine/matching/compilation.ts @@ -0,0 +1,35 @@ +/** + * Compilation detection - keywords indicating compilation/soundtrack albums. + * + * Ported from request-parser core/matching.py + */ + +/** + * Keywords indicating a compilation/soundtrack album (case-insensitive substring match). + */ +export const COMPILATION_KEYWORDS = new Set([ + 'various', + 'soundtrack', + 'compilation', + 'v/a', + 'v.a.', +]); + +/** + * Check if an artist name indicates a compilation/soundtrack album. + * + * @param artist - Artist name to check + * @returns True if artist contains compilation keywords + */ +export function isCompilationArtist(artist: string | null | undefined): boolean { + if (!artist) { + return false; + } + const artistLower = artist.toLowerCase(); + for (const keyword of COMPILATION_KEYWORDS) { + if (artistLower.includes(keyword)) { + return true; + } + } + return false; +} diff --git a/apps/backend/services/requestLine/matching/confidence.ts b/apps/backend/services/requestLine/matching/confidence.ts new file mode 100644 index 0000000..2cccca4 --- /dev/null +++ b/apps/backend/services/requestLine/matching/confidence.ts @@ -0,0 +1,73 @@ +/** + * Confidence scoring for search result matching. + * + * Ported from request-parser core/matching.py + */ + +/** + * Normalize a string for comparison (lowercase, trimmed). + */ +function normalize(s: string | null | undefined): string { + return s ? s.toLowerCase().trim() : ''; +} + +/** + * Calculate confidence score for how well a search result matches a request. + * + * Scoring rules: + * - Exact artist match: +0.4 + * - Partial artist match (substring): +0.3 + * - Exact album match: +0.4 + * - Partial album match (substring): +0.3 + * - Both fields match well (score >= 0.6): +0.2 bonus + * - Minimum score for any result: 0.2 + * + * @param requestArtist - Artist from the search request + * @param requestAlbum - Album from the search request + * @param resultArtist - Artist from the search result + * @param resultAlbum - Album from the search result + * @returns Confidence score between 0.2 and 1.0 + */ +export function calculateConfidence( + requestArtist: string | null | undefined, + requestAlbum: string | null | undefined, + resultArtist: string, + resultAlbum: string +): number { + let score = 0.0; + + const reqArtist = normalize(requestArtist); + const reqAlbum = normalize(requestAlbum); + const resArtist = normalize(resultArtist); + const resAlbum = normalize(resultAlbum); + + // Artist match + if (reqArtist && resArtist) { + if (reqArtist === resArtist) { + score += 0.4; + } else if (reqArtist.includes(resArtist) || resArtist.includes(reqArtist)) { + score += 0.3; + } + } + + // Album match + if (reqAlbum && resAlbum) { + if (reqAlbum === resAlbum) { + score += 0.4; + } else if (reqAlbum.includes(resAlbum) || resAlbum.includes(reqAlbum)) { + score += 0.3; + } + } + + // Bonus for both matches + if (score >= 0.6) { + score += 0.2; + } + + // Base score if we got any result + if (score === 0) { + score = 0.2; + } + + return Math.min(score, 1.0); +} diff --git a/apps/backend/services/requestLine/matching/index.ts b/apps/backend/services/requestLine/matching/index.ts new file mode 100644 index 0000000..ae1ba8d --- /dev/null +++ b/apps/backend/services/requestLine/matching/index.ts @@ -0,0 +1,13 @@ +/** + * Barrel export for matching utilities. + */ + +export { STOPWORDS, extractSignificantWords } from './stopwords.js'; +export { COMPILATION_KEYWORDS, isCompilationArtist } from './compilation.js'; +export { calculateConfidence } from './confidence.js'; +export { detectAmbiguousFormat, type AmbiguousParts } from './ambiguous.js'; + +/** + * Maximum number of results to return from search operations. + */ +export const MAX_SEARCH_RESULTS = 5; diff --git a/apps/backend/services/requestLine/matching/stopwords.ts b/apps/backend/services/requestLine/matching/stopwords.ts new file mode 100644 index 0000000..ff7fe7f --- /dev/null +++ b/apps/backend/services/requestLine/matching/stopwords.ts @@ -0,0 +1,46 @@ +/** + * Stopwords - words to exclude when extracting significant keywords from search queries. + * + * Ported from request-parser core/matching.py + */ + +export const STOPWORDS = new Set([ + // Articles + 'the', + 'a', + 'an', + // Conjunctions/prepositions + 'and', + 'with', + 'from', + // Demonstratives + 'that', + 'this', + // Request-specific noise + 'play', + 'song', + 'remix', + // Label/format noise + 'story', + 'records', +]); + +/** + * Extract significant words from a query string. + * Removes stopwords and words with length <= 1. + */ +export function extractSignificantWords(query: string, minLength = 2): string[] { + // Normalize: remove special chars, keep only alphanumeric and spaces + const normalized = query.toLowerCase().replace(/[^a-z0-9\s]/g, ' '); + const words = normalized.split(/\s+/).filter((w) => w.length > 0); + + // Remove stopwords that might cause mismatches + const significant = words.filter((w) => !STOPWORDS.has(w) && w.length >= minLength); + + // If we removed all words, use original words + if (significant.length === 0) { + return words.filter((w) => w.length >= minLength); + } + + return significant; +} diff --git a/apps/backend/services/requestLine/requestLine.enhanced.service.ts b/apps/backend/services/requestLine/requestLine.enhanced.service.ts new file mode 100644 index 0000000..4fa0cd3 --- /dev/null +++ b/apps/backend/services/requestLine/requestLine.enhanced.service.ts @@ -0,0 +1,301 @@ +/** + * Enhanced Request Line Service with AI parsing and library search. + * + * This is the main orchestration layer that: + * 1. Parses messages with AI + * 2. Searches the library + * 3. Fetches artwork + * 4. Posts to Slack with rich formatting + */ + +import { + ParsedRequest, + EnrichedLibraryResult, + ArtworkResponse, + UnifiedRequestResponse, + RequestLineRequestBody, + MESSAGE_TYPE_LABELS, + MessageType, +} 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 { findSimilarArtist } from '../library.service.js'; +import { DiscogsService, isDiscogsAvailable } from '../discogs/index.js'; +import { fetchArtworkForItems } from '../artwork/index.js'; +import { + buildSlackBlocks, + buildSimpleSlackBlocks, + postBlocksToSlack, + postTextToSlack, + SlackPostResult, +} from '../slack/index.js'; +import { discogsProvider } from '../artwork/providers/index.js'; +import { MAX_SEARCH_RESULTS } from './matching/index.js'; + +/** + * Resolve album names for a track if not provided. + * + * Searches Discogs for ALL releases containing the track, not just the first one. + */ +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(); + + // 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` + ); + } + + if (!isDiscogsAvailable()) { + console.log('[RequestLine] Discogs not available for album lookup'); + return { albums: [], songNotFound: true }; + } + + try { + // Get ALL releases containing this track + const releases = await discogsProvider.searchReleasesByTrack( + parsed.song, + parsed.artist, + 10 + ); + + if (releases.length > 0) { + // Extract unique album names, filtering to releases by this artist + const albums: string[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const [releaseArtist, album] of releases) { + // Only include releases by the requested artist (not compilations) + if (releaseArtist.toLowerCase().startsWith(artistLower)) { + if (!albums.includes(album)) { + albums.push(album); + } + } + } + + if (albums.length > 0) { + console.log( + `[RequestLine] Found ${albums.length} albums for song '${parsed.song}': ${albums.join(', ')}` + ); + return { albums, songNotFound: false }; + } + } + + console.log(`[RequestLine] Could not find albums for song '${parsed.song}'`); + return { albums: [], songNotFound: true }; + } catch (error) { + console.warn(`[RequestLine] Track lookup failed:`, error); + return { albums: [], songNotFound: true }; + } + } + + return { albums: parsed.album ? [parsed.album] : [], songNotFound: false }; +} + +/** + * Build context message for Slack based on search results. + */ +function buildContextMessage( + parsed: ParsedRequest, + foundOnCompilation: boolean, + songNotFound: boolean, + hasResults: boolean +): string | undefined { + if (foundOnCompilation) { + return `Found "${parsed.song}" by ${parsed.artist} on:`; + } + + if (songNotFound && hasResults) { + // Show "here are other albums" only if we have results to show + if (parsed.song && parsed.album) { + return `"${parsed.album}" not found in the library, but here are other albums by ${parsed.artist}:`; + } else if (parsed.song) { + return `"${parsed.song}" is not on any album in the library, but here are some albums by ${parsed.artist}:`; + } + } else if (songNotFound && !hasResults) { + // No results at all after filtering + if (parsed.song && parsed.artist) { + return `"${parsed.song}" by ${parsed.artist} not found in library.`; + } + } + + return undefined; +} + +/** + * Post results to Slack with rich formatting. + */ +async function postResultsToSlack( + message: string, + parsed: ParsedRequest, + itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]>, + context?: string +): Promise { + if (itemsWithArtwork.length > 0) { + const blocks = buildSlackBlocks(message, itemsWithArtwork, context); + return postBlocksToSlack(blocks, message); + } else if (!parsed.isRequest) { + const label = MESSAGE_TYPE_LABELS[parsed.messageType] || 'Other'; + const blocks = buildSimpleSlackBlocks(message, `_${label}_`); + return postBlocksToSlack(blocks, message); + } else { + // Request but no results found + const contextParts: string[] = []; + if (parsed.artist) contextParts.push(`Artist: ${parsed.artist}`); + if (parsed.album) contextParts.push(`Album: ${parsed.album}`); + if (parsed.song) contextParts.push(`Song: ${parsed.song}`); + const ctx = contextParts.length > 0 ? contextParts.join(' | ') : undefined; + const blocks = buildSimpleSlackBlocks(message, `_No results found_ ${ctx || ''}`); + return postBlocksToSlack(blocks, message); + } +} + +/** + * Process a song request through the full pipeline. + * + * This is the main entry point for the enhanced request line service. + */ +export async function processRequest( + body: RequestLineRequestBody +): Promise { + const config = getConfig(); + const message = body.message.trim(); + + if (!message) { + throw new Error('Message cannot be empty'); + } + + let parsed: ParsedRequest; + let libraryResults: EnrichedLibraryResult[] = []; + let itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]> = []; + let songNotFound = false; + let foundOnCompilation = false; + let discogsTitles = new Map(); + let searchType = 'none'; + + // Step 1: Parse the message + if (!body.skipParsing && isParserAvailable()) { + try { + parsed = await parseRequest(message); + 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" + throw new Error(`AI parsing failed: ${error instanceof Error ? error.message : String(error)}`); + } + } else if (body.skipParsing) { + // Create a minimal parsed request when parsing is skipped + parsed = { + song: null, + album: null, + artist: null, + isRequest: true, + messageType: MessageType.REQUEST, + rawMessage: message, + }; + } else { + // AI parsing is required but not available + throw new Error('GROQ_API_KEY is not configured - AI parsing is required'); + } + + // Step 1b: Correct artist spelling + if (parsed.artist) { + try { + const correctedArtist = await findSimilarArtist(parsed.artist); + if (correctedArtist) { + console.log(`[RequestLine] Corrected artist: '${parsed.artist}' -> '${correctedArtist}'`); + parsed = { ...parsed, artist: correctedArtist }; + } + } catch (error) { + console.warn('[RequestLine] Artist correction failed:', error); + } + } + + // Step 2: Look up albums from Discogs if we have a song but no album + 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 : undefined, + albumsForSearch, + }); + + libraryResults = searchState.results.slice(0, MAX_SEARCH_RESULTS); + songNotFound = searchState.songNotFound; + foundOnCompilation = searchState.foundOnCompilation; + discogsTitles = searchState.discogsTitles; + searchType = getSearchTypeFromState(searchState); + } + + // Step 4: Fetch artwork for library items + if (libraryResults.length > 0 && config.enableArtworkLookup) { + try { + itemsWithArtwork = await fetchArtworkForItems(libraryResults, discogsTitles); + } catch (error) { + console.warn('[RequestLine] Artwork fetch failed:', error); + itemsWithArtwork = libraryResults.map((item) => [item, null]); + } + } else { + itemsWithArtwork = libraryResults.map((item) => [item, null]); + } + + // Step 5: Post to Slack (unless skipSlack is set) + let slackResult: SlackPostResult = { success: true, message: 'Slack posting skipped' }; + + if (!body.skipSlack) { + const context = buildContextMessage( + parsed, + foundOnCompilation, + songNotFound, + libraryResults.length > 0 + ); + + try { + slackResult = await postResultsToSlack(message, parsed, itemsWithArtwork, context); + } catch (error) { + console.error('[RequestLine] Slack posting failed:', error); + slackResult = { + success: false, + message: `Slack posting failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + // Extract main artwork from first result + const artwork = + itemsWithArtwork.find(([_, art]) => art !== null)?.[1] || null; + + return { + success: true, + parsed, + artwork, + libraryResults, + searchType, + result: slackResult, + }; +} + +/** + * Parse a message without searching or posting (for debugging). + */ +export async function parseOnly(message: string): Promise { + if (!isParserAvailable()) { + throw new Error('GROQ_API_KEY is not configured'); + } + return parseRequest(message); +} diff --git a/apps/backend/services/requestLine/search/index.ts b/apps/backend/services/requestLine/search/index.ts new file mode 100644 index 0000000..88b6df5 --- /dev/null +++ b/apps/backend/services/requestLine/search/index.ts @@ -0,0 +1,7 @@ +/** + * Barrel export for search module. + */ + +export { executeSearchPipeline, type PipelineOptions } from './pipeline.js'; +export { getSearchTypeFromState, createSearchState, type SearchState } from './state.js'; +export * from './strategies/index.js'; diff --git a/apps/backend/services/requestLine/search/pipeline.ts b/apps/backend/services/requestLine/search/pipeline.ts new file mode 100644 index 0000000..b6e951a --- /dev/null +++ b/apps/backend/services/requestLine/search/pipeline.ts @@ -0,0 +1,113 @@ +/** + * Search pipeline executor. + * + * Orchestrates the execution of search strategies in order until results are found. + * Ported from request-parser core/search.py execute_search_pipeline() + */ + +import { ParsedRequest, SearchState, SearchStrategyType, createSearchState } from '../types.js'; +import { MAX_SEARCH_RESULTS } from '../matching/index.js'; +import { + shouldRunArtistPlusAlbum, + executeArtistPlusAlbum, + shouldRunSwappedInterpretation, + executeSwappedInterpretation, + shouldRunTrackOnCompilation, + executeTrackOnCompilation, + shouldRunSongAsArtist, + executeSongAsArtist, +} from './strategies/index.js'; + +// Forward declaration for Discogs service +interface DiscogsService { + searchReleasesByTrack: ( + track: string, + artist?: string, + limit?: number + ) => Promise>; + searchReleasesByArtist: ( + artist: string, + limit?: number + ) => Promise>; + validateTrackOnRelease: (releaseId: number, track: string, artist: string) => Promise; +} + +export interface PipelineOptions { + /** Optional Discogs service for enhanced search */ + discogsService?: DiscogsService; + /** Album names resolved from Discogs track lookup */ + albumsForSearch?: string[]; +} + +/** + * Execute strategies in array order until results found. + * + * The pipeline tries strategies in this order: + * 1. ARTIST_PLUS_ALBUM - search by artist + album/song + * 2. SWAPPED_INTERPRETATION - try "X - Y" as both orderings + * 3. TRACK_ON_COMPILATION - find song on compilations via Discogs + * 4. SONG_AS_ARTIST - try parsed song as artist (parser misidentification) + * + * @param parsed - The parsed request with artist/song/album + * @param rawMessage - Original request message (for ambiguous format detection) + * @param options - Pipeline options including Discogs service + * @returns SearchState with results and metadata about the search + */ +export async function executeSearchPipeline( + parsed: ParsedRequest, + rawMessage: string, + options: PipelineOptions = {} +): Promise { + const { discogsService, albumsForSearch = [] } = options; + + const state = createSearchState(albumsForSearch); + + // Strategy 1: Artist + Album/Song + if (shouldRunArtistPlusAlbum(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.ARTIST_PLUS_ALBUM); + const [results, fallbackUsed] = await executeArtistPlusAlbum(parsed, state); + if (results.length > 0) { + state.results = results; + } + if (fallbackUsed) { + state.songNotFound = true; + } + } + + // Strategy 2: Swapped Interpretation (only if no results) + if (shouldRunSwappedInterpretation(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.SWAPPED_INTERPRETATION); + const results = await executeSwappedInterpretation(rawMessage); + if (results.length > 0) { + state.results = results; + state.songNotFound = false; + } + } + + // Strategy 3: Track on Compilation (if song not found but we have artist and song) + if (shouldRunTrackOnCompilation(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.TRACK_ON_COMPILATION); + const [results, discogsTitles] = await executeTrackOnCompilation(parsed, discogsService); + if (results.length > 0) { + state.results = results; + state.foundOnCompilation = true; + state.songNotFound = false; + state.discogsTitles = discogsTitles; + } + } + + // Strategy 4: Song as Artist (only if no results and song but no artist) + if (shouldRunSongAsArtist(parsed, state, rawMessage)) { + state.strategiesTried.push(SearchStrategyType.SONG_AS_ARTIST); + const results = await executeSongAsArtist(parsed.song!, discogsService); + if (results.length > 0) { + state.results = results; + state.songNotFound = false; + } + } + + // Limit final results + state.results = state.results.slice(0, MAX_SEARCH_RESULTS); + + return state; +} diff --git a/apps/backend/services/requestLine/search/state.ts b/apps/backend/services/requestLine/search/state.ts new file mode 100644 index 0000000..f6601f2 --- /dev/null +++ b/apps/backend/services/requestLine/search/state.ts @@ -0,0 +1,38 @@ +/** + * Search state management for the search pipeline. + * + * Ported from request-parser core/search.py + */ + +import { EnrichedLibraryResult, SearchState, SearchStrategyType, createSearchState } from '../types.js'; + +/** + * Get the search type string for telemetry from state. + */ +export function getSearchTypeFromState(state: SearchState): string { + if (state.foundOnCompilation) { + return 'compilation'; + } + + if (state.strategiesTried.length === 0) { + return 'none'; + } + + const lastStrategy = state.strategiesTried[state.strategiesTried.length - 1]; + + switch (lastStrategy) { + case SearchStrategyType.ARTIST_PLUS_ALBUM: + return state.songNotFound ? 'fallback' : 'direct'; + case SearchStrategyType.SWAPPED_INTERPRETATION: + return 'alternative'; + case SearchStrategyType.TRACK_ON_COMPILATION: + return 'compilation'; + case SearchStrategyType.SONG_AS_ARTIST: + return 'song_as_artist'; + default: + return 'none'; + } +} + +export { createSearchState }; +export type { SearchState }; diff --git a/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts new file mode 100644 index 0000000..67b2437 --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/artistPlusAlbum.ts @@ -0,0 +1,98 @@ +/** + * Artist + Album/Song search strategy. + * + * This is the primary search strategy that tries: + * 1. Artist + each album (from Discogs lookup) + * 2. Artist + song (song title might match album title) + * 3. Artist only (fallback) + * + * Ported from request-parser routers/request.py search_library_with_fallback() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist } from '../../../library.service.js'; +import { MAX_SEARCH_RESULTS } from '../../matching/index.js'; + +/** + * Check if this strategy should run. + */ +export function shouldRunArtistPlusAlbum( + parsed: ParsedRequest, + state: SearchState, + _rawMessage: string +): boolean { + return !!(parsed.artist && (state.albumsForSearch.length > 0 || parsed.song)); +} + +/** + * Execute the artist + album/song search strategy. + * + * @returns Tuple of [results, fallbackUsed] + */ +export async function executeArtistPlusAlbum( + parsed: ParsedRequest, + state: SearchState +): Promise<[EnrichedLibraryResult[], boolean]> { + const allResults: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + + // Search for each album from Discogs + if (parsed.artist && state.albumsForSearch.length > 0) { + for (const album of state.albumsForSearch) { + const query = `${parsed.artist} ${album}`; + const results = await searchLibrary(query, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + + // Add unique results + for (const item of filtered) { + if (!seenIds.has(item.id)) { + seenIds.add(item.id); + allResults.push(item); + } + } + } + + if (allResults.length > 0) { + // Sort to prioritize results matching the first (primary) album + const primaryAlbumLower = state.albumsForSearch[0].toLowerCase(); + allResults.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(primaryAlbumLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(primaryAlbumLower) ? 1 : 0; + return bMatches - aMatches; + }); + return [allResults.slice(0, MAX_SEARCH_RESULTS), false]; + } + } + + // If no albums from Discogs, try artist + song + if (parsed.artist && parsed.song) { + const query = `${parsed.artist} ${parsed.song}`; + const results = await searchLibrary(query, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + + if (filtered.length > 0) { + // Prioritize results where album title matches song title + const songLower = parsed.song.toLowerCase(); + filtered.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(songLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(songLower) ? 1 : 0; + return bMatches - aMatches; + }); + return [filtered, false]; + } + } + + // If still no results, try just artist (fallback) + if (allResults.length === 0 && parsed.artist) { + console.log( + `[Search] No results for albums ${JSON.stringify(state.albumsForSearch)}, trying artist only: '${parsed.artist}'` + ); + const results = await searchLibrary(parsed.artist, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, parsed.artist); + if (filtered.length > 0) { + return [filtered, true]; // fallback was used + } + } + + return [allResults, false]; +} diff --git a/apps/backend/services/requestLine/search/strategies/index.ts b/apps/backend/services/requestLine/search/strategies/index.ts new file mode 100644 index 0000000..b1905bf --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/index.ts @@ -0,0 +1,8 @@ +/** + * Barrel export for search strategies. + */ + +export { shouldRunArtistPlusAlbum, executeArtistPlusAlbum } from './artistPlusAlbum.js'; +export { shouldRunSwappedInterpretation, executeSwappedInterpretation } from './swappedInterpretation.js'; +export { shouldRunTrackOnCompilation, executeTrackOnCompilation } from './trackOnCompilation.js'; +export { shouldRunSongAsArtist, executeSongAsArtist } from './songAsArtist.js'; diff --git a/apps/backend/services/requestLine/search/strategies/songAsArtist.ts b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts new file mode 100644 index 0000000..8d087df --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/songAsArtist.ts @@ -0,0 +1,118 @@ +/** + * Song as Artist search strategy. + * + * Fallback strategy that tries using the parsed song title as an artist name. + * This handles cases where the AI parser misinterpreted an artist name + * as a song title (e.g., "Laid Back" parsed as song instead of artist). + * + * Ported from request-parser routers/request.py search_song_as_artist() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist, searchAlbumsByTitle } from '../../../library.service.js'; +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>; +}; + +/** + * Check if this strategy should run. + */ +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; +} + +/** + * Execute the song as artist search strategy. + * + * Strategy: + * 1. Search library for direct artist match + * 2. If no results and Discogs available, search Discogs for releases by that artist + * 3. Cross-reference Discogs album titles with library (for compilations) + * + * @param songAsArtist - The song title to try as an artist name + * @param discogsService - Optional Discogs service for cross-referencing + */ +export async function executeSongAsArtist( + songAsArtist: string, + discogsService?: DiscogsService +): Promise { + console.log(`[Search] Trying song '${songAsArtist}' as artist name`); + + // Step 1: Direct library search for artist + const results = await searchLibrary(songAsArtist, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered = filterResultsByArtist(results, songAsArtist); + if (filtered.length > 0) { + console.log(`[Search] Found ${filtered.length} results treating '${songAsArtist}' as artist`); + return filtered; + } + + // Step 2: Search Discogs for releases by this artist (if available) + if (!discogsService) { + return []; + } + + console.log(`[Search] No direct matches, searching Discogs for releases by '${songAsArtist}'`); + const discogsReleases = await discogsService.searchReleasesByArtist(songAsArtist, 10); + + if (discogsReleases.length === 0) { + console.log(`[Search] No Discogs releases found for '${songAsArtist}'`); + return []; + } + + console.log(`[Search] Found ${discogsReleases.length} Discogs releases for '${songAsArtist}'`); + + // Step 3: Cross-reference album titles with library + const crossRefResults: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + + for (const { artist: discogsArtist, album: albumTitle } of discogsReleases) { + if (!albumTitle) { + continue; + } + + // Search library for this album title + const albumResults = await searchAlbumsByTitle(albumTitle, MAX_SEARCH_RESULTS); + + for (const item of albumResults) { + if (seenIds.has(item.id)) { + continue; + } + + // Accept if it's the actual artist or a compilation + const itemArtist = (item.artist || '').toLowerCase(); + 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` + ); + } + } + + if (crossRefResults.length >= MAX_SEARCH_RESULTS) { + break; + } + } + + if (crossRefResults.length > 0) { + 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 new file mode 100644 index 0000000..b0f4603 --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/swappedInterpretation.ts @@ -0,0 +1,75 @@ +/** + * Swapped Interpretation search strategy. + * + * For ambiguous "X - Y" formats, tries both: + * - X as artist, Y as title + * - Y as artist, X as title + * + * Ported from request-parser routers/request.py search_with_alternative_interpretation() + */ + +import { ParsedRequest, EnrichedLibraryResult, SearchState, SearchStrategyType } from '../../types.js'; +import { searchLibrary, filterResultsByArtist } from '../../../library.service.js'; +import { detectAmbiguousFormat, MAX_SEARCH_RESULTS } from '../../matching/index.js'; + +/** + * Check if this strategy should run. + */ +export function shouldRunSwappedInterpretation( + _parsed: ParsedRequest, + state: SearchState, + rawMessage: string +): boolean { + // Only run if no results yet AND message has ambiguous X - Y format + if (state.results.length > 0) { + return false; + } + return detectAmbiguousFormat(rawMessage) !== null; +} + +/** + * Execute the swapped interpretation search strategy. + */ +export async function executeSwappedInterpretation( + rawMessage: string +): Promise { + const parts = detectAmbiguousFormat(rawMessage); + if (!parts) { + return []; + } + + const { part1, part2 } = parts; + + // Try interpretation 1: part1 = artist + const query1 = `${part1} ${part2}`; + const results1 = await searchLibrary(query1, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered1 = filterResultsByArtist(results1, part1); + + // Try interpretation 2: part2 = artist + const query2 = `${part2} ${part1}`; + const results2 = await searchLibrary(query2, undefined, undefined, MAX_SEARCH_RESULTS); + const filtered2 = filterResultsByArtist(results2, part2); + + // Return whichever has results (prefer the one with more/better matches) + if (filtered1.length > 0 && filtered2.length === 0) { + console.log(`[Search] Alternative search matched with '${part1}' as artist`); + return filtered1; + } else if (filtered2.length > 0 && filtered1.length === 0) { + console.log(`[Search] Alternative search matched with '${part2}' as artist`); + return filtered2; + } else if (filtered1.length > 0 && filtered2.length > 0) { + // Both have results - combine and dedupe by id + console.log(`[Search] Alternative search matched both interpretations, combining results`); + const seenIds = new Set(); + const combined: EnrichedLibraryResult[] = []; + for (const item of [...filtered1, ...filtered2]) { + if (!seenIds.has(item.id)) { + combined.push(item); + seenIds.add(item.id); + } + } + return combined.slice(0, MAX_SEARCH_RESULTS); + } + + return []; +} diff --git a/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts new file mode 100644 index 0000000..68b7a5e --- /dev/null +++ b/apps/backend/services/requestLine/search/strategies/trackOnCompilation.ts @@ -0,0 +1,212 @@ +/** + * Track on Compilation search strategy. + * + * Searches for track on compilation albums using Discogs and library keyword search. + * This handles cases where a song exists in the library but on a compilation or + * soundtrack rather than the artist's own album. + * + * Ported from request-parser routers/request.py search_compilations_for_track() + */ + +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'; + +// Forward declaration - will be imported when Discogs service is ready +type DiscogsService = { + searchReleasesByTrack: ( + track: string, + artist?: string, + limit?: number + ) => Promise>; + validateTrackOnRelease: (releaseId: number, track: string, artist: string) => Promise; +}; + +/** + * Check if this strategy should run. + */ +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; +} + +/** + * Execute the track on compilation search strategy. + * + * @param parsed - Parsed request + * @param discogsService - Optional Discogs service for cross-referencing + * @returns Tuple of [results, discogsTitles map] + */ +export async function executeTrackOnCompilation( + parsed: ParsedRequest, + discogsService?: DiscogsService +): Promise<[EnrichedLibraryResult[], Map]> { + if (!parsed.song || !parsed.artist) { + return [[], new Map()]; + } + + console.log(`[Search] Searching for '${parsed.song}' on other releases (compilations, etc.)`); + + const results: EnrichedLibraryResult[] = []; + const seenIds = new Set(); + const discogsTitles = new Map(); + + // First, try a direct library keyword search + let keywordMatches: EnrichedLibraryResult[] = []; + try { + const artistWords = extractSignificantWords(parsed.artist); + const songWords = extractSignificantWords(parsed.song); + + // Include both artist words (max 2) and song words (max 2) to find the right album + const queryWords = [...artistWords.slice(0, 2), ...songWords.slice(0, 2)]; + + if (queryWords.length > 0) { + const keywordQuery = queryWords.join(' '); + console.log(`[Search] Trying direct keyword search: '${keywordQuery}'`); + const keywordResults = await searchLibrary(keywordQuery, undefined, undefined, MAX_SEARCH_RESULTS); + + if (keywordResults.length > 0) { + // Filter by artist unless it's a compilation album + const filtered: EnrichedLibraryResult[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const item of keywordResults) { + const itemArtist = (item.artist || '').toLowerCase(); + if (itemArtist.startsWith(artistLower)) { + filtered.push(item); + } else if (isCompilationArtist(item.artist)) { + // Allow Various Artists/Soundtracks/Compilation albums + filtered.push(item); + } + } + + if (filtered.length > 0) { + 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; + } + } + } + } catch (e) { + console.warn(`[Search] Keyword search failed:`, e); + keywordMatches = []; + } + + // If Discogs service is available, use it for more accurate results + if (discogsService) { + try { + 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() + ) { + console.log(`[Search] Skipping '${release.album}' - appears to be artist name, not album`); + continue; + } + + // Skip very short album titles (likely artifacts) + if (release.album.trim().length < 3) { + continue; + } + + // For Various Artists / compilations, validate the tracklist + if (release.isCompilation) { + 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` + ); + continue; + } + } + + const matches = await searchAlbumsByTitle(release.album, MAX_SEARCH_RESULTS); + + // Filter matches to only include albums by the requested artist OR compilations + const filteredMatches: EnrichedLibraryResult[] = []; + const artistLower = parsed.artist.toLowerCase(); + + for (const match of matches) { + const matchArtist = (match.artist || '').toLowerCase(); + + // If Discogs says it's by the artist, only match artist albums + // If Discogs says it's a compilation, allow compilation matches + if (matchArtist.startsWith(artistLower)) { + filteredMatches.push(match); + } else if (release.isCompilation && isCompilationArtist(match.artist)) { + filteredMatches.push(match); + } + } + + if (filteredMatches.length > 0) { + console.log( + `[Search] Found '${parsed.song}' in library on '${filteredMatches[0].title}' ` + + `(matched from Discogs: '${release.album}')` + ); + // Add matches, deduplicating by ID + for (const match of filteredMatches) { + if (!seenIds.has(match.id)) { + results.push(match); + seenIds.add(match.id); + // Store the Discogs album title for artwork lookup + discogsTitles.set(match.id, release.album); + } + } + + if (results.length >= MAX_SEARCH_RESULTS) { + break; + } + } + } + } catch (e) { + console.warn(`[Search] Failed to search for track on other releases:`, e); + } + } + + // If Discogs didn't find anything, fall back to keyword matches + if (results.length === 0 && keywordMatches.length > 0) { + console.log(`[Search] Discogs search found nothing, using keyword matches as fallback`); + for (const item of keywordMatches.slice(0, 1)) { + if (!seenIds.has(item.id)) { + results.push(item); + seenIds.add(item.id); + } + } + } + + // Prioritize albums whose title matches the song title + if (results.length > 0 && parsed.song) { + const songLower = parsed.song.toLowerCase(); + results.sort((a, b) => { + const aMatches = (a.title || '').toLowerCase().includes(songLower) ? 1 : 0; + const bMatches = (b.title || '').toLowerCase().includes(songLower) ? 1 : 0; + return bMatches - aMatches; + }); + } + + return [results.slice(0, MAX_SEARCH_RESULTS), discogsTitles]; +} diff --git a/apps/backend/services/requestLine/types.ts b/apps/backend/services/requestLine/types.ts new file mode 100644 index 0000000..f0b219d --- /dev/null +++ b/apps/backend/services/requestLine/types.ts @@ -0,0 +1,340 @@ +/** + * Type definitions for the Request Line NLP + Library Search feature. + * + * These types are ported from the Python request-parser project + * and adapted for TypeScript/Express. + */ + +// ============================================================================= +// Message Parsing Types +// ============================================================================= + +/** + * Message type classification from AI parser. + */ +export enum MessageType { + REQUEST = 'request', + DJ_MESSAGE = 'dj_message', + FEEDBACK = 'feedback', + OTHER = 'other', +} + +/** + * Result of AI parsing a listener message. + */ +export interface ParsedRequest { + /** The specific song title requested, or null if not specified */ + song: string | null; + /** The album name, or null if not specified */ + album: string | null; + /** The artist/band name, or null if not specified */ + artist: string | null; + /** True if the listener wants the DJ to play something */ + isRequest: boolean; + /** Classification of the message type */ + messageType: MessageType; + /** The original unparsed message */ + rawMessage: string; +} + +// ============================================================================= +// Library Search Types +// ============================================================================= + +/** + * A single item from the library catalog. + */ +export interface LibraryResult { + /** Database ID */ + id: number; + /** Album title */ + title: string | null; + /** Artist name */ + artist: string | null; + /** Genre code letters (e.g., "RO" for Rock) */ + codeLetters: string | null; + /** Artist number within genre */ + codeArtistNumber: number | null; + /** Release number for this artist */ + codeNumber: number | null; + /** Genre name */ + genre: string | null; + /** Format name (CD, Vinyl, etc.) */ + format: string | null; +} + +/** + * Extended library result with computed fields. + */ +export interface EnrichedLibraryResult extends LibraryResult { + /** Full call number for shelf lookup: / */ + callNumber: string; + /** URL to view this release in the WXYC library */ + libraryUrl: string; +} + +/** + * Compute the call number from library result fields. + */ +export function computeCallNumber(result: LibraryResult): string { + const parts: string[] = []; + if (result.genre) parts.push(result.genre); + if (result.format) parts.push(result.format); + if (result.codeLetters) parts.push(result.codeLetters); + if (result.codeArtistNumber !== null) { + if (result.codeNumber !== null) { + parts.push(`${result.codeArtistNumber}/${result.codeNumber}`); + } else { + parts.push(String(result.codeArtistNumber)); + } + } + return parts.join(' '); +} + +/** + * Compute the library URL from a result ID. + */ +export function computeLibraryUrl(id: number): string { + return `http://www.wxyc.info/wxycdb/libraryRelease?id=${id}`; +} + +/** + * Enrich a library result with computed fields. + */ +export function enrichLibraryResult(result: LibraryResult): EnrichedLibraryResult { + return { + ...result, + callNumber: computeCallNumber(result), + libraryUrl: computeLibraryUrl(result.id), + }; +} + +// ============================================================================= +// Search Strategy Types +// ============================================================================= + +/** + * Descriptive names for each search strategy. + * Used in telemetry to track which strategy succeeded. + */ +export enum SearchStrategyType { + /** Search by artist + album/song name */ + ARTIST_PLUS_ALBUM = 'artist_plus_album', + /** Fallback to just artist name when album/song search fails */ + ARTIST_ONLY = 'artist_only', + /** Try "X - Y" format as both artist/title orderings */ + SWAPPED_INTERPRETATION = 'swapped_interpretation', + /** Find song on compilation albums via Discogs cross-reference */ + TRACK_ON_COMPILATION = 'track_on_compilation', + /** Fallback: try parsed song as artist when no results and no artist parsed */ + SONG_AS_ARTIST = 'song_as_artist', + /** Significant word extraction search */ + KEYWORD_MATCH = 'keyword_match', +} + +/** + * Tracks state across strategy execution. + */ +export interface SearchState { + /** Current search results */ + results: EnrichedLibraryResult[]; + /** True if the exact song/album wasn't found (fell back to artist-only) */ + songNotFound: boolean; + /** True if the song was found on a compilation album */ + foundOnCompilation: boolean; + /** List of strategies that have been executed */ + strategiesTried: SearchStrategyType[]; + /** Map of library item ID to Discogs album title (for artwork lookup) */ + discogsTitles: Map; + /** Album names resolved from Discogs track lookup (may contain multiple) */ + albumsForSearch: string[]; +} + +/** + * Create initial search state. + */ +export function createSearchState(albumsForSearch: string[] = []): SearchState { + return { + results: [], + songNotFound: false, + foundOnCompilation: false, + strategiesTried: [], + discogsTitles: new Map(), + albumsForSearch, + }; +} + +// ============================================================================= +// Artwork Types +// ============================================================================= + +/** + * Request to find album artwork. + */ +export interface ArtworkRequest { + song?: string; + album?: string; + artist?: string; +} + +/** + * Response containing artwork URL and metadata. + */ +export interface ArtworkResponse { + artworkUrl: string | null; + releaseUrl: string | null; + album: string | null; + artist: string | null; + source: string | null; + confidence: number; +} + +/** + * A single search result from an artwork provider. + */ +export interface ArtworkSearchResult { + artworkUrl: string; + releaseUrl: string; + album: string; + artist: string; + source: string; + confidence: number; +} + +// ============================================================================= +// Discogs Types +// ============================================================================= + +/** + * A single track on a release. + */ +export interface DiscogsTrackItem { + position: string; + title: string; + duration?: string; +} + +/** + * Response for track-to-album lookup. + */ +export interface DiscogsTrackAlbumResponse { + album: string | null; + artist: string | null; + releaseId: number | null; + releaseUrl: string | null; + cached: boolean; +} + +/** + * Information about a single release containing a track. + */ +export interface DiscogsReleaseInfo { + album: string; + artist: string; + releaseId: number; + releaseUrl: string; + isCompilation: boolean; +} + +/** + * Response for finding all releases containing a track. + */ +export interface DiscogsTrackReleasesResponse { + track: string; + artist: string | null; + releases: DiscogsReleaseInfo[]; + total: number; + cached: boolean; +} + +/** + * Full release metadata from Discogs. + */ +export interface DiscogsReleaseMetadata { + releaseId: number; + title: string; + artist: string; + year: number | null; + label: string | null; + genres: string[]; + styles: string[]; + tracklist: DiscogsTrackItem[]; + artworkUrl: string | null; + releaseUrl: string; + cached: boolean; +} + +/** + * Request for general Discogs search. + */ +export interface DiscogsSearchRequest { + artist?: string; + album?: string; + track?: string; +} + +/** + * A single result from Discogs search. + */ +export interface DiscogsSearchResult { + album: string | null; + artist: string | null; + releaseId: number; + releaseUrl: string; + artworkUrl: string | null; + confidence: number; +} + +/** + * Response for general Discogs search. + */ +export interface DiscogsSearchResponse { + results: DiscogsSearchResult[]; + total: number; + cached: boolean; +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +/** + * Combined response from parsing, artwork lookup, and library search. + */ +export interface UnifiedRequestResponse { + /** Whether the operation was successful */ + success: boolean; + /** Parsed request metadata */ + parsed: ParsedRequest; + /** Artwork information (best match) */ + artwork: ArtworkResponse | null; + /** Library search results */ + libraryResults: EnrichedLibraryResult[]; + /** Which search strategy succeeded */ + searchType: string; + /** Result from Slack posting */ + result: { success: boolean; message?: string }; +} + +/** + * Request body for song request parsing. + */ +export interface RequestLineRequestBody { + message: string; + skipSlack?: boolean; + skipParsing?: boolean; +} + +// ============================================================================= +// Friendly Labels +// ============================================================================= + +/** + * Human-readable labels for message types in Slack. + */ +export const MESSAGE_TYPE_LABELS: Record = { + [MessageType.REQUEST]: 'Song Request', + [MessageType.DJ_MESSAGE]: 'Message to DJ', + [MessageType.FEEDBACK]: 'Feedback', + [MessageType.OTHER]: 'Other', +}; diff --git a/apps/backend/services/slack/builder.ts b/apps/backend/services/slack/builder.ts new file mode 100644 index 0000000..95c0f1b --- /dev/null +++ b/apps/backend/services/slack/builder.ts @@ -0,0 +1,152 @@ +/** + * Slack message block builders for rich formatting. + * + * Ported from request-parser services/slack.py + */ + +import { EnrichedLibraryResult, ArtworkResponse } from '../requestLine/types.js'; + +/** + * Slack block type definitions. + */ +interface SlackTextBlock { + type: 'mrkdwn' | 'plain_text'; + text: string; +} + +interface SlackImageAccessory { + type: 'image'; + image_url: string; + alt_text: string; +} + +interface SlackSectionBlock { + type: 'section'; + text: SlackTextBlock; + accessory?: SlackImageAccessory; +} + +interface SlackContextBlock { + type: 'context'; + elements: SlackTextBlock[]; +} + +interface SlackDividerBlock { + type: 'divider'; +} + +export type SlackBlock = SlackSectionBlock | SlackContextBlock | SlackDividerBlock; + +/** + * Build Slack message blocks from library results with artwork. + * + * @param message - Original request message + * @param itemsWithArtwork - Library items paired with their artwork + * @param context - Optional context message (e.g., "song not found, showing artist albums") + */ +export function buildSlackBlocks( + message: string, + itemsWithArtwork: Array<[EnrichedLibraryResult, ArtworkResponse | null]>, + context?: string +): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${escapeSlackText(message)}*`, + }, + }, + ]; + + // Add context message if provided (e.g., "song not found, showing artist albums") + if (context) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: context }, + }); + } + + for (const [item, artwork] of itemsWithArtwork) { + // Build text with links to library and Discogs + const textLines = [ + `*${escapeSlackText(item.artist || 'Unknown Artist')}*`, + escapeSlackText(item.title || 'Unknown Title'), + `_${escapeSlackText(item.callNumber)}_`, + ]; + + if (artwork && artwork.releaseUrl) { + textLines.push( + `<${artwork.releaseUrl}|Discogs> | <${item.libraryUrl}|WXYC>` + ); + } else { + textLines.push(`<${item.libraryUrl}|WXYC Library>`); + } + + const block: SlackSectionBlock = { + type: 'section', + text: { + type: 'mrkdwn', + text: textLines.join('\n'), + }, + }; + + if (artwork && artwork.artworkUrl) { + block.accessory = { + type: 'image', + image_url: artwork.artworkUrl, + alt_text: `${item.title} album cover`, + }; + } + + blocks.push(block); + } + + return blocks; +} + +/** + * Build simple Slack message blocks for feedback or no-results messages. + * + * @param message - Original request message + * @param context - Optional context message + */ +export function buildSimpleSlackBlocks( + message: string, + context?: string +): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${escapeSlackText(message)}*`, + }, + }, + ]; + + if (context) { + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: context, + }, + ], + }); + } + + return blocks; +} + +/** + * Escape special characters in text for Slack mrkdwn format. + */ +function escapeSlackText(text: string): string { + // Escape &, <, > which have special meaning in Slack + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/apps/backend/services/slack/index.ts b/apps/backend/services/slack/index.ts new file mode 100644 index 0000000..00d97e9 --- /dev/null +++ b/apps/backend/services/slack/index.ts @@ -0,0 +1,15 @@ +/** + * Barrel export for Slack services. + */ + +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 new file mode 100644 index 0000000..105fec2 --- /dev/null +++ b/apps/backend/services/slack/slack.service.ts @@ -0,0 +1,121 @@ +/** + * Enhanced Slack service with block support. + * + * Extends the existing requestLine.service.ts functionality + * to support rich block messages. + */ + +import https from 'https'; +import { SlackBlock } from './builder.js'; + +const SLACK_TIMEOUT_MS = 10_000; + +const getSlackConfig = () => ({ + hostname: 'hooks.slack.com', + port: 443, + path: process.env.SLACK_WXYC_REQUESTS_WEBHOOK || '', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, +}); + +export interface SlackPostResult { + success: boolean; + message?: string; + statusCode?: number; + response?: string; +} + +/** + * Post a simple text message to Slack. + */ +export async function postTextToSlack(message: string): Promise { + return postToSlack({ text: message }); +} + +/** + * Post blocks to Slack webhook. + * + * @param blocks - Slack block array + * @param fallbackText - Optional fallback text for notifications + */ +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.) + if (fallbackText) { + payload.text = fallbackText; + } + + return postToSlack(payload); +} + +/** + * Post payload to Slack webhook. + */ +async function postToSlack(payload: object): Promise { + // Use mock in test environments + if (process.env.USE_MOCK_SERVICES === 'true') { + // Allow simulating Slack failures in test mode + if (process.env.SIMULATE_SLACK_FAILURE === 'true') { + console.log('[Slack Service] Mock mode - simulating Slack failure'); + return { success: false, statusCode: 500, response: 'Mock: Simulated Slack failure' }; + } + console.log('[Slack Service] Mock mode - would send to Slack:', JSON.stringify(payload).slice(0, 200)); + return { success: true, message: 'Mock: Message sent to Slack successfully' }; + } + + const webhookPath = process.env.SLACK_WXYC_REQUESTS_WEBHOOK; + if (!webhookPath) { + console.error('[Slack Service] SLACK_WXYC_REQUESTS_WEBHOOK not configured'); + return { success: false, message: 'Slack webhook not configured' }; + } + + return new Promise((resolve, reject) => { + const postData = JSON.stringify(payload); + + const req = https.request(getSlackConfig(), (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + resolve({ success: true, message: 'Message sent to Slack successfully' }); + } else { + resolve({ success: false, statusCode: res.statusCode, response: data }); + } + }); + }); + + req.on('error', (e) => { + console.error('[Slack Service] Error sending message to Slack:', e); + reject(e); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Slack request timeout')); + }); + + req.setTimeout(SLACK_TIMEOUT_MS); + req.write(postData); + req.end(); + }); +} + +/** + * Check if Slack is configured. + */ +export function isSlackConfigured(): boolean { + return !!( + process.env.SLACK_WXYC_REQUESTS_WEBHOOK && + process.env.USE_MOCK_SERVICES !== 'true' + ); +} diff --git a/dev_env/docker-compose.yml b/dev_env/docker-compose.yml index e01bce4..a4ef242 100644 --- a/dev_env/docker-compose.yml +++ b/dev_env/docker-compose.yml @@ -152,6 +152,18 @@ services: - USE_MOCK_SERVICES=true # Anonymous device authentication - ANON_DEVICE_JWT_SECRET=${ANON_DEVICE_JWT_SECRET:-ci-test-secret-key-for-anonymous-devices} + - SLACK_WXYC_REQUESTS_WEBHOOK=${SLACK_WXYC_REQUESTS_WEBHOOK} + # Slack failure simulation (for testing error handling) + # Set SIMULATE_SLACK_FAILURE=true to test Slack webhook failure scenarios + - SIMULATE_SLACK_FAILURE=${SIMULATE_SLACK_FAILURE:-false} + # Rate limiting is disabled by default in test mode (USE_MOCK_SERVICES=true) + # To run rate limiting tests, rebuild with TEST_RATE_LIMITING=true: + # TEST_RATE_LIMITING=true docker compose ... up -d --build backend + # Rate limit config (only applies when TEST_RATE_LIMITING=true): + - RATE_LIMIT_REGISTRATION_WINDOW_MS=2000 + - RATE_LIMIT_REGISTRATION_MAX=3 + - RATE_LIMIT_REQUEST_WINDOW_MS=2000 + - RATE_LIMIT_REQUEST_MAX=3 ports: - '${CI_PORT:-8081}:8080' diff --git a/package-lock.json b/package-lock.json index 8e1fe2e..f6e7c4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,9 +70,13 @@ "@wxyc/database": "*", "async-mutex": "^0.5.0", "aws-jwt-verify": "^5.1.0", + "axios": "^1.7.0", "cors": "^2.8.5", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "groq-sdk": "^0.5.0", "jose": "^6.1.3", + "lru-cache": "^10.2.0", "node-fetch": "^3.3.2", "node-ssh": "^13.2.1", "postgres": "^3.4.4", @@ -85,6 +89,12 @@ "drizzle-orm": "^0.41.0" } }, + "apps/backend/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -4131,12 +4141,21 @@ "version": "24.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -4538,6 +4557,18 @@ "resolved": "shared/database", "link": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4602,6 +4633,18 @@ "node": ">=0.4.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4727,7 +4770,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/aws-jwt-verify": { @@ -4743,7 +4785,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5468,7 +5509,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5740,7 +5780,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6070,7 +6109,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6186,6 +6224,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6281,6 +6328,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -6453,7 +6518,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -6504,7 +6568,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6517,6 +6580,34 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -6757,6 +6848,57 @@ "dev": true, "license": "ISC" }, + "node_modules/groq-sdk": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.5.0.tgz", + "integrity": "sha512-RVmhW7qZ+XZoy5fIuSdx/LGQJONpL8MHgZEW7dFwTdgkzStub2XQx6OKv28CHogijdwH41J+Npj/z2jBPu3vmw==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + } + }, + "node_modules/groq-sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/groq-sdk/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/groq-sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -6805,7 +6947,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6866,6 +7007,15 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", @@ -6947,6 +7097,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8146,7 +8305,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -8156,7 +8314,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -9006,7 +9163,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/pstree.remy": { @@ -10019,6 +10175,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11360,7 +11522,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -11518,6 +11679,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", diff --git a/package.json b/package.json index ae1d62f..f80fb88 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:all": "npm run test:unit && npm run test:integration", "db:start": "docker compose -f dev_env/docker-compose.yml --env-file .env --profile dev up -d; docker attach wxyc-db-init", "db:stop": "docker compose -f dev_env/docker-compose.yml --env-file .env --profile dev down db -v --remove-orphans", + "ci:build": "npm run docker:build --workspace=apps/**", "ci:env": "sh -c 'COMPOSE=\"docker compose -f dev_env/docker-compose.yml --env-file .env --profile ci\" && $COMPOSE up -d ci-db && $COMPOSE up ci-db-init && $COMPOSE up -d ${BUILD_ENABLED:+--build} auth backend'", "ci:test": "DB_PORT=${CI_DB_PORT:-5433} PORT=${CI_PORT:-8081} BETTER_AUTH_URL=${CI_BETTER_AUTH_URL:-http://localhost:8083/auth} dotenvx run -f .env -- jest --config jest.config.json --runInBand --coverage", "ci:test:parallel": "DB_PORT=${CI_DB_PORT:-5433} PORT=${CI_PORT:-8081} BETTER_AUTH_URL=${CI_BETTER_AUTH_URL:-http://localhost:8083/auth} dotenvx run -f .env -- jest --config jest.parallel.config.json --coverage", diff --git a/shared/authentication/src/auth.client.ts b/shared/authentication/src/auth.client.ts index 28864d0..c781093 100644 --- a/shared/authentication/src/auth.client.ts +++ b/shared/authentication/src/auth.client.ts @@ -1,9 +1,23 @@ import { createAuthClient } from 'better-auth/client'; -import { adminClient, jwtClient, usernameClient, organizationClient } from 'better-auth/client/plugins'; +import { + adminClient, + anonymousClient, + bearerClient, + jwtClient, + usernameClient, + organizationClient, +} from 'better-auth/client/plugins'; export const authClient = createAuthClient({ // Base URL for the auth service baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth', - plugins: [adminClient(), usernameClient(), jwtClient(), organizationClient()], + plugins: [ + adminClient(), + usernameClient(), + anonymousClient(), + bearerClient(), + jwtClient(), + organizationClient(), + ], }); diff --git a/shared/authentication/src/auth.definition.ts b/shared/authentication/src/auth.definition.ts index df6be13..9e61a25 100644 --- a/shared/authentication/src/auth.definition.ts +++ b/shared/authentication/src/auth.definition.ts @@ -9,11 +9,13 @@ import { user, verification, } from '@wxyc/database'; -import { betterAuth } from 'better-auth'; +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, @@ -36,7 +38,7 @@ const buildResetUrl = (url: string, redirectTo?: string) => { } }; -export const auth = betterAuth({ +export const auth: Auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: { @@ -109,6 +111,10 @@ export const auth = betterAuth({ plugins: [ admin(), username(), + anonymous({ + emailDomainName: 'anonymous.wxyc.org', + }), + bearer(), jwt({ // JWT plugin configuration // JWKS endpoint automatically exposed at /api/auth/jwks @@ -337,8 +343,7 @@ export const auth = betterAuth({ realName: { type: 'string', required: false }, djName: { type: 'string', required: false }, appSkin: { type: 'string', required: true, defaultValue: 'modern-light' }, + isAnonymous: { type: 'boolean', required: false, defaultValue: false }, }, }, }); - -export type Auth = typeof auth; diff --git a/shared/database/src/migrations/0025_rate_limiting_tables.sql b/shared/database/src/migrations/0025_rate_limiting_tables.sql new file mode 100644 index 0000000..a691000 --- /dev/null +++ b/shared/database/src/migrations/0025_rate_limiting_tables.sql @@ -0,0 +1,12 @@ +-- album_metadata and artist_metadata tables already created in 0023_metadata_tables.sql +--> statement-breakpoint +CREATE TABLE "user_activity" ( + "user_id" varchar(255) PRIMARY KEY NOT NULL, + "request_count" integer DEFAULT 0 NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "auth_user" ADD COLUMN "is_anonymous" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Constraints and indexes for album_metadata and artist_metadata already created in 0023_metadata_tables.sql +ALTER TABLE "user_activity" ADD CONSTRAINT "user_activity_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/shared/database/src/migrations/meta/0025_snapshot.json b/shared/database/src/migrations/meta/0025_snapshot.json new file mode 100644 index 0000000..a7841f0 --- /dev/null +++ b/shared/database/src/migrations/meta/0025_snapshot.json @@ -0,0 +1,2635 @@ +{ + "id": "335ff99b-a9d0-49ec-a275-2bf4e9b2edf7", + "prevId": "28b0c268-097b-41b0-8ae6-883f933c63bb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_account_provider_account_key": { + "name": "auth_account_provider_account_key", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.album_metadata": { + "name": "album_metadata", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_key": { + "name": "cache_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "discogs_release_id": { + "name": "discogs_release_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discogs_url": { + "name": "discogs_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "release_year": { + "name": "release_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "artwork_url": { + "name": "artwork_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "spotify_url": { + "name": "spotify_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "apple_music_url": { + "name": "apple_music_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "youtube_music_url": { + "name": "youtube_music_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "bandcamp_url": { + "name": "bandcamp_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "soundcloud_url": { + "name": "soundcloud_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "is_rotation": { + "name": "is_rotation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_accessed": { + "name": "last_accessed", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "album_metadata_album_id_idx": { + "name": "album_metadata_album_id_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "album_metadata_cache_key_idx": { + "name": "album_metadata_cache_key_idx", + "columns": [ + { + "expression": "cache_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "album_metadata_last_accessed_idx": { + "name": "album_metadata_last_accessed_idx", + "columns": [ + { + "expression": "last_accessed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "album_metadata_album_id_library_id_fk": { + "name": "album_metadata_album_id_library_id_fk", + "tableFrom": "album_metadata", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "album_metadata_album_id_unique": { + "name": "album_metadata_album_id_unique", + "nullsNotDistinct": false, + "columns": [ + "album_id" + ] + }, + "album_metadata_cache_key_unique": { + "name": "album_metadata_cache_key_unique", + "nullsNotDistinct": false, + "columns": [ + "cache_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anonymous_devices": { + "name": "anonymous_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "blocked": { + "name": "blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "anonymous_devices_device_id_key": { + "name": "anonymous_devices_device_id_key", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "anonymous_devices_device_id_unique": { + "name": "anonymous_devices_device_id_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artist_library_crossreference": { + "name": "artist_library_crossreference", + "schema": "wxyc_schema", + "columns": { + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "library_id_artist_id": { + "name": "library_id_artist_id", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artist_library_crossreference_artist_id_artists_id_fk": { + "name": "artist_library_crossreference_artist_id_artists_id_fk", + "tableFrom": "artist_library_crossreference", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "artist_library_crossreference_library_id_library_id_fk": { + "name": "artist_library_crossreference_library_id_library_id_fk", + "tableFrom": "artist_library_crossreference", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "library_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artist_metadata": { + "name": "artist_metadata", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_key": { + "name": "cache_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "discogs_artist_id": { + "name": "discogs_artist_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wikipedia_url": { + "name": "wikipedia_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "last_accessed": { + "name": "last_accessed", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artist_metadata_artist_id_idx": { + "name": "artist_metadata_artist_id_idx", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_metadata_cache_key_idx": { + "name": "artist_metadata_cache_key_idx", + "columns": [ + { + "expression": "cache_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_metadata_last_accessed_idx": { + "name": "artist_metadata_last_accessed_idx", + "columns": [ + { + "expression": "last_accessed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artist_metadata_artist_id_artists_id_fk": { + "name": "artist_metadata_artist_id_artists_id_fk", + "tableFrom": "artist_metadata", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "artist_metadata_artist_id_unique": { + "name": "artist_metadata_artist_id_unique", + "nullsNotDistinct": false, + "columns": [ + "artist_id" + ] + }, + "artist_metadata_cache_key_unique": { + "name": "artist_metadata_cache_key_unique", + "nullsNotDistinct": false, + "columns": [ + "cache_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.artists": { + "name": "artists", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "code_letters": { + "name": "code_letters", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "code_artist_number": { + "name": "code_artist_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artist_name_trgm_idx": { + "name": "artist_name_trgm_idx", + "columns": [ + { + "expression": "\"artist_name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "code_letters_idx": { + "name": "code_letters_idx", + "columns": [ + { + "expression": "code_letters", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artists_genre_id_genres_id_fk": { + "name": "artists_genre_id_genres_id_fk", + "tableFrom": "artists", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.bins": { + "name": "bins", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dj_id": { + "name": "dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "track_title": { + "name": "track_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "bins_dj_id_auth_user_id_fk": { + "name": "bins_dj_id_auth_user_id_fk", + "tableFrom": "bins", + "tableTo": "auth_user", + "columnsFrom": [ + "dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bins_album_id_library_id_fk": { + "name": "bins_album_id_library_id_fk", + "tableFrom": "bins", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.dj_stats": { + "name": "dj_stats", + "schema": "wxyc_schema", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "shows_covered": { + "name": "shows_covered", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "dj_stats_user_id_auth_user_id_fk": { + "name": "dj_stats_user_id_auth_user_id_fk", + "tableFrom": "dj_stats", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.flowsheet": { + "name": "flowsheet", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "show_id": { + "name": "show_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rotation_id": { + "name": "rotation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "track_title": { + "name": "track_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "record_label": { + "name": "record_label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "play_order": { + "name": "play_order", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "request_flag": { + "name": "request_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "message": { + "name": "message", + "type": "varchar(250)", + "primaryKey": false, + "notNull": false + }, + "add_time": { + "name": "add_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "flowsheet_show_id_shows_id_fk": { + "name": "flowsheet_show_id_shows_id_fk", + "tableFrom": "flowsheet", + "tableTo": "shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "flowsheet_album_id_library_id_fk": { + "name": "flowsheet_album_id_library_id_fk", + "tableFrom": "flowsheet", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "flowsheet_rotation_id_rotation_id_fk": { + "name": "flowsheet_rotation_id_rotation_id_fk", + "tableFrom": "flowsheet", + "tableTo": "rotation", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "rotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.format": { + "name": "format", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "format_name": { + "name": "format_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.genre_artist_crossreference": { + "name": "genre_artist_crossreference", + "schema": "wxyc_schema", + "columns": { + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artist_genre_code": { + "name": "artist_genre_code", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artist_genre_key": { + "name": "artist_genre_key", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "genre_artist_crossreference_artist_id_artists_id_fk": { + "name": "genre_artist_crossreference_artist_id_artists_id_fk", + "tableFrom": "genre_artist_crossreference", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "genre_artist_crossreference_genre_id_genres_id_fk": { + "name": "genre_artist_crossreference_genre_id_genres_id_fk", + "tableFrom": "genre_artist_crossreference", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.genres": { + "name": "genres", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "genre_name": { + "name": "genre_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plays": { + "name": "plays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_invitation": { + "name": "auth_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_invitation_email_idx": { + "name": "auth_invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_invitation_organization_id_auth_organization_id_fk": { + "name": "auth_invitation_organization_id_auth_organization_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_invitation_inviter_id_auth_user_id_fk": { + "name": "auth_invitation_inviter_id_auth_user_id_fk", + "tableFrom": "auth_invitation", + "tableTo": "auth_user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.library": { + "name": "library", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format_id": { + "name": "format_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "alternate_artist_name": { + "name": "alternate_artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "code_number": { + "name": "code_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "disc_quantity": { + "name": "disc_quantity", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plays": { + "name": "plays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "add_date": { + "name": "add_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "title_trgm_idx": { + "name": "title_trgm_idx", + "columns": [ + { + "expression": "\"album_title\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "genre_id_idx": { + "name": "genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "format_id_idx": { + "name": "format_id_idx", + "columns": [ + { + "expression": "format_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artist_id_idx": { + "name": "artist_id_idx", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_artist_id_artists_id_fk": { + "name": "library_artist_id_artists_id_fk", + "tableFrom": "library", + "tableTo": "artists", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "library_genre_id_genres_id_fk": { + "name": "library_genre_id_genres_id_fk", + "tableFrom": "library", + "tableTo": "genres", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "library_format_id_format_id_fk": { + "name": "library_format_id_format_id_fk", + "tableFrom": "library", + "tableTo": "format", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "format_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_member": { + "name": "auth_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_member_org_user_key": { + "name": "auth_member_org_user_key", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_member_organization_id_auth_organization_id_fk": { + "name": "auth_member_organization_id_auth_organization_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_member_user_id_auth_user_id_fk": { + "name": "auth_member_user_id_auth_user_id_fk", + "tableFrom": "auth_member", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_organization": { + "name": "auth_organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_organization_slug_key": { + "name": "auth_organization_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.reviews": { + "name": "reviews", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review": { + "name": "review", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "author": { + "name": "author", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reviews_album_id_library_id_fk": { + "name": "reviews_album_id_library_id_fk", + "tableFrom": "reviews", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_album_id_unique": { + "name": "reviews_album_id_unique", + "nullsNotDistinct": false, + "columns": [ + "album_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.rotation": { + "name": "rotation", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kill_date": { + "name": "kill_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "album_id_idx": { + "name": "album_id_idx", + "columns": [ + { + "expression": "album_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rotation_album_id_library_id_fk": { + "name": "rotation_album_id_library_id_fk", + "tableFrom": "rotation", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.schedule": { + "name": "schedule", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "show_duration": { + "name": "show_duration", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "specialty_id": { + "name": "specialty_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "assigned_dj_id": { + "name": "assigned_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "assigned_dj_id2": { + "name": "assigned_dj_id2", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_specialty_id_specialty_shows_id_fk": { + "name": "schedule_specialty_id_specialty_shows_id_fk", + "tableFrom": "schedule", + "tableTo": "specialty_shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "specialty_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "schedule_assigned_dj_id_auth_user_id_fk": { + "name": "schedule_assigned_dj_id_auth_user_id_fk", + "tableFrom": "schedule", + "tableTo": "auth_user", + "columnsFrom": [ + "assigned_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "schedule_assigned_dj_id2_auth_user_id_fk": { + "name": "schedule_assigned_dj_id2_auth_user_id_fk", + "tableFrom": "schedule", + "tableTo": "auth_user", + "columnsFrom": [ + "assigned_dj_id2" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_session_token_key": { + "name": "auth_session_token_key", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.shift_covers": { + "name": "shift_covers", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "shift_timestamp": { + "name": "shift_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cover_dj_id": { + "name": "cover_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "covered": { + "name": "covered", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shift_covers_schedule_id_schedule_id_fk": { + "name": "shift_covers_schedule_id_schedule_id_fk", + "tableFrom": "shift_covers", + "tableTo": "schedule", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "shift_covers_cover_dj_id_auth_user_id_fk": { + "name": "shift_covers_cover_dj_id_auth_user_id_fk", + "tableFrom": "shift_covers", + "tableTo": "auth_user", + "columnsFrom": [ + "cover_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.show_djs": { + "name": "show_djs", + "schema": "wxyc_schema", + "columns": { + "show_id": { + "name": "show_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dj_id": { + "name": "dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_djs_show_id_shows_id_fk": { + "name": "show_djs_show_id_shows_id_fk", + "tableFrom": "show_djs", + "tableTo": "shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "show_djs_dj_id_auth_user_id_fk": { + "name": "show_djs_dj_id_auth_user_id_fk", + "tableFrom": "show_djs", + "tableTo": "auth_user", + "columnsFrom": [ + "dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.shows": { + "name": "shows", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "primary_dj_id": { + "name": "primary_dj_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "specialty_id": { + "name": "specialty_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "show_name": { + "name": "show_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "shows_primary_dj_id_auth_user_id_fk": { + "name": "shows_primary_dj_id_auth_user_id_fk", + "tableFrom": "shows", + "tableTo": "auth_user", + "columnsFrom": [ + "primary_dj_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "shows_specialty_id_specialty_shows_id_fk": { + "name": "shows_specialty_id_specialty_shows_id_fk", + "tableFrom": "shows", + "tableTo": "specialty_shows", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "specialty_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.specialty_shows": { + "name": "specialty_shows", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "specialty_name": { + "name": "specialty_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "add_date": { + "name": "add_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dj_name": { + "name": "dj_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "app_skin": { + "name": "app_skin", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'modern-light'" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "auth_user_email_key": { + "name": "auth_user_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_user_username_key": { + "name": "auth_user_username_key", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_activity": { + "name": "user_activity", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_auth_user_id_fk": { + "name": "user_activity_user_id_auth_user_id_fk", + "tableFrom": "user_activity", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.freq_enum": { + "name": "freq_enum", + "schema": "public", + "values": [ + "S", + "L", + "M", + "H" + ] + } + }, + "schemas": { + "wxyc_schema": "wxyc_schema" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "wxyc_schema.library_artist_view": { + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code_letters": { + "name": "code_letters", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "code_artist_number": { + "name": "code_artist_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "code_number": { + "name": "code_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "format_name": { + "name": "format_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "genre_name": { + "name": "genre_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "add_date": { + "name": "add_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"wxyc_schema\".\"library\".\"id\", \"wxyc_schema\".\"artists\".\"code_letters\", \"wxyc_schema\".\"artists\".\"code_artist_number\", \"wxyc_schema\".\"library\".\"code_number\", \"wxyc_schema\".\"artists\".\"artist_name\", \"wxyc_schema\".\"library\".\"album_title\", \"wxyc_schema\".\"format\".\"format_name\", \"wxyc_schema\".\"genres\".\"genre_name\", \"wxyc_schema\".\"rotation\".\"play_freq\", \"wxyc_schema\".\"library\".\"add_date\", \"wxyc_schema\".\"library\".\"label\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\" inner join \"wxyc_schema\".\"format\" on \"wxyc_schema\".\"format\".\"id\" = \"wxyc_schema\".\"library\".\"format_id\" inner join \"wxyc_schema\".\"genres\" on \"wxyc_schema\".\"genres\".\"id\" = \"wxyc_schema\".\"library\".\"genre_id\" left join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"rotation\".\"album_id\" = \"wxyc_schema\".\"library\".\"id\" AND (\"wxyc_schema\".\"rotation\".\"kill_date\" < CURRENT_DATE OR \"wxyc_schema\".\"rotation\".\"kill_date\" IS NULL)", + "name": "library_artist_view", + "schema": "wxyc_schema", + "isExisting": false, + "materialized": false + }, + "wxyc_schema.rotation_library_view": { + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "play_freq": { + "name": "play_freq", + "type": "freq_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "album_title": { + "name": "album_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "kill_date": { + "name": "kill_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"wxyc_schema\".\"library\".\"id\", \"wxyc_schema\".\"rotation\".\"id\", \"wxyc_schema\".\"library\".\"label\", \"wxyc_schema\".\"rotation\".\"play_freq\", \"wxyc_schema\".\"library\".\"album_title\", \"wxyc_schema\".\"artists\".\"artist_name\", \"wxyc_schema\".\"rotation\".\"kill_date\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"library\".\"id\" = \"wxyc_schema\".\"rotation\".\"album_id\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\"", + "name": "rotation_library_view", + "schema": "wxyc_schema", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 88bdfc5..cc1a23e 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1768890679949, "tag": "0024_anonymous_devices", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1768890229444, + "tag": "0025_rate_limiting_tables", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 8334398..4703f15 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -40,6 +40,7 @@ export const user = pgTable( realName: varchar('real_name', { length: 255 }), djName: varchar('dj_name', { length: 255 }), appSkin: varchar('app_skin', { length: 255 }).notNull().default('modern-light'), + isAnonymous: boolean('is_anonymous').notNull().default(false), }, (table) => [ uniqueIndex('auth_user_email_key').on(table.email), @@ -515,7 +516,20 @@ export const artist_metadata = wxyc_schema.table( } ); -// Anonymous device tracking for song requests +// User activity tracking (for anonymous users) +export const user_activity = pgTable('user_activity', { + userId: varchar('user_id', { length: 255 }) + .primaryKey() + .references(() => user.id, { onDelete: 'cascade' }), + requestCount: integer('request_count').notNull().default(0), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type UserActivity = InferSelectModel; +export type NewUserActivity = InferInsertModel; + +// Anonymous device tracking for song requests (legacy - to be deprecated) export const anonymous_devices = pgTable( 'anonymous_devices', { diff --git a/tests/integration/discogs.spec.js b/tests/integration/discogs.spec.js new file mode 100644 index 0000000..cf19c08 --- /dev/null +++ b/tests/integration/discogs.spec.js @@ -0,0 +1,199 @@ +/** + * Discogs Service Integration Tests + * + * Tests for the Discogs API client and service functionality. + * These tests run with USE_MOCK_SERVICES=true to avoid hitting + * the real Discogs API. + * + * Run with: + * npm test -- --testPathPatterns=discogs + */ + +require('dotenv').config({ path: '../../.env' }); +const request = require('supertest')(`${process.env.TEST_HOST}:${process.env.PORT}`); +const { signInAnonymous } = require('../utils/anonymous_auth'); + +// Helper to get an anonymous auth token +const getTestToken = async () => { + const { token, userId, user } = await signInAnonymous(); + return { token, userId, user }; +}; + +describe('Discogs Service', () => { + describe('parseTitle utility', () => { + it('should handle standard "Artist - Album" format in search results', async () => { + const { token } = await getTestToken(); + + // Make a request that would trigger Discogs search (in mock mode) + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play Blue Monday by New Order' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should handle titles with multiple dashes', async () => { + const { token } = await getTestToken(); + + // A song title like "Artist - Album - Deluxe Edition" should parse correctly + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play something from The Dark Side of the Moon' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + + describe('Mock Mode', () => { + it('should return mock responses when USE_MOCK_SERVICES is true', async () => { + const { token } = await getTestToken(); + + // In mock mode, Discogs calls return mock data + // The request should still succeed + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play Autobahn by Kraftwerk' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + // Mock mode should not cause errors + expect(response.body.error).toBeUndefined(); + }); + + it('should handle search requests in mock mode', async () => { + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'I want to hear some ambient music' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + + describe('Service Availability', () => { + it('should handle requests when Discogs credentials are configured', async () => { + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play anything by Aphex Twin' }); + + expect(response.status).toBe(200); + // Response should indicate success even if Discogs is in mock mode + expect(response.body.success).toBe(true); + }); + + it('should gracefully handle artwork lookup in mock mode', async () => { + const { token } = await getTestToken(); + + // Request that would typically trigger artwork lookup + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play Blue Lines by Massive Attack' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + // Should not fail even if artwork lookup returns mock data + }); + }); + + describe('Rate Limiter Behavior', () => { + it('should not block rapid requests in mock mode', async () => { + const { token } = await getTestToken(); + + // Make several requests quickly - rate limiter should not block in mock mode + // since we're not actually hitting the Discogs API + const responses = []; + for (let i = 0; i < 3; i++) { + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: `Test request ${i}` }); + responses.push(response); + } + + // All should succeed (subject to the request rate limiter, not Discogs rate limiter) + responses.forEach((response) => { + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + }); + + describe('Error Handling', () => { + it('should handle malformed requests gracefully', async () => { + const { token } = await getTestToken(); + + 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' }); + + expect(response.status).toBe(401); + }); + }); +}); + +describe('Discogs Integration with Request Line', () => { + describe('Song Request Flow', () => { + it('should process a complete song request with mock Discogs data', async () => { + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Can you play Windowlicker by Aphex Twin?' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + // parsed is only present when AI parsing is enabled (requires OPENAI_API_KEY) + // In CI/legacy mode without parsing, the response uses a simpler format + if (response.body.parsed) { + expect(response.body.parsed.isRequest).toBeDefined(); + } + }); + + it('should handle requests with artist only', async () => { + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play something by Boards of Canada' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should handle requests with song only', async () => { + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Play Halcyon and On and On' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); +}); diff --git a/tests/integration/rateLimiting.spec.js b/tests/integration/rateLimiting.spec.js new file mode 100644 index 0000000..fced3c5 --- /dev/null +++ b/tests/integration/rateLimiting.spec.js @@ -0,0 +1,199 @@ +/** + * Rate Limiting Integration Tests + * + * These tests verify that rate limiting is properly enforced. + * They require the server to be started with rate limiting enabled and configured + * with short windows for testing: + * + * Environment variables for testing: + * TEST_RATE_LIMITING=true + * RATE_LIMIT_REGISTRATION_WINDOW_MS=2000 (2 seconds) + * RATE_LIMIT_REGISTRATION_MAX=3 + * RATE_LIMIT_REQUEST_WINDOW_MS=2000 (2 seconds) + * RATE_LIMIT_REQUEST_MAX=3 + * + * Run with: + * TEST_RATE_LIMITING=true \ + * RATE_LIMIT_REGISTRATION_MAX=3 \ + * RATE_LIMIT_REQUEST_MAX=3 \ + * RATE_LIMIT_REGISTRATION_WINDOW_MS=2000 \ + * RATE_LIMIT_REQUEST_WINDOW_MS=2000 \ + * npm test -- --testPathPattern=rateLimiting + */ + +require('dotenv').config({ path: '../../.env' }); +const request = require('supertest')(`${process.env.TEST_HOST}:${process.env.PORT}`); +const { signInAnonymous } = require('../utils/anonymous_auth'); + +// Skip these tests if rate limiting is not enabled +const rateLimitingEnabled = process.env.TEST_RATE_LIMITING === 'true'; +const describeOrSkip = rateLimitingEnabled ? describe : describe.skip; + +// Helper to sign in as an anonymous user and get a token +const getTestToken = async () => { + const { token, userId, user } = await signInAnonymous(); + return { token, userId, user }; +}; + +// Helper to wait for rate limit window to reset +const waitForWindowReset = (windowMs = 2000) => { + return new Promise((resolve) => setTimeout(resolve, windowMs + 100)); +}; + +describeOrSkip('Rate Limiting', () => { + // Get configured limits from environment (with defaults matching test recommendations) + const REGISTRATION_MAX = parseInt(process.env.RATE_LIMIT_REGISTRATION_MAX || '3', 10); + const REQUEST_MAX = parseInt(process.env.RATE_LIMIT_REQUEST_MAX || '3', 10); + const WINDOW_MS = parseInt(process.env.RATE_LIMIT_REQUEST_WINDOW_MS || '2000', 10); + + describe('Registration Rate Limiting (Legacy Endpoint)', () => { + it('should rate limit the legacy registration endpoint by IP', async () => { + // Wait for any previous window to reset + await waitForWindowReset(WINDOW_MS); + + // Make requests up to the limit + const responses = []; + for (let i = 0; i < REGISTRATION_MAX; i++) { + const response = await request.post('/request/register').send({}); + responses.push(response); + } + + // All requests within limit should return 301 (deprecated redirect) + responses.forEach((response) => { + expect(response.status).toBe(301); + }); + + // Next request should be rate limited + const limitedResponse = await request.post('/request/register').send({}); + + expect(limitedResponse.status).toBe(429); + expect(limitedResponse.body.message).toMatch(/too many/i); + }); + }); + + describe('Song Request Rate Limiting', () => { + it('should allow requests up to the limit per user', async () => { + // Get a fresh anonymous user token + const { token } = await getTestToken(); + + const responses = []; + for (let i = 0; i < REQUEST_MAX; i++) { + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: `Test request ${i}` }); + responses.push(response); + } + + // All requests within limit should succeed + responses.forEach((response) => { + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }); + + it('should return 429 when request limit is exceeded for a user', async () => { + // Wait for window reset and get a fresh user + await waitForWindowReset(WINDOW_MS); + const { token } = await getTestToken(); + + // Make requests up to the limit + for (let i = 0; i < REQUEST_MAX; i++) { + await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: `Test request ${i}` }); + } + + // Next request should be rate limited + const limitedResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'This should be rate limited' }); + + expect(limitedResponse.status).toBe(429); + expect(limitedResponse.body.message).toMatch(/too many/i); + expect(limitedResponse.body.retryAfter).toBeDefined(); + }); + + it('should track rate limits separately per user', async () => { + await waitForWindowReset(WINDOW_MS); + + // Get two different anonymous users + const user1 = await getTestToken(); + const user2 = await getTestToken(); + + // Exhaust limit for user 1 + for (let i = 0; i < REQUEST_MAX; i++) { + await request + .post('/request') + .set('Authorization', `Bearer ${user1.token}`) + .send({ message: `User 1 request ${i}` }); + } + + // User 1 should be rate limited + const user1Limited = await request + .post('/request') + .set('Authorization', `Bearer ${user1.token}`) + .send({ message: 'User 1 limited' }); + expect(user1Limited.status).toBe(429); + + // User 2 should still be able to make requests + const user2Response = await request + .post('/request') + .set('Authorization', `Bearer ${user2.token}`) + .send({ message: 'User 2 should work' }); + expect(user2Response.status).toBe(200); + }); + + it('should include rate limit headers on requests', async () => { + await waitForWindowReset(WINDOW_MS); + const { token } = await getTestToken(); + + const response = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Test message' }); + + expect(response.status).toBe(200); + expect(response.headers['ratelimit-limit']).toBeDefined(); + expect(response.headers['ratelimit-remaining']).toBeDefined(); + expect(response.headers['ratelimit-reset']).toBeDefined(); + }); + + it('should reset after window expires', async () => { + // Wait for window to reset + await waitForWindowReset(WINDOW_MS); + + const { token } = await getTestToken(); + + // Exhaust the limit + for (let i = 0; i < REQUEST_MAX; i++) { + await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: `Request ${i}` }); + } + + // Verify we're rate limited + const limitedResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Should be limited' }); + expect(limitedResponse.status).toBe(429); + + // Wait for window to reset + await waitForWindowReset(WINDOW_MS); + + // Should be able to make requests again + const resetResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Should work after reset' }); + expect(resetResponse.status).toBe(200); + }); + }); +}); + +// Export helper for use in other test files +module.exports = { getTestToken }; diff --git a/tests/integration/requestLine.spec.js b/tests/integration/requestLine.spec.js index 3615103..1882f0f 100644 --- a/tests/integration/requestLine.spec.js +++ b/tests/integration/requestLine.spec.js @@ -1,157 +1,44 @@ require('dotenv').config({ path: '../../.env' }); const request = require('supertest')(`${process.env.TEST_HOST}:${process.env.PORT}`); -const crypto = require('crypto'); -const postgres = require('postgres'); - -// Database connection for test utilities -const getDbConnection = () => { - const dbPort = process.env.DB_PORT || 5432; - return postgres({ - host: process.env.DB_HOST || 'localhost', - port: dbPort, - database: process.env.DB_NAME, - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - }); -}; - -// Helper to generate a valid UUID for test device IDs -const generateTestDeviceId = () => crypto.randomUUID(); - -// Helper to block a device in the database -const blockDevice = async (deviceId) => { - const sql = getDbConnection(); - try { - await sql` - UPDATE anonymous_devices - SET blocked = true, blocked_at = NOW(), blocked_reason = 'Test block' - WHERE device_id = ${deviceId} - `; - } finally { - await sql.end(); - } -}; - -// Helper to unblock a device (cleanup) -const unblockDevice = async (deviceId) => { - const sql = getDbConnection(); - try { - await sql` - UPDATE anonymous_devices - SET blocked = false, blocked_at = NULL, blocked_reason = NULL - WHERE device_id = ${deviceId} - `; - } finally { - await sql.end(); - } -}; +const { signInAnonymous, banUser, unbanUser, getAdminToken } = require('../utils/anonymous_auth'); -// Helper to register a test device and get a token -const registerTestDevice = async (deviceId = null) => { - const testDeviceId = deviceId || generateTestDeviceId(); - const response = await request - .post('/request/register') - .send({ deviceId: testDeviceId }); - - return { - deviceId: testDeviceId, - token: response.body.token, - expiresAt: response.body.expiresAt, - response, - }; +// Helper to get a new anonymous auth token +const getTestToken = async () => { + const { token, userId, user } = await signInAnonymous(); + return { token, userId, user }; }; describe('Request Line Endpoint', () => { - describe('Device Registration', () => { - it('should register a new device and return a token', async () => { - const { response, deviceId } = await registerTestDevice(); + describe('Device Registration (Legacy Endpoint)', () => { + it('should return 301 redirect for legacy registration endpoint', async () => { + const response = await request.post('/request/register').send({ deviceId: 'test-uuid' }); - expect(response.status).toBe(200); - expect(response.body.token).toBeDefined(); - expect(response.body.expiresAt).toBeDefined(); - expect(typeof response.body.token).toBe('string'); - expect(response.body.token.length).toBeGreaterThan(0); - }); - - it('should return 400 when deviceId is missing', async () => { - const response = await request.post('/request/register').send({}); - - expect(response.status).toBe(400); - expect(response.body.message).toMatch(/deviceId/i); + expect(response.status).toBe(301); + expect(response.body.message).toMatch(/deprecated/i); + expect(response.body.endpoint).toMatch(/sign-in\/anonymous/); }); + }); - it('should return 400 when deviceId is invalid format', async () => { - const response = await request - .post('/request/register') - .send({ deviceId: 'not-a-valid-uuid' }); - - expect(response.status).toBe(400); - expect(response.body.message).toMatch(/invalid/i); - }); - - it('should return same device for repeated registration with same deviceId', async () => { - const deviceId = generateTestDeviceId(); - - const response1 = await request.post('/request/register').send({ deviceId }); - const response2 = await request.post('/request/register').send({ deviceId }); - - expect(response1.status).toBe(200); - expect(response2.status).toBe(200); - // Both should succeed (tokens may differ but both should be valid) - expect(response1.body.token).toBeDefined(); - expect(response2.body.token).toBeDefined(); - }); - - it('should return 403 when device is blocked', async () => { - // Register a device first - const deviceId = generateTestDeviceId(); - const registerResponse = await request.post('/request/register').send({ deviceId }); - expect(registerResponse.status).toBe(200); - const { token } = registerResponse.body; - - // Make a request with the token - should succeed before blocking - const successResponse = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test before block' }); - expect(successResponse.status).toBe(200); - - // Block the device in the database - await blockDevice(deviceId); + describe('Anonymous Authentication', () => { + it('should return 401 without Authorization header', async () => { + const response = await request.post('/request').send({ message: 'Test song request' }); - try { - // Attempt to make another request - should return 403 - const blockedResponse = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test after block' }); - expect(blockedResponse.status).toBe(403); - expect(blockedResponse.body.message).toMatch(/blocked/i); - - // Attempt to re-register - should also return 403 - const reRegisterResponse = await request.post('/request/register').send({ deviceId }); - expect(reRegisterResponse.status).toBe(403); - expect(reRegisterResponse.body.message).toMatch(/blocked/i); - } finally { - // Cleanup: unblock the device - await unblockDevice(deviceId); - } + expect(response.status).toBe(401); }); - }); - describe('Anonymous Authentication', () => { - it('should return 401 when Authorization header is missing', async () => { + it('should return 401 with malformed Authorization header', async () => { const response = await request .post('/request') + .set('Authorization', 'not-bearer-format') .send({ message: 'Test song request' }); expect(response.status).toBe(401); }); - it('should return 401 with invalid token format', async () => { + it('should return 401 with empty Bearer token', async () => { const response = await request .post('/request') - .set('Authorization', 'invalid-token') + .set('Authorization', 'Bearer ') .send({ message: 'Test song request' }); expect(response.status).toBe(401); @@ -160,14 +47,14 @@ describe('Request Line Endpoint', () => { it('should return 401 with invalid Bearer token', async () => { const response = await request .post('/request') - .set('Authorization', 'Bearer invalid-jwt-token') + .set('Authorization', 'Bearer invalid-token') .send({ message: 'Test song request' }); expect(response.status).toBe(401); }); - it('should accept valid anonymous device token', async () => { - const { token } = await registerTestDevice(); + it('should accept valid anonymous session token', async () => { + const { token } = await getTestToken(); const response = await request .post('/request') @@ -182,7 +69,7 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); @@ -255,56 +142,55 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); - it('should handle simple text message', async () => { - const testMessage = 'Please play Carry the Zero by Built to Spill'; - + it('should accept song request messages', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play Blue Monday by New Order' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.message).toBe('Request line submitted successfully'); - expect(response.body.result).toBeDefined(); - expect(response.body.result.success).toBe(true); }); - it('should handle message with special characters', async () => { - const testMessage = 'Request: "Señor" by Los Ángeles Azules!'; - + it('should handle special characters in message', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play "Smells Like Teen Spirit" by Nirvana & friends!' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it('should handle message with newlines', async () => { - const testMessage = 'Song Request:\nArtist: Built to Spill\nTrack: Carry the Zero'; - + it('should handle unicode characters in message', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: testMessage }); + .send({ message: 'Play Mötley Crüe or 日本語 music' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); - it('should handle message at maximum length (500 characters)', async () => { - const maxMessage = 'A'.repeat(500); + it('should handle emoji in message', async () => { + const response = await request + .post('/request') + .set('Authorization', `Bearer ${testToken}`) + .send({ message: 'Play some music 🎵🎸' }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should trim leading and trailing whitespace', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: maxMessage }); + .send({ message: ' Test song request ' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); @@ -315,29 +201,25 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); - it('should return correct response structure', async () => { + it('should return JSON response with success field', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) .send({ message: 'Test message' }); expect(response.status).toBe(200); - expect(response.body).toHaveProperty('success'); - expect(response.body).toHaveProperty('message'); - expect(response.body).toHaveProperty('result'); expect(response.body.success).toBe(true); - expect(response.body.message).toBe('Request line submitted successfully'); }); - it('should include result object with success flag', async () => { + it('should include result object on success', async () => { const response = await request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); + .send({ message: 'Test song request' }); expect(response.status).toBe(200); expect(response.body.result).toBeDefined(); @@ -359,22 +241,22 @@ describe('Request Line Endpoint', () => { let testToken; beforeAll(async () => { - const { token } = await registerTestDevice(); + const { token } = await getTestToken(); testToken = token; }); it('should handle rapid successive requests (up to rate limit)', async () => { - // Note: Rate limit is 10 per 15 minutes per device - const requests = Array(5) - .fill(null) - .map((_, i) => + const promises = []; + for (let i = 0; i < 3; i++) { + promises.push( request .post('/request') .set('Authorization', `Bearer ${testToken}`) - .send({ message: `Rapid request ${i}` }) + .send({ message: `Test request ${i}` }) ); + } - const responses = await Promise.all(requests); + const responses = await Promise.all(promises); responses.forEach((response) => { expect(response.status).toBe(200); @@ -389,7 +271,7 @@ describe('Request Line Endpoint', () => { .send({ message: 'Test message', extraField: 'should be ignored', - anotherField: 123, + anotherExtra: 123, }); expect(response.status).toBe(200); @@ -397,63 +279,37 @@ describe('Request Line Endpoint', () => { }); }); - describe('HTTP Methods', () => { - let testToken; + describe('User Banning', () => { + // Skip these tests if admin credentials aren't configured or TEST_ADMIN_BAN isn't explicitly enabled + // Admin tests require valid credentials that exist in the auth database + const enableAdminTests = process.env.TEST_ADMIN_BAN === 'true'; + const describeOrSkip = enableAdminTests ? describe : describe.skip; - beforeAll(async () => { - const { token } = await registerTestDevice(); - testToken = token; - }); - - it('should reject GET requests', async () => { - const response = await request - .get('/request') - .set('Authorization', `Bearer ${testToken}`); - - expect(response.status).toBe(404); - }); - - it('should reject PUT requests', async () => { - const response = await request - .put('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); - - expect(response.status).toBe(404); - }); + describeOrSkip('with admin credentials', () => { + it('should return 403 when user is banned', async () => { + // Get a new anonymous user + const { token, userId } = await getTestToken(); - it('should reject DELETE requests', async () => { - const response = await request - .delete('/request') - .set('Authorization', `Bearer ${testToken}`); - - expect(response.status).toBe(404); - }); - - it('should reject PATCH requests', async () => { - const response = await request - .patch('/request') - .set('Authorization', `Bearer ${testToken}`) - .send({ message: 'Test message' }); - - expect(response.status).toBe(404); - }); - }); + // Verify request works before banning + const beforeBanResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Test before ban' }); + expect(beforeBanResponse.status).toBe(200); - describe('Token Refresh', () => { - it('should include refresh token headers when token is nearing expiration', async () => { - // This test would require mocking the token expiration - // For now, we just verify the headers can be present - const { token } = await registerTestDevice(); + // Ban the user + await banUser(userId, 'Test ban'); - const response = await request - .post('/request') - .set('Authorization', `Bearer ${token}`) - .send({ message: 'Test message' }); + // Request should now return 403 + const afterBanResponse = await request + .post('/request') + .set('Authorization', `Bearer ${token}`) + .send({ message: 'Test after ban' }); + expect(afterBanResponse.status).toBe(403); - expect(response.status).toBe(200); - // Note: X-Refresh-Token headers will only be present if token is within refresh threshold - // In normal test conditions, a fresh token won't trigger refresh + // Clean up: unban the user + await unbanUser(userId); + }); }); }); }); diff --git a/tests/utils/anonymous_auth.js b/tests/utils/anonymous_auth.js new file mode 100644 index 0000000..e3f93da --- /dev/null +++ b/tests/utils/anonymous_auth.js @@ -0,0 +1,198 @@ +/** + * Anonymous auth test utility + * Authenticates with better-auth's anonymous sign-in endpoint and retrieves session token for testing + */ + +const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL || 'http://localhost:8082/auth'; + +/** + * Signs in as an anonymous user via better-auth. + * Returns the session token and user info. + * + * @returns {Promise<{token: string, userId: string, user: object}>} + */ +async function signInAnonymous() { + try { + const response = await fetch(`${BETTER_AUTH_URL}/sign-in/anonymous`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Anonymous sign-in failed: ${response.status} ${errorText}`); + } + + // Extract session token from response header + const token = response.headers.get('set-auth-token'); + + if (!token) { + // Some configurations may return the token in the body instead + const body = await response.json(); + if (body.token) { + return { + token: body.token, + userId: body.user?.id, + user: body.user, + }; + } + throw new Error('No session token received from anonymous sign-in'); + } + + const body = await response.json(); + + return { + token, + userId: body.user?.id, + user: body.user, + }; + } catch (error) { + console.error(`Error connecting to better-auth service at ${BETTER_AUTH_URL}:`, error.message); + throw error; + } +} + +/** + * Gets a valid anonymous auth token for testing. + * Convenience wrapper around signInAnonymous that returns just the token. + * + * @returns {Promise} The session token + */ +async function getAnonymousToken() { + const { token } = await signInAnonymous(); + return token; +} + +/** + * Bans an anonymous user via better-auth admin API. + * Requires admin credentials to be set in AUTH_USERNAME and AUTH_PASSWORD env vars. + * + * @param {string} userId - The user ID to ban + * @param {string} reason - The ban reason + * @param {number} [expiresInSeconds] - Optional ban duration in seconds + * @returns {Promise} + */ +async function banUser(userId, reason, expiresInSeconds) { + const adminToken = await getAdminToken(); + + const body = { + userId, + banReason: reason, + }; + + if (expiresInSeconds) { + body.banExpiresIn = expiresInSeconds; + } + + const response = await fetch(`${BETTER_AUTH_URL}/admin/ban-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to ban user: ${response.status} ${errorText}`); + } +} + +/** + * Unbans a user via better-auth admin API. + * Requires admin credentials to be set in AUTH_USERNAME and AUTH_PASSWORD env vars. + * + * @param {string} userId - The user ID to unban + * @returns {Promise} + */ +async function unbanUser(userId) { + const adminToken = await getAdminToken(); + + const response = await fetch(`${BETTER_AUTH_URL}/admin/unban-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ userId }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to unban user: ${response.status} ${errorText}`); + } +} + +/** + * Gets an admin JWT token for admin operations. + * Uses AUTH_USERNAME and AUTH_PASSWORD env vars. + * + * @returns {Promise} Admin JWT token + */ +async function getAdminToken() { + const username = process.env.AUTH_USERNAME; + const password = process.env.AUTH_PASSWORD; + + if (!username || !password) { + throw new Error('AUTH_USERNAME and AUTH_PASSWORD environment variables must be set for admin operations'); + } + + // Sign in as admin + const signInResponse = await fetch(`${BETTER_AUTH_URL}/sign-in/username`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ username, password }), + }); + + if (!signInResponse.ok) { + const errorText = await signInResponse.text(); + throw new Error(`Admin sign-in failed: ${signInResponse.status} ${errorText}`); + } + + // Extract session cookies from response + const cookies = signInResponse.headers.getSetCookie(); + + if (!cookies || cookies.length === 0) { + throw new Error('No session cookie received from admin sign-in'); + } + + // Combine cookies for JWT request + const cookieHeader = cookies.map((cookie) => cookie.split(';')[0].trim()).join('; '); + + // Get JWT token + const jwtResponse = await fetch(`${BETTER_AUTH_URL}/token`, { + method: 'GET', + headers: { + Cookie: cookieHeader, + }, + credentials: 'include', + }); + + if (!jwtResponse.ok) { + const errorText = await jwtResponse.text(); + throw new Error(`Admin JWT token request failed: ${jwtResponse.status} ${errorText}`); + } + + const jwtData = await jwtResponse.json(); + + if (!jwtData?.token) { + throw new Error('No token in admin JWT response'); + } + + return jwtData.token; +} + +module.exports = { + signInAnonymous, + getAnonymousToken, + banUser, + unbanUser, + getAdminToken, + BETTER_AUTH_URL, +};