diff --git a/apps/backend/app.ts b/apps/backend/app.ts index c97b5ee6..762f7bb8 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -14,7 +14,8 @@ import { request_line_route } from './routes/requestLine.route.js'; import { showMemberMiddleware } from './middleware/checkShowMember.js'; import { activeShow } from './middleware/checkActiveShow.js'; import errorHandler from './middleware/errorHandler.js'; -import { requirePermissions } from '@wxyc/authentication'; +import { auth, requirePermissions } from '@wxyc/authentication'; +import { toNodeHandler } from 'better-auth/node'; const port = process.env.PORT || 8080; const app = express(); @@ -26,12 +27,93 @@ app.use(express.json()); app.use( cors({ origin: process.env.FRONTEND_SOURCE || '*', - methods: ['GET', 'POST', 'DELETE', 'PATCH'], - allowedHeaders: ['Content-Type', 'Authorization'], + methods: ['GET', 'POST', 'DELETE', 'PATCH', 'OPTIONS', 'PUT'], + allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'Set-Cookie'], + exposedHeaders: ['Content-Length', 'Set-Cookie'], credentials: true, }) ); +// Test helper endpoints for auth (must be registered BEFORE Better Auth handler) +// Disabled in production +if (process.env.NODE_ENV !== 'production') { + app.get('/auth/test/verification-token', async (req, res) => { + try { + const { identifier, type = 'reset-password' } = req.query; + if (!identifier || typeof identifier !== 'string') { + return res.status(400).json({ error: 'identifier query parameter is required (email address)' }); + } + + const { db, verification, user } = await import('@wxyc/database'); + const { eq, desc, like, and } = await import('drizzle-orm'); + + const userResult = await db.select({ id: user.id }).from(user).where(eq(user.email, identifier)).limit(1); + + if (userResult.length === 0) { + return res.status(404).json({ error: 'User not found with this email' }); + } + + const userId = userResult[0].id; + const tokenPrefix = `${type}:`; + const result = await db + .select() + .from(verification) + .where(and(eq(verification.value, userId), like(verification.identifier, `${tokenPrefix}%`))) + .orderBy(desc(verification.createdAt)) + .limit(1); + + if (result.length === 0) { + return res.status(404).json({ error: `No ${type} token found for this user` }); + } + + const fullIdentifier = result[0].identifier; + const token = fullIdentifier.startsWith(tokenPrefix) ? fullIdentifier.slice(tokenPrefix.length) : fullIdentifier; + + res.json({ + token, + expiresAt: result[0].expiresAt, + createdAt: result[0].createdAt, + }); + } catch (error) { + console.error('Error fetching verification token:', error); + res.status(500).json({ error: 'Failed to fetch verification token' }); + } + }); + + app.post('/auth/test/expire-session', async (req, res) => { + try { + const { userId } = req.body; + if (!userId || typeof userId !== 'string') { + return res.status(400).json({ error: 'userId is required in request body' }); + } + + const { db, session } = await import('@wxyc/database'); + const { eq } = await import('drizzle-orm'); + + await db + .update(session) + .set({ expiresAt: new Date(0) }) + .where(eq(session.userId, userId)); + + res.json({ success: true, message: `Session expired for user ${userId}` }); + } catch (error) { + console.error('Error expiring session:', error); + res.status(500).json({ error: 'Failed to expire session' }); + } + }); + + console.log( + '[TEST ENDPOINTS] Test helper endpoints enabled (/auth/test/verification-token, /auth/test/expire-session)' + ); +} + +// Mount Better Auth handler for all auth routes. +// better-auth derives its basePath from BETTER_AUTH_URL (e.g. /auth), +// so we mount at the root and let better-auth handle path matching. +// Express 5 strips the mount prefix when using app.use('/auth', ...), +// but toNodeHandler needs the full path, so mount at root. +app.all('/auth/{*path}', toNodeHandler(auth)); + // Serve documentation const swaggerDoc = parse_yaml(swaggerContent); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)); @@ -73,8 +155,167 @@ app.get('/healthcheck', async (req, res) => { app.use(errorHandler); -const server = app.listen(port, () => { - console.log(`listening on port: ${port}!`); -}); +// Create default user if configured +const createDefaultUser = async () => { + if (process.env.CREATE_DEFAULT_USER !== 'TRUE') return; + + try { + const email = process.env.DEFAULT_USER_EMAIL; + const username = process.env.DEFAULT_USER_USERNAME; + const password = process.env.DEFAULT_USER_PASSWORD; + const djName = process.env.DEFAULT_USER_DJ_NAME; + const realName = process.env.DEFAULT_USER_REAL_NAME; + + const organizationSlug = process.env.DEFAULT_ORG_SLUG; + const organizationName = process.env.DEFAULT_ORG_NAME; + + if (!username || !email || !password || !djName || !realName || !organizationSlug || !organizationName) { + throw new Error('Default user credentials are not fully set in environment variables.'); + } + + const context = await auth.$context; + const adapter = context.adapter; + const internalAdapter = context.internalAdapter; + const passwordUtility = context.password; + + const existingUser = await internalAdapter.findUserByEmail(email); + + if (existingUser) { + console.log('Default user already exists, skipping creation.'); + return; + } + + const newUser = await internalAdapter.createUser({ + email: email, + emailVerified: true, + name: username, + username: username, + createdAt: new Date(), + updatedAt: new Date(), + real_name: realName, + dj_name: djName, + app_skin: 'modern-light', + }); + + const hashedPassword = await passwordUtility.hash(password); + await internalAdapter.linkAccount({ + accountId: crypto.randomUUID(), + providerId: 'credential', + password: hashedPassword, + userId: newUser.id, + }); + + let organizationId; + + const existingOrganization = await adapter.findOne<{ id: string }>({ + model: 'organization', + where: [{ field: 'slug', value: organizationSlug }], + }); + + if (existingOrganization) { + organizationId = existingOrganization.id; + } else { + const newOrganization = await adapter.create({ + model: 'organization', + data: { + name: organizationName, + slug: organizationSlug, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + organizationId = newOrganization.id; + } + + if (!organizationId) { + throw new Error('Failed to create or retrieve organization for default user.'); + } + + const existingMembership = await adapter.findOne<{ id: string }>({ + model: 'member', + where: [ + { field: 'userId', value: newUser.id }, + { field: 'organizationId', value: organizationId }, + ], + }); + + if (existingMembership) { + throw new Error('Somehow, default user membership already exists for new user.'); + } + + await adapter.create({ + model: 'member', + data: { + userId: newUser.id, + organizationId: organizationId, + role: 'stationManager', + createdAt: new Date(), + }, + }); + + const { db, user } = await import('@wxyc/database'); + const { eq } = await import('drizzle-orm'); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, newUser.id)); + + console.log('Default user created successfully with admin role.'); + } catch (error) { + console.error('Error creating default user!'); + throw error; + } +}; + +// Fix admin roles for existing stationManagers (one-time migration) +const syncAdminRoles = async () => { + try { + const { db, user, member, organization } = await import('@wxyc/database'); + const { eq, sql } = await import('drizzle-orm'); + + const defaultOrgSlug = process.env.DEFAULT_ORG_SLUG; + if (!defaultOrgSlug) { + console.log('[ADMIN PERMISSIONS] DEFAULT_ORG_SLUG not set, skipping admin role fix'); + return; + } + + const usersNeedingFix = await db + .select({ + userId: user.id, + userEmail: user.email, + userRole: user.role, + memberRole: member.role, + }) + .from(user) + .innerJoin(member, sql`${member.userId} = ${user.id}` as any) + .innerJoin(organization, sql`${member.organizationId} = ${organization.id}` as any) + .where( + sql`${organization.slug} = ${defaultOrgSlug} + AND ${member.role} IN ('admin', 'owner', 'stationManager') + AND (${user.role} IS NULL OR ${user.role} != 'admin')` as any + ); + + if (usersNeedingFix.length > 0) { + console.log(`[ADMIN PERMISSIONS] Found ${usersNeedingFix.length} users needing admin role fix: `); + for (const u of usersNeedingFix) { + console.log(`[ADMIN PERMISSIONS] - ${u.userEmail} (${u.memberRole}) - current role: ${u.userRole || 'null'}`); + await db.update(user).set({ role: 'admin' }).where(eq(user.id, u.userId)); + console.log(`[ADMIN PERMISSIONS] - Fixed: ${u.userEmail} now has admin role`); + } + } else { + console.log('[ADMIN PERMISSIONS] All stationManagers already have admin role'); + } + } catch (error) { + console.error('[ADMIN PERMISSIONS] Error fixing admin roles:', error); + } +}; + +// Initialize default user and sync admin roles, then start server +void (async () => { + await createDefaultUser(); + await syncAdminRoles(); + + const server = app.listen(port, () => { + console.log(`listening on port: ${port}!`); + }); -server.setTimeout(30000); + server.setTimeout(30000); +})(); diff --git a/apps/backend/controllers/scanner.controller.ts b/apps/backend/controllers/scanner.controller.ts index 126c43c0..953883c0 100644 --- a/apps/backend/controllers/scanner.controller.ts +++ b/apps/backend/controllers/scanner.controller.ts @@ -1,11 +1,13 @@ /** - * Scanner controller for vinyl record image scanning and UPC lookup. + * Scanner controller for vinyl record image scanning, UPC lookup, + * and batch processing. */ import { RequestHandler } from 'express'; import { processImages } from '../services/scanner/processor.js'; import { ScanContext } from '../services/scanner/types.js'; import { DiscogsService } from '../services/discogs/discogs.service.js'; +import * as batchService from '../services/scanner/batch.js'; /** * POST /library/scan @@ -96,3 +98,122 @@ export const upcLookup: RequestHandler = async (req, res, next) => { next(error); } }; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const MAX_BATCH_ITEMS = 10; +const MAX_IMAGES_PER_ITEM = 5; +const MAX_TOTAL_IMAGES = 50; + +/** + * POST /library/scan/batch + * + * Accepts multipart form data with vinyl record images and a JSON manifest + * describing how images map to items. Returns 202 with a job ID for polling. + * + * Form fields: + * - images: up to 50 JPEG files (via multer) + * - manifest: JSON string with item groupings: + * { + * "items": [ + * { "imageCount": 2, "photoTypes": ["front_cover", "center_label"], "context": { "artistName": "..." } } + * ] + * } + */ +export const createBatchScan: RequestHandler = async (req, res, next) => { + try { + const files = req.files as Express.Multer.File[] | undefined; + if (!files || files.length === 0) { + res.status(400).json({ status: 400, message: 'No images provided' }); + return; + } + + // Parse manifest + const rawManifest = req.body.manifest; + if (!rawManifest || typeof rawManifest !== 'string') { + res.status(400).json({ status: 400, message: 'Missing or invalid manifest field' }); + return; + } + + let manifest: { items: batchService.BatchItem[] }; + try { + manifest = JSON.parse(rawManifest); + } catch { + res.status(400).json({ status: 400, message: 'Invalid JSON in manifest field' }); + return; + } + + if (!manifest.items || !Array.isArray(manifest.items) || manifest.items.length === 0) { + res.status(400).json({ status: 400, message: 'Manifest must contain a non-empty items array' }); + return; + } + + // Validate limits + if (manifest.items.length > MAX_BATCH_ITEMS) { + res.status(400).json({ status: 400, message: `Batch cannot exceed ${MAX_BATCH_ITEMS} items` }); + return; + } + + const totalExpectedImages = manifest.items.reduce((sum, item) => sum + (item.imageCount || 0), 0); + + if (totalExpectedImages > MAX_TOTAL_IMAGES) { + res.status(400).json({ status: 400, message: `Total images cannot exceed ${MAX_TOTAL_IMAGES}` }); + return; + } + + for (const item of manifest.items) { + if (item.imageCount > MAX_IMAGES_PER_ITEM) { + res.status(400).json({ status: 400, message: `Each item cannot exceed ${MAX_IMAGES_PER_ITEM} images` }); + return; + } + } + + if (files.length !== totalExpectedImages) { + res.status(400).json({ + status: 400, + message: `Image count mismatch: ${files.length} files uploaded but manifest expects ${totalExpectedImages}`, + }); + return; + } + + const imageBuffers = files.map((file) => file.buffer); + const userId = req.auth!.id!; + + const result = await batchService.createBatchJob(userId, manifest.items, imageBuffers); + + res.status(202).json(result); + } catch (error) { + console.error('Error creating batch scan:', error); + next(error); + } +}; + +/** + * GET /library/scan/batch/:jobId + * + * Returns the current status of a batch scan job, including individual + * result statuses and extraction data. + */ +export const getBatchStatus: RequestHandler = async (req, res, next) => { + try { + const { jobId } = req.params; + + if (!UUID_REGEX.test(jobId)) { + res.status(400).json({ status: 400, message: 'Invalid job ID format' }); + return; + } + + const userId = req.auth!.id!; + const status = await batchService.getJobStatus(jobId, userId); + + if (!status) { + res.status(404).json({ status: 404, message: 'Job not found' }); + return; + } + + res.status(200).json(status); + } catch (error) { + console.error('Error getting batch status:', error); + next(error); + } +}; diff --git a/apps/backend/routes/scanner.route.ts b/apps/backend/routes/scanner.route.ts index 154e099e..89229af4 100644 --- a/apps/backend/routes/scanner.route.ts +++ b/apps/backend/routes/scanner.route.ts @@ -1,5 +1,6 @@ /** - * Scanner routes for vinyl record image scanning and UPC lookup. + * Scanner routes for vinyl record image scanning, UPC lookup, + * and batch processing. */ import { requirePermissions } from '@wxyc/authentication'; @@ -21,4 +22,13 @@ scanner_route.post( scannerController.scanImages ); +scanner_route.post( + '/batch', + requirePermissions({ catalog: ['write'] }), + upload.array('images', 50), + scannerController.createBatchScan +); + +scanner_route.get('/batch/:jobId', requirePermissions({ catalog: ['read'] }), scannerController.getBatchStatus); + scanner_route.post('/upc-lookup', requirePermissions({ catalog: ['read'] }), scannerController.upcLookup); diff --git a/apps/backend/services/scanner/batch.ts b/apps/backend/services/scanner/batch.ts new file mode 100644 index 00000000..5cf1b33c --- /dev/null +++ b/apps/backend/services/scanner/batch.ts @@ -0,0 +1,287 @@ +/** + * Batch scan processing service. + * + * Manages batch jobs where multiple vinyl records are scanned in one request. + * Each job contains multiple items, each processed sequentially through the + * Gemini extraction pipeline. + */ + +import { eq, asc, sql, inArray } from 'drizzle-orm'; +import { db, scan_jobs, scan_results, library_artist_view } from '@wxyc/database'; +import { processImages } from './processor.js'; +import { ScanContext } from './types.js'; + +/** + * Describes a single item in a batch scan request. + */ +export interface BatchItem { + imageCount: number; + photoTypes: string[]; + context: ScanContext; +} + +/** + * Response from creating a batch job. + */ +export interface BatchJobCreated { + jobId: string; + status: 'pending'; + totalItems: number; +} + +/** + * Album details for a matched catalog item, hydrated from library_artist_view. + */ +export interface MatchedAlbumInfo { + id: number; + artistName: string; + albumTitle: string; + codeLetters: string; + codeArtistNumber: number; + codeNumber: number; + genreName: string; + formatName: string; + label: string | null; +} + +/** + * Status of a single scan result within a batch job. + */ +export interface BatchResultStatus { + itemIndex: number; + status: string; + extraction: unknown; + matchedAlbumId: number | null; + matchedAlbum: MatchedAlbumInfo | null; + errorMessage: string | null; +} + +/** + * Full status of a batch job including all results. + */ +export interface BatchJobStatus { + jobId: string; + status: string; + totalItems: number; + completedItems: number; + failedItems: number; + results: BatchResultStatus[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * Create a new batch scan job. + * + * Inserts the job and result rows, then kicks off background processing + * via setImmediate so the HTTP response returns immediately. + * + * @param userId - Authenticated user ID + * @param items - Batch item descriptors (imageCount, photoTypes, context) + * @param imageBuffers - All image buffers in order (consumed by items sequentially) + * @returns Job ID and initial status + */ +export async function createBatchJob( + userId: string, + items: BatchItem[], + imageBuffers: Buffer[] +): Promise { + const jobId = crypto.randomUUID(); + + // Insert the job row + await db.insert(scan_jobs).values({ + id: jobId, + user_id: userId, + status: 'pending', + total_items: items.length, + completed_items: 0, + failed_items: 0, + }); + + // Insert a result row for each item + const resultRows = items.map((item, index) => ({ + job_id: jobId, + item_index: index, + status: 'pending' as const, + context: item.context, + })); + await db.insert(scan_results).values(resultRows); + + // Fire-and-forget background processing + setImmediate(() => { + processJobItems(jobId, items, imageBuffers).catch((err) => { + console.error(`[Scanner] Batch job ${jobId} failed unexpectedly:`, err); + }); + }); + + return { + jobId, + status: 'pending', + totalItems: items.length, + }; +} + +/** + * Get the status of a batch job, including all individual results. + * + * Returns null if the job does not exist or does not belong to the given user + * (ownership check prevents enumeration). + * + * @param jobId - The batch job UUID + * @param userId - Authenticated user ID (ownership check) + * @returns Job status with results, or null if not found/unauthorized + */ +export async function getJobStatus(jobId: string, userId: string): Promise { + const jobs = await db.select().from(scan_jobs).where(eq(scan_jobs.id, jobId)).execute(); + + if (jobs.length === 0) { + return null; + } + + const job = jobs[0]; + + // Ownership check: return null (indistinguishable from not-found) + if (job.user_id !== userId) { + return null; + } + + const results = await db + .select() + .from(scan_results) + .where(eq(scan_results.job_id, jobId)) + .orderBy(asc(scan_results.item_index)) + .execute(); + + // Hydrate matched album details from library_artist_view + const albumIds = results + .map((r) => r.matched_album_id) + .filter((id): id is number => id !== null); + + const albumMap = new Map(); + if (albumIds.length > 0) { + const albums = await db + .select() + .from(library_artist_view) + .where(inArray(library_artist_view.id, albumIds)) + .execute(); + + for (const album of albums) { + albumMap.set(album.id, { + id: album.id, + artistName: album.artist_name, + albumTitle: album.album_title, + codeLetters: album.code_letters, + codeArtistNumber: album.code_artist_number, + codeNumber: album.code_number, + genreName: album.genre_name, + formatName: album.format_name, + label: album.label, + }); + } + } + + return { + jobId: job.id, + status: job.status, + totalItems: job.total_items, + completedItems: job.completed_items, + failedItems: job.failed_items, + createdAt: job.created_at, + updatedAt: job.updated_at, + results: results.map((r) => ({ + itemIndex: r.item_index, + status: r.status, + extraction: r.extraction, + matchedAlbumId: r.matched_album_id, + matchedAlbum: r.matched_album_id ? albumMap.get(r.matched_album_id) ?? null : null, + errorMessage: r.error_message, + })), + }; +} + +/** + * Process all items in a batch job sequentially. + * + * Updates job and result statuses as each item is processed. + * On completion, sets the job status to 'completed' if any items succeeded, + * or 'failed' if all items failed. + * + * @param jobId - The batch job UUID + * @param items - Batch item descriptors + * @param imageBuffers - All image buffers in order + */ +export async function processJobItems(jobId: string, items: BatchItem[], imageBuffers: Buffer[]): Promise { + // Mark job as processing + await db + .update(scan_jobs) + .set({ status: 'processing', updated_at: new Date() }) + .where(eq(scan_jobs.id, jobId)) + .execute(); + + let completedCount = 0; + let failedCount = 0; + let bufferOffset = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const itemImages = imageBuffers.slice(bufferOffset, bufferOffset + item.imageCount); + bufferOffset += item.imageCount; + + // Mark this result as processing + await db + .update(scan_results) + .set({ status: 'processing' }) + .where(sql`${scan_results.job_id} = ${jobId} AND ${scan_results.item_index} = ${i}`) + .execute(); + + try { + const result = await processImages(itemImages, item.photoTypes, item.context); + + completedCount++; + await db + .update(scan_results) + .set({ + status: 'completed', + extraction: result.extraction, + matched_album_id: result.matchedAlbumId ?? null, + completed_at: new Date(), + }) + .where(sql`${scan_results.job_id} = ${jobId} AND ${scan_results.item_index} = ${i}`) + .execute(); + + await db + .update(scan_jobs) + .set({ completed_items: completedCount, updated_at: new Date() }) + .where(eq(scan_jobs.id, jobId)) + .execute(); + } catch (error) { + failedCount++; + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[Scanner] Batch item ${i} failed for job ${jobId}:`, errorMessage); + + await db + .update(scan_results) + .set({ + status: 'failed', + error_message: errorMessage, + completed_at: new Date(), + }) + .where(sql`${scan_results.job_id} = ${jobId} AND ${scan_results.item_index} = ${i}`) + .execute(); + + await db + .update(scan_jobs) + .set({ failed_items: failedCount, updated_at: new Date() }) + .where(eq(scan_jobs.id, jobId)) + .execute(); + } + } + + // Set final job status + const finalStatus = completedCount > 0 ? 'completed' : 'failed'; + await db + .update(scan_jobs) + .set({ status: finalStatus, updated_at: new Date() }) + .where(eq(scan_jobs.id, jobId)) + .execute(); +} diff --git a/apps/backend/services/scanner/gemini.service.ts b/apps/backend/services/scanner/gemini.service.ts index 0ffcf655..85631377 100644 --- a/apps/backend/services/scanner/gemini.service.ts +++ b/apps/backend/services/scanner/gemini.service.ts @@ -41,6 +41,8 @@ export function resetGeminiClient(): void { * Raw response shape from Gemini extraction. */ interface RawExtractionResponse { + artist_name?: { value: string; confidence: number }; + album_title?: { value: string; confidence: number }; label_name?: { value: string; confidence: number }; catalog_number?: { value: string; confidence: number }; review_text?: { value: string; confidence: number }; @@ -75,7 +77,7 @@ export async function extractFromImages( context: ScanContext ): Promise { const client = getGeminiClient(); - const model = client.getGenerativeModel({ model: 'gemini-2.0-flash' }); + const model = client.getGenerativeModel({ model: 'gemini-3-flash-preview' }); console.log(`[Scanner] Extracting metadata from ${images.length} image(s)`); @@ -114,6 +116,12 @@ export async function extractFromImages( const extraction: ScanExtraction = {}; + const artistName = parseField(parsed.artist_name); + if (artistName) extraction.artistName = artistName; + + const albumTitle = parseField(parsed.album_title); + if (albumTitle) extraction.albumTitle = albumTitle; + const labelName = parseField(parsed.label_name); if (labelName) extraction.labelName = labelName; diff --git a/apps/backend/services/scanner/processor.ts b/apps/backend/services/scanner/processor.ts index 37efc3df..f0799eb7 100644 --- a/apps/backend/services/scanner/processor.ts +++ b/apps/backend/services/scanner/processor.ts @@ -49,8 +49,8 @@ export async function processImages(images: Buffer[], photoTypes: string[], cont * a fuzzy search of the library database. */ async function tryMatchCatalog(extraction: ScanExtraction, context: ScanContext): Promise { - const artistName = context.artistName || extraction.labelName?.value; - const albumTitle = context.albumTitle; + const artistName = context.artistName || extraction.artistName?.value; + const albumTitle = context.albumTitle || extraction.albumTitle?.value; if (!artistName && !albumTitle) { return undefined; diff --git a/apps/backend/services/scanner/prompts.ts b/apps/backend/services/scanner/prompts.ts index 7cf8872b..99498863 100644 --- a/apps/backend/services/scanner/prompts.ts +++ b/apps/backend/services/scanner/prompts.ts @@ -15,10 +15,12 @@ export const SCANNER_SYSTEM_PROMPT = `You are a metadata extraction system for a Your task is to examine photos of vinyl records and extract the following fields: -1. **label_name**: The record label printed on the center label or sleeve (e.g., "Sub Pop", "Merge Records", "4AD"). -2. **catalog_number**: The catalog/release number assigned by the label (e.g., "SP 1234", "MRG-567"). This is NOT the library code. -3. **review_text**: Any handwritten DJ notes or reviews found on the record, sleeve, or sticker. These are typically brief opinions about the music written by station DJs (e.g., "Great opener, side B is stronger", "Play track 3!"). -4. **upc**: The UPC/EAN barcode number, if visible (a 12- or 13-digit number). +1. **artist_name**: The performing artist or band name, typically printed prominently on the front cover, spine, or center label. +2. **album_title**: The album or release title, typically on the front cover, spine, or center label. +3. **label_name**: The record label printed on the center label or sleeve (e.g., "Sub Pop", "Merge Records", "4AD"). +4. **catalog_number**: The catalog/release number assigned by the label (e.g., "SP 1234", "MRG-567"). This is NOT the library code. +5. **review_text**: Any handwritten DJ notes or reviews found on the record, sleeve, or sticker. These are typically brief opinions about the music written by station DJs (e.g., "Great opener, side B is stronger", "Play track 3!"). +6. **upc**: The UPC/EAN barcode number, if visible (a 12- or 13-digit number). For each field you extract, provide a confidence score between 0 and 1: - 1.0: Text is clearly legible and unambiguous @@ -35,6 +37,8 @@ Important notes: Respond with valid JSON only, no markdown formatting. Use this exact structure: { + "artist_name": { "value": "string", "confidence": number }, + "album_title": { "value": "string", "confidence": number }, "label_name": { "value": "string", "confidence": number }, "catalog_number": { "value": "string", "confidence": number }, "review_text": { "value": "string", "confidence": number }, diff --git a/apps/backend/services/scanner/types.ts b/apps/backend/services/scanner/types.ts index a1d10747..745c3852 100644 --- a/apps/backend/services/scanner/types.ts +++ b/apps/backend/services/scanner/types.ts @@ -29,6 +29,8 @@ export interface ExtractionField { * Structured extraction results from Gemini image analysis. */ export interface ScanExtraction { + artistName?: ExtractionField; + albumTitle?: ExtractionField; labelName?: ExtractionField; catalogNumber?: ExtractionField; reviewText?: ExtractionField; diff --git a/jest.unit.config.ts b/jest.unit.config.ts index 5cd78097..ddb7cc1d 100644 --- a/jest.unit.config.ts +++ b/jest.unit.config.ts @@ -25,6 +25,7 @@ const config: Config = { // Remove .js extensions from relative imports (ESM compatibility) '^(\\.{1,2}/.*)\\.(js)$': '$1', }, + modulePathIgnorePatterns: ['/.claude/worktrees/'], collectCoverageFrom: ['apps/backend/**/*.ts', '!**/*.d.ts', '!**/dist/**'], clearMocks: true, }; diff --git a/shared/database/src/migrations/0027_scan-jobs-tables.sql b/shared/database/src/migrations/0027_scan-jobs-tables.sql new file mode 100644 index 00000000..b6a199ef --- /dev/null +++ b/shared/database/src/migrations/0027_scan-jobs-tables.sql @@ -0,0 +1,30 @@ +CREATE TYPE "wxyc_schema"."scan_job_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint +CREATE TYPE "wxyc_schema"."scan_result_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint +CREATE TABLE "wxyc_schema"."scan_jobs" ( + "id" uuid PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "status" "wxyc_schema"."scan_job_status" DEFAULT 'pending' NOT NULL, + "total_items" smallint NOT NULL, + "completed_items" smallint DEFAULT 0 NOT NULL, + "failed_items" smallint DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wxyc_schema"."scan_results" ( + "id" serial PRIMARY KEY NOT NULL, + "job_id" uuid NOT NULL, + "item_index" smallint NOT NULL, + "status" "wxyc_schema"."scan_result_status" DEFAULT 'pending' NOT NULL, + "context" jsonb, + "extraction" jsonb, + "matched_album_id" integer, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "wxyc_schema"."scan_jobs" ADD CONSTRAINT "scan_jobs_user_id_auth_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wxyc_schema"."scan_results" ADD CONSTRAINT "scan_results_job_id_scan_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "wxyc_schema"."scan_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wxyc_schema"."scan_results" ADD CONSTRAINT "scan_results_matched_album_id_library_id_fk" FOREIGN KEY ("matched_album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "scan_results_job_id_idx" ON "wxyc_schema"."scan_results" USING btree ("job_id"); diff --git a/shared/database/src/migrations/meta/0027_snapshot.json b/shared/database/src/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..c6ac38f3 --- /dev/null +++ b/shared/database/src/migrations/meta/0027_snapshot.json @@ -0,0 +1,2879 @@ +{ + "id": "ede8cebc-40fd-42e4-b483-653cb55cc4d8", + "prevId": "df397568-f27d-4a06-9aba-08264d97ae8e", + "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 + }, + "entry_type": { + "name": "entry_type", + "type": "flowsheet_entry_type", + "typeSchema": "wxyc_schema", + "primaryKey": false, + "notNull": true, + "default": "'track'" + }, + "track_title": { + "name": "track_title", + "type": "varchar(128)", + "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 + }, + "rotation_bin": { + "name": "rotation_bin", + "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.scan_jobs": { + "name": "scan_jobs", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "scan_job_status", + "typeSchema": "wxyc_schema", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_items": { + "name": "total_items", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "completed_items": { + "name": "completed_items", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "scan_jobs_user_id_auth_user_id_fk": { + "name": "scan_jobs_user_id_auth_user_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "wxyc_schema.scan_results": { + "name": "scan_results", + "schema": "wxyc_schema", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_index": { + "name": "item_index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "scan_result_status", + "typeSchema": "wxyc_schema", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "extraction": { + "name": "extraction", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "matched_album_id": { + "name": "matched_album_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scan_results_job_id_idx": { + "name": "scan_results_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scan_results_job_id_scan_jobs_id_fk": { + "name": "scan_results_job_id_scan_jobs_id_fk", + "tableFrom": "scan_results", + "tableTo": "scan_jobs", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scan_results_matched_album_id_library_id_fk": { + "name": "scan_results_matched_album_id_library_id_fk", + "tableFrom": "scan_results", + "tableTo": "library", + "schemaTo": "wxyc_schema", + "columnsFrom": [ + "matched_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": { + "wxyc_schema.flowsheet_entry_type": { + "name": "flowsheet_entry_type", + "schema": "wxyc_schema", + "values": [ + "track", + "show_start", + "show_end", + "dj_join", + "dj_leave", + "talkset", + "breakpoint", + "message" + ] + }, + "public.freq_enum": { + "name": "freq_enum", + "schema": "public", + "values": [ + "S", + "L", + "M", + "H" + ] + }, + "wxyc_schema.scan_job_status": { + "name": "scan_job_status", + "schema": "wxyc_schema", + "values": [ + "pending", + "processing", + "completed", + "failed" + ] + }, + "wxyc_schema.scan_result_status": { + "name": "scan_result_status", + "schema": "wxyc_schema", + "values": [ + "pending", + "processing", + "completed", + "failed" + ] + } + }, + "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 + }, + "rotation_bin": { + "name": "rotation_bin", + "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\".\"rotation_bin\", \"wxyc_schema\".\"library\".\"add_date\", \"wxyc_schema\".\"library\".\"label\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\" inner join \"wxyc_schema\".\"format\" on \"wxyc_schema\".\"format\".\"id\" = \"wxyc_schema\".\"library\".\"format_id\" inner join \"wxyc_schema\".\"genres\" on \"wxyc_schema\".\"genres\".\"id\" = \"wxyc_schema\".\"library\".\"genre_id\" left join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"rotation\".\"album_id\" = \"wxyc_schema\".\"library\".\"id\" AND (\"wxyc_schema\".\"rotation\".\"kill_date\" < CURRENT_DATE OR \"wxyc_schema\".\"rotation\".\"kill_date\" IS NULL)", + "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 + }, + "rotation_bin": { + "name": "rotation_bin", + "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\".\"rotation_bin\", \"wxyc_schema\".\"library\".\"album_title\", \"wxyc_schema\".\"artists\".\"artist_name\", \"wxyc_schema\".\"rotation\".\"kill_date\" from \"wxyc_schema\".\"library\" inner join \"wxyc_schema\".\"rotation\" on \"wxyc_schema\".\"library\".\"id\" = \"wxyc_schema\".\"rotation\".\"album_id\" inner join \"wxyc_schema\".\"artists\" on \"wxyc_schema\".\"artists\".\"id\" = \"wxyc_schema\".\"library\".\"artist_id\"", + "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/0029_snapshot.json b/shared/database/src/migrations/meta/0029_snapshot.json index 0426bcf7..664158ba 100644 --- a/shared/database/src/migrations/meta/0029_snapshot.json +++ b/shared/database/src/migrations/meta/0029_snapshot.json @@ -1,6 +1,6 @@ { "id": "df397568-f27d-4a06-9aba-08264d97ae8e", - "prevId": "335ff99b-a9d0-49ec-a275-2bf4e9b2edf7", + "prevId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "version": "7", "dialect": "postgresql", "tables": { diff --git a/shared/database/src/migrations/meta/_journal.json b/shared/database/src/migrations/meta/_journal.json index 05edfa78..d38c3a02 100644 --- a/shared/database/src/migrations/meta/_journal.json +++ b/shared/database/src/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1771099200000, "tag": "0029_rename_play_freq_to_rotation_bin", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1772311941106, + "tag": "0027_scan-jobs-tables", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/shared/database/src/schema.ts b/shared/database/src/schema.ts index 7a9bfc5e..6b031352 100644 --- a/shared/database/src/schema.ts +++ b/shared/database/src/schema.ts @@ -15,6 +15,8 @@ import { pgEnum, date, uniqueIndex, + uuid, + jsonb, } from 'drizzle-orm/pg-core'; // Schema name is configurable for parallel test isolation (each Jest worker gets its own schema) @@ -563,3 +565,55 @@ export const anonymous_devices = pgTable( export type AnonymousDevice = InferSelectModel; export type NewAnonymousDevice = InferInsertModel; + +// Scanner batch processing tables +export const scanJobStatusEnum = wxyc_schema.enum('scan_job_status', ['pending', 'processing', 'completed', 'failed']); + +export const scanResultStatusEnum = wxyc_schema.enum('scan_result_status', [ + 'pending', + 'processing', + 'completed', + 'failed', +]); + +export const scan_jobs = wxyc_schema.table('scan_jobs', { + id: uuid('id').primaryKey(), + user_id: varchar('user_id', { length: 255 }) + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: scanJobStatusEnum('status').notNull().default('pending'), + total_items: smallint('total_items').notNull(), + completed_items: smallint('completed_items').notNull().default(0), + failed_items: smallint('failed_items').notNull().default(0), + created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updated_at: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type ScanJobRow = InferSelectModel; +export type NewScanJobRow = InferInsertModel; + +export const scan_results = wxyc_schema.table( + 'scan_results', + { + id: serial('id').primaryKey(), + job_id: uuid('job_id') + .notNull() + .references(() => scan_jobs.id, { onDelete: 'cascade' }), + item_index: smallint('item_index').notNull(), + status: scanResultStatusEnum('status').notNull().default('pending'), + context: jsonb('context'), + extraction: jsonb('extraction'), + matched_album_id: integer('matched_album_id').references(() => library.id), + error_message: text('error_message'), + created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + completed_at: timestamp('completed_at', { withTimezone: true }), + }, + (table) => { + return { + jobIdIdx: index('scan_results_job_id_idx').on(table.job_id), + }; + } +); + +export type ScanResultRow = InferSelectModel; +export type NewScanResultRow = InferInsertModel; diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts index 64a7e216..0abdf123 100644 --- a/tests/mocks/database.mock.ts +++ b/tests/mocks/database.mock.ts @@ -90,9 +90,13 @@ export const shows = {}; export const show_djs = {}; export const user = {}; export const specialty_shows = {}; +export const scan_jobs = {}; +export const scan_results = {}; -// Mock enum +// Mock enums export const flowsheetEntryTypeEnum = () => ({}); +export const scanJobStatusEnum = () => ({}); +export const scanResultStatusEnum = () => ({}); // Mock types export type AnonymousDevice = { @@ -131,3 +135,8 @@ export type NewFSEntry = Partial; export type Show = Record; export type ShowDJ = Record; export type User = Record; + +export type ScanJobRow = Record; +export type NewScanJobRow = Record; +export type ScanResultRow = Record; +export type NewScanResultRow = Record; diff --git a/tests/unit/controllers/scanner.controller.test.ts b/tests/unit/controllers/scanner.controller.test.ts new file mode 100644 index 00000000..6f054f2a --- /dev/null +++ b/tests/unit/controllers/scanner.controller.test.ts @@ -0,0 +1,219 @@ +/** + * Unit tests for the scanner controller batch endpoints. + */ + +import { jest } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; + +// Mock the batch service +const mockCreateBatchJob = jest.fn< + ( + userId: string, + items: unknown[], + imageBuffers: Buffer[] + ) => Promise<{ + jobId: string; + status: string; + totalItems: number; + }> +>(); +const mockGetJobStatus = jest.fn<(jobId: string, userId: string) => Promise>(); + +jest.mock('../../../apps/backend/services/scanner/batch', () => ({ + createBatchJob: mockCreateBatchJob, + getJobStatus: mockGetJobStatus, +})); + +// Mock the processor (for scanImages handler which we're not testing here but need for the import) +jest.mock('../../../apps/backend/services/scanner/processor', () => ({ + processImages: jest.fn(), +})); + +// Mock discogs service +jest.mock('../../../apps/backend/services/discogs/discogs.service', () => ({ + DiscogsService: { + searchByBarcode: jest.fn(), + }, +})); + +import { createBatchScan, getBatchStatus } from '../../../apps/backend/controllers/scanner.controller'; + +// Helper to create mock Express req/res/next +const createMockRes = () => { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + return res; +}; + +describe('scanner.controller', () => { + let mockNext: NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockNext = jest.fn() as unknown as NextFunction; + }); + + describe('createBatchScan', () => { + it('returns 400 when no images are uploaded', async () => { + const req = { + files: [], + body: { manifest: JSON.stringify({ items: [] }) }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('image') })); + }); + + it('returns 400 when manifest is missing', async () => { + const req = { + files: [{ buffer: Buffer.from('img'), mimetype: 'image/jpeg' }], + body: {}, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('manifest') })); + }); + + it('returns 400 when manifest has invalid JSON', async () => { + const req = { + files: [{ buffer: Buffer.from('img'), mimetype: 'image/jpeg' }], + body: { manifest: 'not valid json' }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when image count does not match manifest sum', async () => { + const manifest = { + items: [{ imageCount: 3, photoTypes: ['front_cover', 'back_cover', 'center_label'], context: {} }], + }; + const req = { + files: [{ buffer: Buffer.from('img1') }], // only 1 image, manifest says 3 + body: { manifest: JSON.stringify(manifest) }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when batch exceeds 10 items', async () => { + const items = Array.from({ length: 11 }, () => ({ + imageCount: 1, + photoTypes: ['front_cover'], + context: {}, + })); + const files = Array.from({ length: 11 }, () => ({ buffer: Buffer.from('img') })); + const req = { + files, + body: { manifest: JSON.stringify({ items }) }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 202 with job info on success', async () => { + const manifest = { + items: [ + { imageCount: 2, photoTypes: ['front_cover', 'center_label'], context: { artistName: 'Superchunk' } }, + { imageCount: 1, photoTypes: ['front_cover'], context: {} }, + ], + }; + const files = [{ buffer: Buffer.from('img1') }, { buffer: Buffer.from('img2') }, { buffer: Buffer.from('img3') }]; + const req = { + files, + body: { manifest: JSON.stringify(manifest) }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + mockCreateBatchJob.mockResolvedValue({ + jobId: 'job-uuid', + status: 'pending', + totalItems: 2, + }); + + await createBatchScan(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(202); + expect(mockCreateBatchJob).toHaveBeenCalledWith('user-123', manifest.items, [ + files[0].buffer, + files[1].buffer, + files[2].buffer, + ]); + }); + }); + + describe('getBatchStatus', () => { + it('returns 400 for invalid UUID', async () => { + const req = { + params: { jobId: 'not-a-uuid' }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + await getBatchStatus(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 404 when job not found', async () => { + const req = { + params: { jobId: '550e8400-e29b-41d4-a716-446655440000' }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + mockGetJobStatus.mockResolvedValue(null); + + await getBatchStatus(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 200 with job status', async () => { + const jobStatus = { + jobId: '550e8400-e29b-41d4-a716-446655440000', + status: 'completed', + totalItems: 2, + completedItems: 2, + failedItems: 0, + results: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + const req = { + params: { jobId: '550e8400-e29b-41d4-a716-446655440000' }, + auth: { id: 'user-123' }, + } as unknown as Request; + const res = createMockRes(); + + mockGetJobStatus.mockResolvedValue(jobStatus); + + await getBatchStatus(req, res as Response, mockNext); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(jobStatus); + expect(mockGetJobStatus).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', 'user-123'); + }); + }); +}); diff --git a/tests/unit/services/scanner/batch.test.ts b/tests/unit/services/scanner/batch.test.ts new file mode 100644 index 00000000..f52cf609 --- /dev/null +++ b/tests/unit/services/scanner/batch.test.ts @@ -0,0 +1,379 @@ +/** + * Unit tests for the batch scan processing service. + */ + +import { jest } from '@jest/globals'; + +// Mock the database +const mockInsert = jest.fn().mockReturnThis(); +const mockValues = jest.fn().mockReturnThis(); +const mockReturning = jest.fn<() => Promise>().mockResolvedValue([]); +const mockSelect = jest.fn().mockReturnThis(); +const mockFrom = jest.fn().mockReturnThis(); +const mockWhere = jest.fn().mockReturnThis(); +const mockOrderBy = jest.fn().mockReturnThis(); +const mockUpdate = jest.fn().mockReturnThis(); +const mockSet = jest.fn().mockReturnThis(); +const mockExecute = jest.fn<() => Promise>().mockResolvedValue([]); + +jest.mock('@wxyc/database', () => ({ + db: { + insert: mockInsert, + select: mockSelect, + update: mockUpdate, + }, + scan_jobs: { + id: 'id', + user_id: 'user_id', + status: 'status', + completed_items: 'completed_items', + failed_items: 'failed_items', + updated_at: 'updated_at', + }, + scan_results: { + id: 'id', + job_id: 'job_id', + item_index: 'item_index', + status: 'status', + extraction: 'extraction', + matched_album_id: 'matched_album_id', + error_message: 'error_message', + completed_at: 'completed_at', + }, + library_artist_view: { + id: 'id', + artist_name: 'artist_name', + album_title: 'album_title', + code_letters: 'code_letters', + code_artist_number: 'code_artist_number', + code_number: 'code_number', + genre_name: 'genre_name', + format_name: 'format_name', + label: 'label', + }, +})); + +// Wire up the chain: insert().values().returning() and select().from().where().orderBy().execute() +mockInsert.mockReturnValue({ values: mockValues }); +mockValues.mockReturnValue({ returning: mockReturning }); +mockSelect.mockReturnValue({ from: mockFrom }); +mockFrom.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); +mockOrderBy.mockReturnValue({ execute: mockExecute }); +mockUpdate.mockReturnValue({ set: mockSet }); +mockSet.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); + +// Mock the processor module +const mockProcessImages = jest.fn< + ( + images: Buffer[], + photoTypes: string[], + context: Record + ) => Promise<{ + extraction: Record; + matchedAlbumId?: number; + }> +>(); +jest.mock('../../../../apps/backend/services/scanner/processor', () => ({ + processImages: mockProcessImages, +})); + +// Mock drizzle-orm operators +jest.mock('drizzle-orm', () => ({ + eq: jest.fn((a, b) => ({ eq: [a, b] })), + asc: jest.fn((col) => ({ asc: col })), + inArray: jest.fn((col, values) => ({ inArray: [col, values] })), + sql: Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ sql: strings, values })), + { + raw: jest.fn((s: string) => ({ raw: s })), + } + ), +})); + +// Mock crypto.randomUUID +const mockUUID = '550e8400-e29b-41d4-a716-446655440000'; +jest.spyOn(crypto, 'randomUUID').mockReturnValue(mockUUID as `${string}-${string}-${string}-${string}-${string}`); + +import { createBatchJob, getJobStatus, processJobItems } from '../../../../apps/backend/services/scanner/batch'; + +describe('batch service', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Re-wire the chain after clearAllMocks + mockInsert.mockReturnValue({ values: mockValues }); + mockValues.mockReturnValue({ returning: mockReturning }); + mockSelect.mockReturnValue({ from: mockFrom }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockWhere.mockReturnValue({ orderBy: mockOrderBy, execute: mockExecute }); + mockOrderBy.mockReturnValue({ execute: mockExecute }); + mockUpdate.mockReturnValue({ set: mockSet }); + mockSet.mockReturnValue({ where: mockWhere }); + + mockReturning.mockResolvedValue([]); + mockExecute.mockResolvedValue([]); + }); + + describe('createBatchJob', () => { + const userId = 'user-123'; + const items = [ + { imageCount: 2, photoTypes: ['front_cover', 'center_label'], context: { artistName: 'Superchunk' } }, + { imageCount: 1, photoTypes: ['front_cover'], context: {} }, + ]; + const imageBuffers = [Buffer.from('img1'), Buffer.from('img2'), Buffer.from('img3')]; + let setImmediateSpy: jest.SpiedFunction; + + beforeEach(() => { + // Prevent setImmediate callbacks from firing after tests complete + setImmediateSpy = jest + .spyOn(global, 'setImmediate') + .mockImplementation((() => {}) as unknown as typeof setImmediate); + }); + + afterEach(() => { + setImmediateSpy.mockRestore(); + }); + + it('returns job info with pending status', async () => { + mockReturning.mockResolvedValueOnce([{ id: mockUUID }]); + + const result = await createBatchJob(userId, items, imageBuffers); + + expect(result).toEqual({ + jobId: mockUUID, + status: 'pending', + totalItems: 2, + }); + }); + + it('inserts a job row and result rows', async () => { + mockReturning.mockResolvedValueOnce([{ id: mockUUID }]); + + await createBatchJob(userId, items, imageBuffers); + + // Should call insert twice: once for job, once for results + expect(mockInsert).toHaveBeenCalledTimes(2); + }); + + it('fires background processing via setImmediate', async () => { + mockReturning.mockResolvedValueOnce([{ id: mockUUID }]); + + await createBatchJob(userId, items, imageBuffers); + + expect(setImmediateSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getJobStatus', () => { + const jobId = mockUUID; + const userId = 'user-123'; + + it('returns null when job not found', async () => { + mockExecute.mockResolvedValueOnce([]); + + const result = await getJobStatus(jobId, userId); + + expect(result).toBeNull(); + }); + + it('returns null when job belongs to a different user', async () => { + mockExecute.mockResolvedValueOnce([ + { + id: jobId, + user_id: 'other-user', + status: 'pending', + total_items: 1, + completed_items: 0, + failed_items: 0, + created_at: new Date(), + updated_at: new Date(), + }, + ]); + + const result = await getJobStatus(jobId, userId); + + expect(result).toBeNull(); + }); + + it('returns job status with results when owned by the user', async () => { + const jobRow = { + id: jobId, + user_id: userId, + status: 'processing', + total_items: 2, + completed_items: 1, + failed_items: 0, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + const resultRows = [ + { + id: 1, + job_id: jobId, + item_index: 0, + status: 'completed', + context: {}, + extraction: { labelName: { value: 'Sub Pop', confidence: 0.9 } }, + matched_album_id: 42, + error_message: null, + created_at: new Date(), + completed_at: new Date(), + }, + { + id: 2, + job_id: jobId, + item_index: 1, + status: 'processing', + context: {}, + extraction: null, + matched_album_id: null, + error_message: null, + created_at: new Date(), + completed_at: null, + }, + ]; + const albumRows = [ + { + id: 42, + artist_name: 'Nirvana', + album_title: 'Bleach', + code_letters: 'NI', + code_artist_number: 1, + code_number: 3, + genre_name: 'ROCK', + format_name: 'LP', + label: 'Sub Pop', + }, + ]; + mockExecute + .mockResolvedValueOnce([jobRow]) + .mockResolvedValueOnce(resultRows) + .mockResolvedValueOnce(albumRows); + + const result = await getJobStatus(jobId, userId); + + if (result === null) { + throw new Error('Expected non-null result'); + } + expect(result.jobId).toBe(jobId); + expect(result.status).toBe('processing'); + expect(result.totalItems).toBe(2); + expect(result.completedItems).toBe(1); + expect(result.failedItems).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].status).toBe('completed'); + expect(result.results[0].matchedAlbum).toEqual({ + id: 42, + artistName: 'Nirvana', + albumTitle: 'Bleach', + codeLetters: 'NI', + codeArtistNumber: 1, + codeNumber: 3, + genreName: 'ROCK', + formatName: 'LP', + label: 'Sub Pop', + }); + expect(result.results[1].status).toBe('processing'); + expect(result.results[1].matchedAlbum).toBeNull(); + }); + + it('returns null matchedAlbum when no results have matched albums', async () => { + const jobRow = { + id: jobId, + user_id: userId, + status: 'completed', + total_items: 1, + completed_items: 1, + failed_items: 0, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + const resultRows = [ + { + id: 1, + job_id: jobId, + item_index: 0, + status: 'completed', + context: {}, + extraction: { labelName: { value: 'Merge', confidence: 0.9 } }, + matched_album_id: null, + error_message: null, + created_at: new Date(), + completed_at: new Date(), + }, + ]; + // Only two DB calls: job + results (no album lookup needed) + mockExecute.mockResolvedValueOnce([jobRow]).mockResolvedValueOnce(resultRows); + + const result = await getJobStatus(jobId, userId); + + if (result === null) { + throw new Error('Expected non-null result'); + } + expect(result.results[0].matchedAlbum).toBeNull(); + }); + }); + + describe('processJobItems', () => { + const jobId = mockUUID; + const items = [ + { imageCount: 2, photoTypes: ['front_cover', 'center_label'], context: { artistName: 'Superchunk' } }, + { imageCount: 1, photoTypes: ['front_cover'], context: {} }, + ]; + const imageBuffers = [Buffer.from('img1'), Buffer.from('img2'), Buffer.from('img3')]; + + it('processes each item sequentially with correct image slices', async () => { + mockProcessImages.mockResolvedValue({ + extraction: { labelName: { value: 'Merge', confidence: 0.9 } }, + matchedAlbumId: 101, + }); + + await processJobItems(jobId, items, imageBuffers); + + expect(mockProcessImages).toHaveBeenCalledTimes(2); + // First item gets images 0-1 + expect(mockProcessImages).toHaveBeenNthCalledWith( + 1, + [imageBuffers[0], imageBuffers[1]], + ['front_cover', 'center_label'], + { + artistName: 'Superchunk', + } + ); + // Second item gets image 2 + expect(mockProcessImages).toHaveBeenNthCalledWith(2, [imageBuffers[2]], ['front_cover'], {}); + }); + + it('updates job status to processing at start', async () => { + mockProcessImages.mockResolvedValue({ + extraction: {}, + }); + + await processJobItems(jobId, items, imageBuffers); + + // First update should set job status to processing + expect(mockUpdate).toHaveBeenCalled(); + }); + + it('continues processing remaining items when one fails', async () => { + mockProcessImages.mockRejectedValueOnce(new Error('Gemini API failed')).mockResolvedValueOnce({ + extraction: { labelName: { value: 'Sub Pop', confidence: 0.9 } }, + matchedAlbumId: 42, + }); + + await processJobItems(jobId, items, imageBuffers); + + expect(mockProcessImages).toHaveBeenCalledTimes(2); + }); + + it('does not throw when all items fail', async () => { + mockProcessImages.mockRejectedValue(new Error('API down')); + + await expect(processJobItems(jobId, items, imageBuffers)).resolves.not.toThrow(); + + expect(mockProcessImages).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/unit/services/scanner/gemini.service.test.ts b/tests/unit/services/scanner/gemini.service.test.ts index 6d051023..c9ff4073 100644 --- a/tests/unit/services/scanner/gemini.service.test.ts +++ b/tests/unit/services/scanner/gemini.service.test.ts @@ -60,7 +60,7 @@ describe('gemini.service', () => { await extractFromImages(mockImages, mockPhotoTypes, mockContext); - expect(mockGetGenerativeModel).toHaveBeenCalledWith({ model: 'gemini-2.0-flash' }); + expect(mockGetGenerativeModel).toHaveBeenCalledWith({ model: 'gemini-3-flash-preview' }); }); it('sends images as base64 inline data', async () => { diff --git a/tests/unit/services/scanner/processor.test.ts b/tests/unit/services/scanner/processor.test.ts index 9523276f..225da854 100644 --- a/tests/unit/services/scanner/processor.test.ts +++ b/tests/unit/services/scanner/processor.test.ts @@ -77,9 +77,9 @@ describe('processor', () => { expect(result.matchedAlbumId).toBe(101); }); - it('uses label name from extraction when no artist in context', async () => { + it('uses extracted artist name when no artist in context', async () => { const mockExtraction: ScanExtraction = { - labelName: { value: 'Merge Records', confidence: 0.9 }, + artistName: { value: 'Superchunk', confidence: 0.9 }, }; mockExtractFromImages.mockResolvedValue(mockExtraction); @@ -88,10 +88,25 @@ describe('processor', () => { const context: ScanContext = { albumTitle: 'Foolish' }; const result = await processImages(mockImages, mockPhotoTypes, context); - expect(mockFuzzySearchLibrary).toHaveBeenCalledWith('Merge Records', 'Foolish', 1); + expect(mockFuzzySearchLibrary).toHaveBeenCalledWith('Superchunk', 'Foolish', 1); expect(result.matchedAlbumId).toBeUndefined(); }); + it('uses extracted album title when no title in context', async () => { + const mockExtraction: ScanExtraction = { + albumTitle: { value: 'Bleach', confidence: 0.85 }, + }; + + mockExtractFromImages.mockResolvedValue(mockExtraction); + mockFuzzySearchLibrary.mockResolvedValue([{ id: 55, artist_name: 'Nirvana', album_title: 'Bleach' }]); + + const context: ScanContext = { artistName: 'Nirvana' }; + const result = await processImages(mockImages, mockPhotoTypes, context); + + expect(mockFuzzySearchLibrary).toHaveBeenCalledWith('Nirvana', 'Bleach', 1); + expect(result.matchedAlbumId).toBe(55); + }); + it('returns undefined matchedAlbumId when no context for matching', async () => { const mockExtraction: ScanExtraction = {};