From 555d68eef808c69a0aac5bab2b8d4f54261456a1 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 28 Feb 2026 13:00:45 -0800 Subject: [PATCH 1/6] Add batch scan processing for vinyl record metadata extraction Introduces POST /library/scan/batch and GET /library/scan/batch/:jobId endpoints that let DJs upload multiple vinyl record images in one request, get a job ID back immediately (202 Accepted), and poll for results as each item processes asynchronously through Gemini. Schema: scan_jobs and scan_results tables with status enums, cascading deletes, and JSONB columns for context/extraction data. Batch service: createBatchJob (insert + fire-and-forget background processing), getJobStatus (ownership-checked polling), processJobItems (sequential per-item processing with partial failure handling). Validation: max 10 items, max 5 images per item, max 50 total images, manifest image count must match uploaded files. --- .../backend/controllers/scanner.controller.ts | 123 +- apps/backend/routes/scanner.route.ts | 12 +- apps/backend/services/scanner/batch.ts | 242 ++ jest.unit.config.ts | 1 + .../src/migrations/0027_scan-jobs-tables.sql | 30 + .../src/migrations/meta/0027_snapshot.json | 2879 +++++++++++++++++ .../src/migrations/meta/0029_snapshot.json | 2 +- .../src/migrations/meta/_journal.json | 9 +- shared/database/src/schema.ts | 54 + tests/mocks/database.mock.ts | 11 +- .../controllers/scanner.controller.test.ts | 219 ++ tests/unit/services/scanner/batch.test.ts | 303 ++ 12 files changed, 3880 insertions(+), 5 deletions(-) create mode 100644 apps/backend/services/scanner/batch.ts create mode 100644 shared/database/src/migrations/0027_scan-jobs-tables.sql create mode 100644 shared/database/src/migrations/meta/0027_snapshot.json create mode 100644 tests/unit/controllers/scanner.controller.test.ts create mode 100644 tests/unit/services/scanner/batch.test.ts 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..62491388 --- /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 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..51cffa2c --- /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); + }); + }); +}); From 6e5bb6ae0c2bceadc48a711dba5059513d6ac829 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 3 Mar 2026 17:02:00 -0800 Subject: [PATCH 2/6] Upgrade Gemini model from 2.0-flash to 3.1-pro-preview The 2.0-flash free tier quota is exhausted and the model is outdated. Gemini 3.1 Pro has better handwriting recognition, which is needed for reading handwritten album reviews and catalog stickers on vinyl records. --- apps/backend/services/scanner/gemini.service.ts | 2 +- tests/unit/services/scanner/gemini.service.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/services/scanner/gemini.service.ts b/apps/backend/services/scanner/gemini.service.ts index 0ffcf655..2c827ae6 100644 --- a/apps/backend/services/scanner/gemini.service.ts +++ b/apps/backend/services/scanner/gemini.service.ts @@ -75,7 +75,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.1-pro-preview' }); console.log(`[Scanner] Extracting metadata from ${images.length} image(s)`); diff --git a/tests/unit/services/scanner/gemini.service.test.ts b/tests/unit/services/scanner/gemini.service.test.ts index 6d051023..0d2dfb94 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.1-pro-preview' }); }); it('sends images as base64 inline data', async () => { From 72d3ccdc54b3c76fce32f0c6231be7ad641252a0 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 3 Mar 2026 20:31:24 -0800 Subject: [PATCH 3/6] Switch Gemini model to gemini-3-flash-preview The gemini-3.1-pro-preview free tier quota is exhausted (limit: 0). Switch to gemini-3-flash-preview which has available free tier capacity. --- apps/backend/services/scanner/gemini.service.ts | 2 +- tests/unit/services/scanner/gemini.service.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/services/scanner/gemini.service.ts b/apps/backend/services/scanner/gemini.service.ts index 2c827ae6..8ac2c648 100644 --- a/apps/backend/services/scanner/gemini.service.ts +++ b/apps/backend/services/scanner/gemini.service.ts @@ -75,7 +75,7 @@ export async function extractFromImages( context: ScanContext ): Promise { const client = getGeminiClient(); - const model = client.getGenerativeModel({ model: 'gemini-3.1-pro-preview' }); + const model = client.getGenerativeModel({ model: 'gemini-3-flash-preview' }); console.log(`[Scanner] Extracting metadata from ${images.length} image(s)`); diff --git a/tests/unit/services/scanner/gemini.service.test.ts b/tests/unit/services/scanner/gemini.service.test.ts index 0d2dfb94..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-3.1-pro-preview' }); + expect(mockGetGenerativeModel).toHaveBeenCalledWith({ model: 'gemini-3-flash-preview' }); }); it('sends images as base64 inline data', async () => { From 2993e9fdfbf6ee99bcb5ba4631a0bb4710ac5def Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 3 Mar 2026 20:48:39 -0800 Subject: [PATCH 4/6] Add artist name and album title to Gemini extraction Gemini now extracts artist_name and album_title alongside label, catalog number, review text, and UPC. Updated the system prompt, response parsing, and TypeScript types. --- apps/backend/services/scanner/gemini.service.ts | 8 ++++++++ apps/backend/services/scanner/prompts.ts | 12 ++++++++---- apps/backend/services/scanner/types.ts | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/backend/services/scanner/gemini.service.ts b/apps/backend/services/scanner/gemini.service.ts index 8ac2c648..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 }; @@ -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/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; From e8e90520f49a25de0f208099172e37d136d53cb5 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 3 Mar 2026 21:01:10 -0800 Subject: [PATCH 5/6] Hydrate matched album details in batch results and fix catalog matching fallbacks Batch status responses now include full album details (artist, title, library code, genre, format, label) for matched items by joining library_artist_view, instead of only returning the album ID. Also fix catalog matching to use extracted artist name and album title as fallbacks instead of label name. --- apps/backend/services/scanner/batch.ts | 49 +++++++++++- apps/backend/services/scanner/processor.ts | 4 +- tests/unit/services/scanner/batch.test.ts | 78 ++++++++++++++++++- tests/unit/services/scanner/processor.test.ts | 21 ++++- 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/apps/backend/services/scanner/batch.ts b/apps/backend/services/scanner/batch.ts index 62491388..5cf1b33c 100644 --- a/apps/backend/services/scanner/batch.ts +++ b/apps/backend/services/scanner/batch.ts @@ -6,8 +6,8 @@ * Gemini extraction pipeline. */ -import { eq, asc, sql } from 'drizzle-orm'; -import { db, scan_jobs, scan_results } from '@wxyc/database'; +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'; @@ -29,6 +29,21 @@ export interface BatchJobCreated { 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. */ @@ -37,6 +52,7 @@ export interface BatchResultStatus { status: string; extraction: unknown; matchedAlbumId: number | null; + matchedAlbum: MatchedAlbumInfo | null; errorMessage: string | null; } @@ -136,6 +152,34 @@ export async function getJobStatus(jobId: string, userId: string): Promise 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, @@ -149,6 +193,7 @@ export async function getJobStatus(jobId: string, userId: string): 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/tests/unit/services/scanner/batch.test.ts b/tests/unit/services/scanner/batch.test.ts index 51cffa2c..f52cf609 100644 --- a/tests/unit/services/scanner/batch.test.ts +++ b/tests/unit/services/scanner/batch.test.ts @@ -40,6 +40,17 @@ jest.mock('@wxyc/database', () => ({ 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() @@ -72,6 +83,7 @@ jest.mock('../../../../apps/backend/services/scanner/processor', () => ({ 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 })), { @@ -222,7 +234,23 @@ describe('batch service', () => { completed_at: null, }, ]; - mockExecute.mockResolvedValueOnce([jobRow]).mockResolvedValueOnce(resultRows); + 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); @@ -236,7 +264,55 @@ describe('batch service', () => { 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(); }); }); 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 = {}; From 42d56fdb7544f0d500b71d9ef0357e089261e12b Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Wed, 4 Mar 2026 16:37:52 -0800 Subject: [PATCH 6/6] Mount better-auth in backend app for dev environment Mount the better-auth handler directly in the backend Express app so the iOS library-scanner only needs a single base URL for both auth and API calls. This simplifies the dev environment by eliminating the need for a separate auth service on Railway. Changes: - Import auth from @wxyc/authentication and toNodeHandler from better-auth/node - Mount auth handler at /auth/{*path} (Express 5 path-to-regexp syntax) - Add test helper endpoints (/auth/test/verification-token, /auth/test/expire-session) - Add createDefaultUser() and syncAdminRoles() async startup functions - Wrap server startup in async IIFE to run startup functions before listening --- apps/backend/app.ts | 255 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 248 insertions(+), 7 deletions(-) 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); +})();