#!/usr/bin/env ts-node /** * Migration Script: Convert Old Media URLs to MediaReferences * * This script migrates existing seeded data from old stored route strings/URLs * to the new MediaReference format. It handles: * * - Driver avatars: /api/avatar/{id} -> system-default (deterministic variant) * - Team logos: /api/media/teams/{id}/logo -> generated * - League logos: /api/media/leagues/{id}/logo -> generated * - Other old formats -> none * * Usage: * # Test mode (dry run, no changes) * npm run migrate:media:test * * # Execute migration * npm run migrate:media:exec * * Environment: * - GRIDPILOT_API_PERSISTENCE=postgres|inmemory (default: postgres) * - DATABASE_URL (for postgres mode) */ import { DataSource } from 'typeorm'; import { Logger } from '@core/shared/application'; import { MediaReference } from '@core/domain/media/MediaReference'; // Import entities import { DriverOrmEntity } from '../adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; import { TeamOrmEntity } from '../adapters/racing/persistence/typeorm/entities/TeamOrmEntities'; import { LeagueOrmEntity } from '../adapters/racing/persistence/typeorm/entities/LeagueOrmEntity'; // Import in-memory repositories for testing import { InMemoryDriverRepository } from '../adapters/racing/persistence/inmemory/InMemoryDriverRepository'; import { InMemoryTeamRepository } from '../adapters/racing/persistence/inmemory/InMemoryTeamRepository'; import { InMemoryLeagueRepository } from '../adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; import { InMemoryMediaRepository } from '../adapters/racing/persistence/media/InMemoryMediaRepository'; interface MigrationResult { success: boolean; processed: { drivers: number; teams: number; leagues: number; }; updated: { drivers: number; teams: number; leagues: number; }; errors: string[]; } class MediaMigrationLogger implements Logger { info(message: string): void { console.log(`[INFO] ${message}`); } warn(message: string): void { console.warn(`[WARN] ${message}`); } error(message: string, trace?: string): void { console.error(`[ERROR] ${message}`, trace || ''); } debug(message: string): void { console.debug(`[DEBUG] ${message}`); } } export class MediaReferenceMigration { private logger: Logger; constructor(private readonly dryRun: boolean = true) { this.logger = new MediaMigrationLogger(); } /** * Parse old URL format and determine appropriate MediaReference */ private parseOldUrl(url: string | null | undefined): MediaReference | null { if (!url || typeof url !== 'string') { return null; } const trimmed = url.trim(); if (trimmed === '') { return null; } // Pattern: /api/avatar/{driverId} const avatarMatch = trimmed.match(/^\/api\/avatar\/([a-zA-Z0-9-]+)$/); if (avatarMatch) { const driverId = avatarMatch[1]; const variant = this.getDeterministicAvatarVariant(driverId); return MediaReference.systemDefault(variant); } // Pattern: /api/media/teams/{teamId}/logo const teamLogoMatch = trimmed.match(/^\/api\/media\/teams\/([a-zA-Z0-9-]+)\/logo$/); if (teamLogoMatch) { const teamId = teamLogoMatch[1]; return MediaReference.generated('team', teamId); } // Pattern: /api/media/leagues/{leagueId}/logo const leagueLogoMatch = trimmed.match(/^\/api\/media\/leagues\/([a-zA-Z0-9-]+)\/logo$/); if (leagueLogoMatch) { const leagueId = leagueLogoMatch[1]; return MediaReference.generated('league', leagueId); } // Pattern: /api/media/teams/{teamId}/logo (alternative format) const teamLogoAltMatch = trimmed.match(/^\/api\/teams\/([a-zA-Z0-9-]+)\/logo$/); if (teamLogoAltMatch) { const teamId = teamLogoAltMatch[1]; return MediaReference.generated('team', teamId); } // Pattern: Static file paths (old format) if (trimmed.includes('/images/avatars/')) { // Old static files - convert to system default if (trimmed.includes('male')) return MediaReference.systemDefault('male'); if (trimmed.includes('female')) return MediaReference.systemDefault('female'); if (trimmed.includes('neutral')) return MediaReference.systemDefault('neutral'); return MediaReference.systemDefault('avatar'); } // Pattern: Full URLs (external or old API) if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { // External URLs - can't migrate automatically, mark as none this.logger.warn(`External URL found: ${trimmed} - converting to none`); return MediaReference.createNone(); } // Unknown format - convert to none this.logger.warn(`Unknown URL format: ${trimmed} - converting to none`); return MediaReference.createNone(); } /** * Deterministic avatar variant selection based on driver ID * Uses hash % 3 to ensure consistency */ private getDeterministicAvatarVariant(driverId: string): 'male' | 'female' | 'neutral' { const hash = this.hashCode(driverId); const variantIndex = Math.abs(hash) % 3; switch (variantIndex) { case 0: return 'male'; case 1: return 'female'; case 2: return 'neutral'; default: return 'neutral'; } } /** * Simple hash function for deterministic selection */ private hashCode(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash; } /** * Migrate PostgreSQL database */ async migratePostgres(): Promise { const result: MigrationResult = { success: false, processed: { drivers: 0, teams: 0, leagues: 0 }, updated: { drivers: 0, teams: 0, leagues: 0 }, errors: [] }; try { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { throw new Error('DATABASE_URL environment variable is required for postgres migration'); } this.logger.info('Connecting to PostgreSQL database...'); const dataSource = new DataSource({ type: 'postgres', url: databaseUrl, entities: [DriverOrmEntity, TeamOrmEntity, LeagueOrmEntity], synchronize: false, // Don't auto-create tables logging: false }); await dataSource.initialize(); this.logger.info('Database connection established'); // Migrate Drivers const driverRepo = dataSource.getRepository(DriverOrmEntity); const drivers = await driverRepo.find(); result.processed.drivers = drivers.length; this.logger.info(`Found ${drivers.length} drivers to migrate`); for (const driver of drivers) { try { // Check if avatarRef already exists and is valid if (driver.avatarRef && typeof driver.avatarRef === 'object') { try { const existing = MediaReference.fromJSON(driver.avatarRef as any); // Valid reference, skip continue; } catch { // Invalid reference, proceed with migration } } // Get old URL from avatarRef if it's a string, or check if we need to parse let oldUrl: string | null = null; if (driver.avatarRef && typeof driver.avatarRef === 'string') { oldUrl = driver.avatarRef; } else if (driver.avatarRef && typeof driver.avatarRef === 'object') { // Try to extract URL from old object format const refAny = driver.avatarRef as any; oldUrl = refAny.url || refAny.avatarUrl || null; } if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { if (!this.dryRun) { await driverRepo.update(driver.id, { avatarRef: newRef.toJSON() as any }); result.updated.drivers++; } else { result.updated.drivers++; // Count as would-be update } } } } catch (error) { result.errors.push(`Driver ${driver.id}: ${error instanceof Error ? error.message : String(error)}`); } } // Migrate Teams const teamRepo = dataSource.getRepository(TeamOrmEntity); const teams = await teamRepo.find(); result.processed.teams = teams.length; this.logger.info(`Found ${teams.length} teams to migrate`); for (const team of teams) { try { // Check if logoRef already exists and is valid if (team.logoRef && typeof team.logoRef === 'object') { try { const existing = MediaReference.fromJSON(team.logoRef as any); // Valid reference, skip continue; } catch { // Invalid reference, proceed with migration } } let oldUrl: string | null = null; if (team.logoRef && typeof team.logoRef === 'string') { oldUrl = team.logoRef; } else if (team.logoRef && typeof team.logoRef === 'object') { const refAny = team.logoRef as any; oldUrl = refAny.url || refAny.logoUrl || null; } if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { if (!this.dryRun) { await teamRepo.update(team.id, { logoRef: newRef.toJSON() as any }); result.updated.teams++; } else { result.updated.teams++; } } } } catch (error) { result.errors.push(`Team ${team.id}: ${error instanceof Error ? error.message : String(error)}`); } } // Migrate Leagues const leagueRepo = dataSource.getRepository(LeagueOrmEntity); const leagues = await leagueRepo.find(); result.processed.leagues = leagues.length; this.logger.info(`Found ${leagues.length} leagues to migrate`); for (const league of leagues) { try { // Check if logoRef already exists and is valid if (league.logoRef && typeof league.logoRef === 'object') { try { const existing = MediaReference.fromJSON(league.logoRef as any); // Valid reference, skip continue; } catch { // Invalid reference, proceed with migration } } let oldUrl: string | null = null; if (league.logoRef && typeof league.logoRef === 'string') { oldUrl = league.logoRef; } else if (league.logoRef && typeof league.logoRef === 'object') { const refAny = league.logoRef as any; oldUrl = refAny.url || refAny.logoUrl || null; } if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { if (!this.dryRun) { await leagueRepo.update(league.id, { logoRef: newRef.toJSON() as any }); result.updated.leagues++; } else { result.updated.leagues++; } } } } catch (error) { result.errors.push(`League ${league.id}: ${error instanceof Error ? error.message : String(error)}`); } } await dataSource.destroy(); result.success = true; this.logger.info(`Migration completed: ${result.updated.drivers} drivers, ${result.updated.teams} teams, ${result.updated.leagues} leagues updated`); return result; } catch (error) { result.errors.push(error instanceof Error ? error.message : String(error)); this.logger.error('Migration failed', error instanceof Error ? error.stack : undefined); return result; } } /** * Migrate in-memory repositories (for testing) */ async migrateInMemory(): Promise { const result: MigrationResult = { success: false, processed: { drivers: 0, teams: 0, leagues: 0 }, updated: { drivers: 0, teams: 0, leagues: 0 }, errors: [] }; try { const logger = new MediaMigrationLogger(); // Create in-memory repositories const driverRepo = new InMemoryDriverRepository(logger); const teamRepo = new InMemoryTeamRepository(logger); const leagueRepo = new InMemoryLeagueRepository(logger); const mediaRepo = new InMemoryMediaRepository(logger); // Get all data const drivers = await driverRepo.findAll(); const teams = await teamRepo.findAll(); const leagues = await leagueRepo.findAll(); result.processed.drivers = drivers.length; result.processed.teams = teams.length; result.processed.leagues = leagues.length; this.logger.info(`In-memory mode: Found ${drivers.length} drivers, ${teams.length} teams, ${leagues.length} leagues`); // Migrate drivers for (const driver of drivers) { try { // Check if already has valid MediaReference if (driver.avatarRef && driver.avatarRef instanceof MediaReference) { continue; } // Get old URL from in-memory media repo const oldUrl = await mediaRepo.getDriverAvatar(driver.id); if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { // Update driver in repository const updated = driver.update({ avatarRef: newRef }); if (!this.dryRun) { await driverRepo.update(updated); result.updated.drivers++; } else { result.updated.drivers++; } } } } catch (error) { result.errors.push(`Driver ${driver.id}: ${error instanceof Error ? error.message : String(error)}`); } } // Migrate teams for (const team of teams) { try { if (team.logoRef && team.logoRef instanceof MediaReference) { continue; } const oldUrl = await mediaRepo.getTeamLogo(team.id); if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { const updated = team.update({ logoRef: newRef }); if (!this.dryRun) { await teamRepo.update(updated); result.updated.teams++; } else { result.updated.teams++; } } } } catch (error) { result.errors.push(`Team ${team.id}: ${error instanceof Error ? error.message : String(error)}`); } } // Migrate leagues for (const league of leagues) { try { if (league.logoRef && league.logoRef instanceof MediaReference) { continue; } const oldUrl = await mediaRepo.getLeagueLogo(league.id); if (oldUrl) { const newRef = this.parseOldUrl(oldUrl); if (newRef) { const updated = league.update({ logoRef: newRef }); if (!this.dryRun) { await leagueRepo.update(updated); result.updated.leagues++; } else { result.updated.leagues++; } } } } catch (error) { result.errors.push(`League ${league.id}: ${error instanceof Error ? error.message : String(error)}`); } } result.success = true; this.logger.info(`In-memory migration completed: ${result.updated.drivers} drivers, ${result.updated.teams} teams, ${result.updated.leagues} leagues updated`); return result; } catch (error) { result.errors.push(error instanceof Error ? error.message : String(error)); this.logger.error('In-memory migration failed', error instanceof Error ? error.stack : undefined); return result; } } /** * Run migration based on environment */ async run(): Promise { const persistence = process.env.GRIDPILOT_API_PERSISTENCE || (process.env.DATABASE_URL ? 'postgres' : 'inmemory'); this.logger.info(`Starting media reference migration in ${this.dryRun ? 'DRY RUN' : 'EXECUTE'} mode`); this.logger.info(`Persistence mode: ${persistence}`); if (this.dryRun) { this.logger.info('DRY RUN: No changes will be made to the database'); } if (persistence === 'postgres') { return this.migratePostgres(); } else { return this.migrateInMemory(); } } } /** * CLI entry point */ async function main() { const args = process.argv.slice(2); const dryRun = !args.includes('--execute') && !args.includes('-e'); if (args.includes('--help') || args.includes('-h')) { console.log(` Media Reference Migration Script Usage: ts-node scripts/migrate-media-refs.ts [options] Options: --execute, -e Execute the migration (default: dry run) --help, -h Show this help message Environment: GRIDPILOT_API_PERSISTENCE=postgres|inmemory (default: postgres if DATABASE_URL set) DATABASE_URL (required for postgres mode) Examples: # Dry run (test mode) ts-node scripts/migrate-media-refs.ts # Execute migration ts-node scripts/migrate-media-refs.ts --execute # With specific persistence GRIDPILOT_API_PERSISTENCE=inmemory ts-node scripts/migrate-media-refs.ts --execute `); process.exit(0); } const migration = new MediaMigration(dryRun); const result = await migration.run(); // Print summary console.log('\n=== Migration Summary ==='); console.log(`Mode: ${dryRun ? 'DRY RUN' : 'EXECUTED'}`); console.log(`Processed: ${result.processed.drivers} drivers, ${result.processed.teams} teams, ${result.processed.leagues} leagues`); console.log(`Updated: ${result.updated.drivers} drivers, ${result.updated.teams} teams, ${result.updated.leagues} leagues`); if (result.errors.length > 0) { console.log(`\nErrors (${result.errors.length}):`); result.errors.forEach(err => console.log(` - ${err}`)); } if (!result.success) { console.log('\n❌ Migration failed'); process.exit(1); } if (dryRun) { console.log('\n✅ Dry run completed successfully. Run with --execute to apply changes.'); } else { console.log('\n✅ Migration completed successfully.'); } process.exit(0); } // Run if called directly if (require.main === module) { main().catch(error => { console.error('Fatal error:', error); process.exit(1); }); }