diff --git a/apps/backend/controllers/scanner.controller.ts b/apps/backend/controllers/scanner.controller.ts index 126c43c..953883c 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 154e099..89229af 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 0000000..6249138 --- /dev/null +++ b/apps/backend/services/scanner/batch.ts @@ -0,0 +1,242 @@ +/** + * 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 } from 'drizzle-orm'; +import { db, scan_jobs, scan_results } 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; +} + +/** + * Status of a single scan result within a batch job. + */ +export interface BatchResultStatus { + itemIndex: number; + status: string; + extraction: unknown; + matchedAlbumId: number | 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(); + + 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, + 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/jest.unit.config.ts b/jest.unit.config.ts index 5cd7809..ddb7cc1 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 0000000..b6a199e --- /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 0000000..c6ac38f --- /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 0426bcf..664158b 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 05edfa7..d38c3a0 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 7a9bfc5..6b03135 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 64a7e21..0abdf12 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 0000000..6f054f2 --- /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 0000000..51cffa2 --- /dev/null +++ b/tests/unit/services/scanner/batch.test.ts @@ -0,0 +1,303 @@ +/** + * 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', + }, +})); + +// 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 })), + 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, + }, + ]; + mockExecute.mockResolvedValueOnce([jobRow]).mockResolvedValueOnce(resultRows); + + 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[1].status).toBe('processing'); + }); + }); + + 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); + }); + }); +});