diff --git a/scripts/manageAdmin.ts b/scripts/manageAdmin.ts new file mode 100644 index 0000000..2db9c5f --- /dev/null +++ b/scripts/manageAdmin.ts @@ -0,0 +1,207 @@ +/** + * Admin management script for Supabase + * + * Usage: + * npm run admin:add -- --user-id="facebook|123" --name="John" --email="john@example.com" + * npm run admin:add -- --user-id="facebook|123" --super # Add as super admin (all permissions) + * npm run admin:list # List all admins + * npm run admin:remove -- --user-id="facebook|123" # Remove admin + * + * Required .env variables: + * SUPABASE_DB_URL - Direct DB connection string + */ + +import postgres from 'postgres'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import * as dotenv from 'dotenv'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +dotenv.config(); + +const DATABASE_URL = process.env.SUPABASE_DB_URL; + +if (!DATABASE_URL) { + console.error('Missing SUPABASE_DB_URL environment variable.'); + process.exit(1); +} + +const sql = postgres(DATABASE_URL); + +// Parse command line args +const args = process.argv.slice(2); +const command = args[0]; // 'add', 'list', 'remove' + +function getArg(name: string): string | null { + const arg = args.find(a => a.startsWith(`--${name}=`)); + return arg ? arg.split('=')[1] : null; +} + +function hasFlag(name: string): boolean { + return args.includes(`--${name}`); +} + +async function addAdmin() { + const userId = getArg('user-id'); + const displayName = getArg('name') || null; + const email = getArg('email') || null; + const isSuper = hasFlag('super'); + + if (!userId) { + console.error('Missing required --user-id argument'); + console.error('Usage: npm run admin:add -- --user-id="facebook|123" --name="John" [--super]'); + process.exit(1); + } + + console.log(`\nAdding admin: ${userId}`); + if (isSuper) { + console.log(' Type: Super Admin (all permissions)'); + } + + try { + const result = await sql` + INSERT INTO admins ( + user_id, + display_name, + email, + can_review_levels, + can_manage_admins, + can_manage_official, + can_view_analytics, + is_active + ) VALUES ( + ${userId}, + ${displayName}, + ${email}, + true, + ${isSuper}, + ${isSuper}, + ${isSuper}, + true + ) + ON CONFLICT (user_id) DO UPDATE SET + display_name = COALESCE(EXCLUDED.display_name, admins.display_name), + email = COALESCE(EXCLUDED.email, admins.email), + can_review_levels = true, + can_manage_admins = ${isSuper} OR admins.can_manage_admins, + can_manage_official = ${isSuper} OR admins.can_manage_official, + can_view_analytics = ${isSuper} OR admins.can_view_analytics, + is_active = true + RETURNING * + `; + + console.log('\n✓ Admin added/updated successfully!'); + console.log('\nPermissions:'); + console.log(` can_review_levels: ${result[0].can_review_levels}`); + console.log(` can_manage_admins: ${result[0].can_manage_admins}`); + console.log(` can_manage_official: ${result[0].can_manage_official}`); + console.log(` can_view_analytics: ${result[0].can_view_analytics}`); + } catch (error: any) { + console.error('Failed to add admin:', error.message); + process.exit(1); + } +} + +async function listAdmins() { + console.log('\nCurrent Admins:\n'); + + const admins = await sql` + SELECT + user_id, + display_name, + email, + can_review_levels, + can_manage_admins, + can_manage_official, + can_view_analytics, + is_active, + expires_at, + created_at + FROM admins + ORDER BY created_at + `; + + if (admins.length === 0) { + console.log(' No admins found.'); + return; + } + + for (const admin of admins) { + const status = admin.is_active ? '✓ active' : '✗ inactive'; + const perms = [ + admin.can_review_levels ? 'review' : null, + admin.can_manage_admins ? 'manage_admins' : null, + admin.can_manage_official ? 'manage_official' : null, + admin.can_view_analytics ? 'analytics' : null, + ].filter(Boolean).join(', '); + + console.log(` ${admin.user_id}`); + console.log(` Name: ${admin.display_name || '(not set)'}`); + console.log(` Email: ${admin.email || '(not set)'}`); + console.log(` Status: ${status}`); + console.log(` Permissions: ${perms || 'none'}`); + if (admin.expires_at) { + console.log(` Expires: ${admin.expires_at}`); + } + console.log(''); + } + + console.log(`Total: ${admins.length} admin(s)`); +} + +async function removeAdmin() { + const userId = getArg('user-id'); + + if (!userId) { + console.error('Missing required --user-id argument'); + console.error('Usage: npm run admin:remove -- --user-id="facebook|123"'); + process.exit(1); + } + + console.log(`\nRemoving admin: ${userId}`); + + const result = await sql` + DELETE FROM admins WHERE user_id = ${userId} RETURNING user_id + `; + + if (result.length === 0) { + console.log(' Admin not found.'); + } else { + console.log('✓ Admin removed successfully!'); + } +} + +async function main() { + try { + switch (command) { + case 'add': + await addAdmin(); + break; + case 'list': + await listAdmins(); + break; + case 'remove': + await removeAdmin(); + break; + default: + console.log('Admin Management Script\n'); + console.log('Commands:'); + console.log(' npm run admin:add -- --user-id="id" [--name="Name"] [--email="email"] [--super]'); + console.log(' npm run admin:list'); + console.log(' npm run admin:remove -- --user-id="id"'); + break; + } + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('Error:', error.message); + sql.end(); + process.exit(1); +}); diff --git a/scripts/runMigration.ts b/scripts/runMigration.ts new file mode 100644 index 0000000..fe7c275 --- /dev/null +++ b/scripts/runMigration.ts @@ -0,0 +1,182 @@ +/** + * Migration runner for Supabase database + * + * Usage: + * npm run migrate # Run all pending migrations + * npm run migrate -- --file=001_cloud_levels.sql # Run specific migration + * npm run migrate -- --status # Show migration status + * + * Required .env variables: + * SUPABASE_DB_URL - Direct DB connection string (Settings → Database → URI) + */ + +import postgres from 'postgres'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import * as dotenv from 'dotenv'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +dotenv.config(); + +const DATABASE_URL = process.env.SUPABASE_DB_URL; + +if (!DATABASE_URL) { + console.error('Missing SUPABASE_DB_URL environment variable.'); + console.error('Get it from Supabase → Settings → Database → Connection string (URI)'); + console.error('Use the "Session pooler" connection string for IPv4 compatibility.'); + process.exit(1); +} + +const sql = postgres(DATABASE_URL); + +const MIGRATIONS_DIR = path.join(__dirname, '..', 'supabase', 'migrations'); + +/** + * Ensure migrations tracking table exists + */ +async function ensureMigrationsTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + executed_at TIMESTAMPTZ DEFAULT NOW() + ) + `; +} + +/** + * Get list of already executed migrations + */ +async function getExecutedMigrations(): Promise { + const result = await sql`SELECT name FROM _migrations ORDER BY id`; + return result.map(row => row.name); +} + +/** + * Get list of migration files + */ +function getMigrationFiles(): string[] { + if (!fs.existsSync(MIGRATIONS_DIR)) { + console.error(`Migrations directory not found: ${MIGRATIONS_DIR}`); + process.exit(1); + } + + return fs.readdirSync(MIGRATIONS_DIR) + .filter(f => f.endsWith('.sql')) + .sort(); +} + +/** + * Run a single migration file + */ +async function runMigration(filename: string): Promise { + const filepath = path.join(MIGRATIONS_DIR, filename); + + if (!fs.existsSync(filepath)) { + throw new Error(`Migration file not found: ${filepath}`); + } + + const content = fs.readFileSync(filepath, 'utf-8'); + + console.log(` Running: ${filename}...`); + + try { + // Execute the migration + await sql.unsafe(content); + + // Record the migration + await sql`INSERT INTO _migrations (name) VALUES (${filename})`; + + console.log(` ✓ ${filename} completed`); + } catch (error) { + console.error(` ✗ ${filename} failed:`, error.message); + throw error; + } +} + +/** + * Run all pending migrations + */ +async function runAllMigrations(): Promise { + await ensureMigrationsTable(); + + const executed = await getExecutedMigrations(); + const files = getMigrationFiles(); + const pending = files.filter(f => !executed.includes(f)); + + if (pending.length === 0) { + console.log('No pending migrations.'); + return; + } + + console.log(`\nRunning ${pending.length} migration(s):\n`); + + for (const file of pending) { + await runMigration(file); + } + + console.log('\n✓ All migrations completed successfully!'); +} + +/** + * Show migration status + */ +async function showStatus(): Promise { + await ensureMigrationsTable(); + + const executed = await getExecutedMigrations(); + const files = getMigrationFiles(); + + console.log('\nMigration Status:\n'); + console.log(' File Status'); + console.log(' -------------------------------- --------'); + + for (const file of files) { + const status = executed.includes(file) ? '✓ done' : '○ pending'; + console.log(` ${file.padEnd(34)} ${status}`); + } + + const pending = files.filter(f => !executed.includes(f)); + console.log(`\n Total: ${files.length} | Done: ${executed.length} | Pending: ${pending.length}\n`); +} + +// Parse command line args +const args = process.argv.slice(2); +const showStatusFlag = args.includes('--status'); +const fileArg = args.find(arg => arg.startsWith('--file=')); +const specificFile = fileArg ? fileArg.split('=')[1] : null; + +async function main() { + try { + if (showStatusFlag) { + await showStatus(); + } else if (specificFile) { + await ensureMigrationsTable(); + const executed = await getExecutedMigrations(); + + if (executed.includes(specificFile)) { + console.log(`Migration ${specificFile} has already been executed.`); + console.log('To re-run, manually delete it from _migrations table first.'); + } else { + console.log(`\nRunning specific migration:\n`); + await runMigration(specificFile); + console.log('\n✓ Migration completed!'); + } + } else { + await runAllMigrations(); + } + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('\nMigration failed:', error.message); + sql.end(); + process.exit(1); +}); diff --git a/scripts/seedLevels.ts b/scripts/seedLevels.ts new file mode 100644 index 0000000..fef1e1e --- /dev/null +++ b/scripts/seedLevels.ts @@ -0,0 +1,242 @@ +/** + * Seed script for populating official levels from JSON files + * + * Usage: + * npm run seed:levels # Seed all levels from directory.json + * npm run seed:levels -- --clean # Delete all official levels first + * npm run seed:levels -- --admin-id="facebook|123" # Specify admin user ID + * + * Required .env variables: + * SUPABASE_DB_URL - Direct DB connection string + * + * Note: Requires an admin user with can_manage_official permission. + * The script will use the first super admin found, or you can specify --admin-id. + */ + +import postgres from 'postgres'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import * as dotenv from 'dotenv'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +dotenv.config(); + +const DATABASE_URL = process.env.SUPABASE_DB_URL; + +if (!DATABASE_URL) { + console.error('Missing SUPABASE_DB_URL environment variable.'); + process.exit(1); +} + +const sql = postgres(DATABASE_URL); + +const LEVELS_DIR = path.join(__dirname, '..', 'public', 'levels'); +const DIRECTORY_FILE = path.join(LEVELS_DIR, 'directory.json'); + +interface DirectoryEntry { + id: string; + name: string; + description: string; + version: string; + levelPath: string; + difficulty: string; + estimatedTime: string; + missionBrief: string[]; + unlockRequirements: string[]; + tags: string[]; + defaultLocked: boolean; +} + +interface Directory { + version: string; + levels: DirectoryEntry[]; +} + +// Parse command line args +const args = process.argv.slice(2); +const cleanFirst = args.includes('--clean'); +const adminIdArg = args.find(a => a.startsWith('--admin-id=')); +const specifiedAdminId = adminIdArg ? adminIdArg.split('=')[1] : null; + +/** + * Get an admin's internal user ID (UUID) with manage_official permission + */ +async function getAdminInternalUserId(): Promise { + if (specifiedAdminId) { + // Verify the specified admin exists and has permission + const admin = await sql` + SELECT internal_user_id FROM admins + WHERE user_id = ${specifiedAdminId} + AND is_active = true + AND can_manage_official = true + AND (expires_at IS NULL OR expires_at > NOW()) + `; + if (admin.length === 0) { + throw new Error(`Admin ${specifiedAdminId} not found or lacks manage_official permission`); + } + if (!admin[0].internal_user_id) { + throw new Error(`Admin ${specifiedAdminId} has no internal user ID. Run migration 002 first.`); + } + return admin[0].internal_user_id; + } + + // Find any admin with manage_official permission + const admins = await sql` + SELECT internal_user_id FROM admins + WHERE is_active = true + AND can_manage_official = true + AND internal_user_id IS NOT NULL + AND (expires_at IS NULL OR expires_at > NOW()) + LIMIT 1 + `; + + if (admins.length === 0) { + throw new Error('No admin found with manage_official permission and internal user ID. Run admin:add first.'); + } + + return admins[0].internal_user_id; +} + +/** + * Clean existing official levels + */ +async function cleanOfficialLevels(): Promise { + console.log('\nDeleting existing official levels...'); + + const result = await sql` + DELETE FROM levels WHERE level_type = 'official' RETURNING id + `; + + console.log(` Deleted ${result.length} official level(s)`); +} + +/** + * Seed levels from directory.json + */ +async function seedLevels(): Promise { + // Read directory.json + if (!fs.existsSync(DIRECTORY_FILE)) { + throw new Error(`Directory file not found: ${DIRECTORY_FILE}`); + } + + const directory: Directory = JSON.parse(fs.readFileSync(DIRECTORY_FILE, 'utf-8')); + console.log(`\nFound ${directory.levels.length} levels in directory.json (v${directory.version})`); + + // Get admin's internal user ID (UUID) + const adminUserId = await getAdminInternalUserId(); + console.log(`Using admin internal ID: ${adminUserId}\n`); + + let inserted = 0; + let updated = 0; + let failed = 0; + + for (let i = 0; i < directory.levels.length; i++) { + const entry = directory.levels[i]; + const levelPath = path.join(LEVELS_DIR, entry.levelPath); + + process.stdout.write(` [${i + 1}/${directory.levels.length}] ${entry.name}... `); + + // Check if level config file exists + if (!fs.existsSync(levelPath)) { + console.log('✗ config file not found'); + failed++; + continue; + } + + try { + // Read level config + const config = JSON.parse(fs.readFileSync(levelPath, 'utf-8')); + + // Upsert the level + const result = await sql` + INSERT INTO levels ( + user_id, + slug, + name, + description, + difficulty, + estimated_time, + tags, + config, + mission_brief, + level_type, + sort_order, + unlock_requirements, + default_locked + ) VALUES ( + ${adminUserId}, + ${entry.id}, + ${entry.name}, + ${entry.description}, + ${entry.difficulty}, + ${entry.estimatedTime}, + ${entry.tags}, + ${JSON.stringify(config)}, + ${entry.missionBrief}, + 'official', + ${i}, + ${entry.unlockRequirements}, + ${entry.defaultLocked} + ) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + difficulty = EXCLUDED.difficulty, + estimated_time = EXCLUDED.estimated_time, + tags = EXCLUDED.tags, + config = EXCLUDED.config, + mission_brief = EXCLUDED.mission_brief, + sort_order = EXCLUDED.sort_order, + unlock_requirements = EXCLUDED.unlock_requirements, + default_locked = EXCLUDED.default_locked, + updated_at = NOW() + RETURNING (xmax = 0) as is_insert + `; + + if (result[0].is_insert) { + console.log('✓ inserted'); + inserted++; + } else { + console.log('✓ updated'); + updated++; + } + } catch (error: any) { + console.log(`✗ ${error.message}`); + failed++; + } + } + + console.log('\n----------------------------------------'); + console.log(`Inserted: ${inserted}`); + console.log(`Updated: ${updated}`); + console.log(`Failed: ${failed}`); + console.log(`Total: ${directory.levels.length}`); + + if (failed === 0) { + console.log('\n✓ All levels seeded successfully!'); + } else { + console.log('\n⚠ Some levels failed to seed'); + } +} + +async function main() { + try { + if (cleanFirst) { + await cleanOfficialLevels(); + } + await seedLevels(); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + console.error('\nSeeding failed:', error.message); + sql.end(); + process.exit(1); +}); diff --git a/src/components/game/LevelCard.svelte b/src/components/game/LevelCard.svelte index 81ebc77..5fcffeb 100644 --- a/src/components/game/LevelCard.svelte +++ b/src/components/game/LevelCard.svelte @@ -1,20 +1,18 @@
- -
@@ -41,42 +37,23 @@
{#if !isReady}
Loading levels...
- {:else if defaultLevels.size === 0} + {:else if levels.size === 0}

No Levels Found

-

No levels available. Please check your installation.

+

No levels available. Please check your connection.

{:else} - {#each DEFAULT_LEVEL_ORDER as levelId} - {@const entry = defaultLevels.get(levelId)} + {#each LEVEL_ORDER as levelId} + {@const entry = levels.get(levelId)} {#if entry} {/if} {/each} - - {#if customLevels.size > 0} -
-

Custom Levels

-
- - {#each Array.from(customLevels.entries()) as [levelId, entry]} - - {/each} - {/if} {/if}
- - - -
diff --git a/src/levels/level1.ts b/src/levels/level1.ts index dc57ba7..bcd2a86 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -18,7 +18,8 @@ import debugLog from '../core/debug'; import {PhysicsRecorder} from "../replay/recording/physicsRecorder"; import {getAnalytics} from "../analytics"; import {MissionBrief} from "../ui/hud/missionBrief"; -import {LevelRegistry, LevelDirectoryEntry} from "./storage/levelRegistry"; +import {LevelRegistry} from "./storage/levelRegistry"; +import type {CloudLevelEntry} from "../services/cloudLevelService"; import { InputControlManager } from "../ship/input/inputControlManager"; export class Level1 implements Level { @@ -166,7 +167,7 @@ export class Level1 implements Level { this._missionBriefShown = true; console.log('[Level1] showMissionBrief() called'); - let directoryEntry: LevelDirectoryEntry | null = null; + let directoryEntry: CloudLevelEntry | null = null; // Try to get directory entry if we have a level ID if (this._levelId) { @@ -182,12 +183,12 @@ export class Level1 implements Level { console.log('[Level1] Registry entry found:', !!registryEntry); if (registryEntry) { - directoryEntry = registryEntry.directoryEntry; - console.log('[Level1] Directory entry data:', { + directoryEntry = registryEntry; + console.log('[Level1] Level entry data:', { id: directoryEntry?.id, + slug: directoryEntry?.slug, name: directoryEntry?.name, description: directoryEntry?.description, - levelPath: directoryEntry?.levelPath, missionBriefCount: directoryEntry?.missionBrief?.length || 0, estimatedTime: directoryEntry?.estimatedTime, difficulty: directoryEntry?.difficulty @@ -199,11 +200,7 @@ export class Level1 implements Level { console.log(` ${i + 1}. ${item}`); }); } else { - console.warn('[Level1] ⚠️ No missionBrief found in directory entry!'); - } - - if (!directoryEntry?.levelPath) { - console.warn('[Level1] ⚠️ No levelPath found in directory entry!'); + console.warn('[Level1] ⚠️ No missionBrief found in level entry!'); } } else { console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId); diff --git a/src/levels/storage/levelRegistry.ts b/src/levels/storage/levelRegistry.ts index 4a1d029..20e4c11 100644 --- a/src/levels/storage/levelRegistry.ts +++ b/src/levels/storage/levelRegistry.ts @@ -1,52 +1,12 @@ -import {LevelConfig} from "../config/levelConfig"; +import { LevelConfig } from "../config/levelConfig"; +import { CloudLevelService, CloudLevelEntry } from "../../services/cloudLevelService"; /** - * Level directory entry from directory.json manifest - */ -export interface LevelDirectoryEntry { - id: string; - name: string; - description: string; - version: string; - levelPath: string; - missionBrief?: string[]; - estimatedTime?: string; - difficulty?: string; - unlockRequirements?: string[]; - tags?: string[]; - defaultLocked?: boolean; -} - -/** - * Directory manifest structure - */ -export interface LevelDirectory { - version: string; - levels: LevelDirectoryEntry[]; -} - -/** - * Registry entry combining directory info with loaded config - */ -export interface LevelRegistryEntry { - directoryEntry: LevelDirectoryEntry; - config: LevelConfig | null; // null if not yet loaded - isDefault: boolean; - loadedAt?: Date; -} - -const CUSTOM_LEVELS_KEY = 'space-game-custom-levels'; - -/** - * Singleton registry for managing both default and custom levels - * Always fetches fresh from network - no caching + * Singleton registry for managing levels from cloud (Supabase) */ export class LevelRegistry { private static instance: LevelRegistry | null = null; - - private defaultLevels: Map = new Map(); - private customLevels: Map = new Map(); - private directoryManifest: LevelDirectory | null = null; + private levels: Map = new Map(); private initialized: boolean = false; private constructor() {} @@ -59,328 +19,46 @@ export class LevelRegistry { } /** - * Initialize the registry by loading directory and levels + * Initialize the registry by loading levels from cloud */ public async initialize(): Promise { - if (this.initialized) { - return; + if (this.initialized) return; + + const cloudService = CloudLevelService.getInstance(); + if (!cloudService.isAvailable()) { + throw new Error('Cloud service not available - cannot load levels'); } - try { - await this.loadDirectory(); - this.loadCustomLevels(); - this.initialized = true; - console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels'); - } catch (error) { - console.error('[LevelRegistry] Failed to initialize:', error); - throw error; + const entries = await cloudService.getOfficialLevels(); + for (const entry of entries) { + const key = entry.slug || entry.id; + this.levels.set(key, entry); } + + this.initialized = true; + console.log('[LevelRegistry] Loaded', this.levels.size, 'levels from cloud:', + Array.from(this.levels.keys())); } /** - * Check if running in development mode (for cache-busting HTTP requests) + * Get a level config by ID/slug */ - private isDevMode(): boolean { - return window.location.hostname === 'localhost' || - window.location.hostname.includes('dev.') || - window.location.port !== ''; + public getLevel(levelId: string): LevelConfig | null { + return this.levels.get(levelId)?.config || null; } /** - * Load the directory.json manifest (always fresh from network) + * Get full level entry by ID/slug */ - private async loadDirectory(): Promise { - try { - // Add cache-busting in dev mode to avoid browser HTTP cache - const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : ''; - const response = await fetch(`/levels/directory.json${cacheBuster}`); - - if (!response.ok) { - throw new Error(`Failed to fetch directory: ${response.status}`); - } - - this.directoryManifest = await response.json(); - console.log('[LevelRegistry] Loaded directory with', this.directoryManifest?.levels?.length || 0, 'levels'); - - this.populateDefaultLevelEntries(); - } catch (error) { - console.error('[LevelRegistry] Failed to load directory:', error); - throw new Error('Unable to load level directory. Please check your connection.'); - } + public getLevelEntry(levelId: string): CloudLevelEntry | null { + return this.levels.get(levelId) || null; } /** - * Populate default level registry entries from directory + * Get all levels */ - private populateDefaultLevelEntries(): void { - if (!this.directoryManifest) { - return; - } - - this.defaultLevels.clear(); - - for (const entry of this.directoryManifest.levels) { - this.defaultLevels.set(entry.id, { - directoryEntry: entry, - config: null, // Lazy load - isDefault: true - }); - } - - console.log('[LevelRegistry] Level IDs:', Array.from(this.defaultLevels.keys())); - } - - /** - * Load custom levels from localStorage - */ - private loadCustomLevels(): void { - this.customLevels.clear(); - - const stored = localStorage.getItem(CUSTOM_LEVELS_KEY); - if (!stored) { - return; - } - - try { - const levelsArray: [string, LevelConfig][] = JSON.parse(stored); - - for (const [id, config] of levelsArray) { - this.customLevels.set(id, { - directoryEntry: { - id, - name: config.metadata?.description || id, - description: config.metadata?.description || '', - version: config.version || '1.0', - levelPath: '', - difficulty: config.difficulty, - missionBrief: [], - defaultLocked: false - }, - config, - isDefault: false, - loadedAt: new Date() - }); - } - } catch (error) { - console.error('Failed to load custom levels from localStorage:', error); - } - } - - /** - * Get a level config by ID (loads if not yet loaded) - */ - public async getLevel(levelId: string): Promise { - // Check default levels first - const defaultEntry = this.defaultLevels.get(levelId); - if (defaultEntry) { - if (!defaultEntry.config) { - await this.loadDefaultLevel(levelId); - } - return defaultEntry.config; - } - - // Check custom levels - const customEntry = this.customLevels.get(levelId); - return customEntry?.config || null; - } - - /** - * Load a default level's config from JSON (always fresh from network) - */ - private async loadDefaultLevel(levelId: string): Promise { - const entry = this.defaultLevels.get(levelId); - if (!entry || entry.config) { - return; - } - - const levelPath = `/levels/${entry.directoryEntry.levelPath}`; - - try { - // Add cache-busting in dev mode - const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : ''; - const response = await fetch(`${levelPath}${cacheBuster}`); - - if (!response.ok) { - throw new Error(`Failed to fetch level: ${response.status}`); - } - - entry.config = await response.json(); - entry.loadedAt = new Date(); - console.log('[LevelRegistry] Loaded level:', levelId); - } catch (error) { - console.error(`[LevelRegistry] Failed to load level ${levelId}:`, error); - throw error; - } - } - - /** - * Get all level registry entries (default + custom) - */ - public getAllLevels(): Map { - const all = new Map(); - - for (const [id, entry] of this.defaultLevels) { - all.set(id, entry); - } - - for (const [id, entry] of this.customLevels) { - all.set(id, entry); - } - - return all; - } - - /** - * Get only default levels - */ - public getDefaultLevels(): Map { - return new Map(this.defaultLevels); - } - - /** - * Get only custom levels - */ - public getCustomLevels(): Map { - return new Map(this.customLevels); - } - - /** - * Save a custom level - */ - public saveCustomLevel(levelId: string, config: LevelConfig): void { - if (!config.metadata) { - config.metadata = { - author: 'Player', - description: levelId - }; - } - - if (config.metadata.type === 'default') { - delete config.metadata.type; - } - - this.customLevels.set(levelId, { - directoryEntry: { - id: levelId, - name: config.metadata.description || levelId, - description: config.metadata.description || '', - version: config.version || '1.0', - levelPath: '', - difficulty: config.difficulty, - missionBrief: [], - defaultLocked: false - }, - config, - isDefault: false, - loadedAt: new Date() - }); - - this.saveCustomLevelsToStorage(); - } - - /** - * Delete a custom level - */ - public deleteCustomLevel(levelId: string): boolean { - const deleted = this.customLevels.delete(levelId); - if (deleted) { - this.saveCustomLevelsToStorage(); - } - return deleted; - } - - /** - * Copy a default level to custom levels with a new ID - */ - public async copyDefaultToCustom(defaultLevelId: string, newCustomId: string): Promise { - const config = await this.getLevel(defaultLevelId); - if (!config) { - return false; - } - - const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config)); - - clonedConfig.metadata = { - ...clonedConfig.metadata, - type: undefined, - author: 'Player', - description: `Copy of ${defaultLevelId}`, - originalDefault: defaultLevelId - }; - - this.saveCustomLevel(newCustomId, clonedConfig); - return true; - } - - /** - * Persist custom levels to localStorage - */ - private saveCustomLevelsToStorage(): void { - const levelsArray: [string, LevelConfig][] = []; - - for (const [id, entry] of this.customLevels) { - if (entry.config) { - levelsArray.push([id, entry.config]); - } - } - - localStorage.setItem(CUSTOM_LEVELS_KEY, JSON.stringify(levelsArray)); - } - - /** - * Force refresh all default levels from network - */ - public async refreshDefaultLevels(): Promise { - // Clear in-memory configs - for (const entry of this.defaultLevels.values()) { - entry.config = null; - entry.loadedAt = undefined; - } - - // Reload directory - await this.loadDirectory(); - } - - /** - * Export custom levels as JSON for backup/sharing - */ - public exportCustomLevels(): string { - const levelsArray: [string, LevelConfig][] = []; - - for (const [id, entry] of this.customLevels) { - if (entry.config) { - levelsArray.push([id, entry.config]); - } - } - - return JSON.stringify(levelsArray, null, 2); - } - - /** - * Import custom levels from JSON - */ - public importCustomLevels(jsonString: string): number { - try { - const levelsArray: [string, LevelConfig][] = JSON.parse(jsonString); - let importCount = 0; - - for (const [id, config] of levelsArray) { - this.saveCustomLevel(id, config); - importCount++; - } - - return importCount; - } catch (error) { - console.error('Failed to import custom levels:', error); - throw new Error('Invalid custom levels JSON format'); - } - } - - /** - * Get directory manifest - */ - public getDirectory(): LevelDirectory | null { - return this.directoryManifest; + public getAllLevels(): Map { + return new Map(this.levels); } /** @@ -391,17 +69,11 @@ export class LevelRegistry { } /** - * Reset registry state (for testing or force reload) + * Reset registry state */ public reset(): void { - for (const entry of this.defaultLevels.values()) { - entry.config = null; - entry.loadedAt = undefined; - } - + this.levels.clear(); this.initialized = false; - this.directoryManifest = null; - console.log('[LevelRegistry] Reset complete. Call initialize() to reload.'); } } diff --git a/src/services/cloudLevelService.ts b/src/services/cloudLevelService.ts new file mode 100644 index 0000000..00416ec --- /dev/null +++ b/src/services/cloudLevelService.ts @@ -0,0 +1,685 @@ +import { SupabaseService } from './supabaseService'; +import { AuthService } from './authService'; +import type { LevelConfig } from '../levels/config/levelConfig'; + +/** + * Level entry from the cloud database + */ +export interface CloudLevelEntry { + id: string; + slug: string | null; + userId: string; + name: string; + description: string | null; + difficulty: string; + estimatedTime: string | null; + tags: string[]; + config: LevelConfig; + missionBrief: string[]; + levelType: 'official' | 'private' | 'pending_review' | 'published' | 'rejected'; + sortOrder: number; + unlockRequirements: string[]; + defaultLocked: boolean; + playCount: number; + completionCount: number; + avgRating: number; + ratingCount: number; + createdAt: string; + updatedAt: string; + reviewNotes?: string; +} + +/** + * Database row format (snake_case) + */ +interface LevelRow { + id: string; + slug: string | null; + user_id: string; + name: string; + description: string | null; + difficulty: string; + estimated_time: string | null; + tags: string[]; + config: LevelConfig | string; // May come as string from some DB configurations + mission_brief: string[]; + level_type: string; + sort_order: number; + unlock_requirements: string[]; + default_locked: boolean; + play_count: number; + completion_count: number; + avg_rating: number; + rating_count: number; + created_at: string; + updated_at: string; + review_notes?: string; +} + +/** + * Convert database row to CloudLevelEntry + */ +function rowToEntry(row: LevelRow): CloudLevelEntry { + // Handle config - it might come as string from some Supabase configurations + let config: LevelConfig = row.config as LevelConfig; + if (typeof row.config === 'string') { + try { + config = JSON.parse(row.config); + } catch (e) { + console.error('[CloudLevelService] Failed to parse config string:', e); + } + } + + return { + id: row.id, + slug: row.slug, + userId: row.user_id, + name: row.name, + description: row.description, + difficulty: row.difficulty, + estimatedTime: row.estimated_time, + tags: row.tags || [], + config: config, + missionBrief: row.mission_brief || [], + levelType: row.level_type as CloudLevelEntry['levelType'], + sortOrder: row.sort_order, + unlockRequirements: row.unlock_requirements || [], + defaultLocked: row.default_locked, + playCount: row.play_count, + completionCount: row.completion_count, + avgRating: Number(row.avg_rating) || 0, + ratingCount: row.rating_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + reviewNotes: row.review_notes, + }; +} + +/** + * Service for interacting with cloud-based level storage via Supabase + */ +export class CloudLevelService { + private static _instance: CloudLevelService; + + private constructor() {} + + /** + * Get the singleton instance + */ + public static getInstance(): CloudLevelService { + if (!CloudLevelService._instance) { + CloudLevelService._instance = new CloudLevelService(); + } + return CloudLevelService._instance; + } + + /** + * Check if cloud level storage is available + */ + public isAvailable(): boolean { + return SupabaseService.getInstance().isConfigured(); + } + + // ========================================= + // FETCHING LEVELS + // ========================================= + + /** + * Get all official levels (sorted by sort_order) + */ + public async getOfficialLevels(): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) { + console.warn('[CloudLevelService] Supabase not configured'); + return []; + } + + console.log('[CloudLevelService] Fetching official levels...'); + const { data, error } = await client + .from('levels') + .select('*') + .eq('level_type', 'official') + .order('sort_order', { ascending: true }); + + console.log('[CloudLevelService] Query result - data:', data?.length, 'rows, error:', error); + if (data) { + console.log('[CloudLevelService] Raw rows:', JSON.stringify(data, null, 2)); + } + + if (error) { + console.error('[CloudLevelService] Failed to fetch official levels:', error); + return []; + } + + return (data || []).map(rowToEntry); + } + + /** + * Get published community levels (paginated) + */ + public async getPublishedLevels(limit: number = 20, offset: number = 0): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) { + console.warn('[CloudLevelService] Supabase not configured'); + return []; + } + + const { data, error } = await client + .from('levels') + .select('*') + .eq('level_type', 'published') + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + console.error('[CloudLevelService] Failed to fetch published levels:', error); + return []; + } + + return (data || []).map(rowToEntry); + } + + /** + * Get current user's levels (requires auth) + */ + public async getMyLevels(): Promise { + const supabaseService = SupabaseService.getInstance(); + const client = await supabaseService.getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return []; + } + + // Get internal user ID (UUID) + const internalUserId = await supabaseService.ensureUserExists(); + if (!internalUserId) { + console.warn('[CloudLevelService] No internal user ID available'); + return []; + } + + const { data, error } = await client + .from('levels') + .select('*') + .eq('user_id', internalUserId) + .order('updated_at', { ascending: false }); + + if (error) { + console.error('[CloudLevelService] Failed to fetch user levels:', error); + return []; + } + + return (data || []).map(rowToEntry); + } + + /** + * Get a level by ID + */ + public async getLevelById(id: string): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) { + console.warn('[CloudLevelService] Supabase not configured'); + return null; + } + + const { data, error } = await client + .from('levels') + .select('*') + .eq('id', id) + .single(); + + if (error) { + if (error.code !== 'PGRST116') { // Not found is not an error + console.error('[CloudLevelService] Failed to fetch level:', error); + } + return null; + } + + return data ? rowToEntry(data) : null; + } + + /** + * Get a level by slug + */ + public async getLevelBySlug(slug: string): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) { + console.warn('[CloudLevelService] Supabase not configured'); + return null; + } + + const { data, error } = await client + .from('levels') + .select('*') + .eq('slug', slug) + .single(); + + if (error) { + if (error.code !== 'PGRST116') { + console.error('[CloudLevelService] Failed to fetch level by slug:', error); + } + return null; + } + + return data ? rowToEntry(data) : null; + } + + /** + * Check if a slug is available + */ + public async isSlugAvailable(slug: string, excludeLevelId?: string): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) { + return false; + } + + const { data, error } = await client + .rpc('is_slug_available', { + check_slug: slug, + exclude_level_id: excludeLevelId || null + }); + + if (error) { + console.error('[CloudLevelService] Failed to check slug availability:', error); + return false; + } + + return data === true; + } + + // ========================================= + // CRUD OPERATIONS (authenticated) + // ========================================= + + /** + * Create a new level (as private) + */ + public async createLevel( + name: string, + config: LevelConfig, + options?: { + slug?: string; + description?: string; + difficulty?: string; + tags?: string[]; + missionBrief?: string[]; + } + ): Promise { + const supabaseService = SupabaseService.getInstance(); + const client = await supabaseService.getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return null; + } + + // Get internal user ID (UUID) + const internalUserId = await supabaseService.ensureUserExists(); + if (!internalUserId) { + console.warn('[CloudLevelService] No internal user ID available'); + return null; + } + + const { data, error } = await client + .from('levels') + .insert({ + user_id: internalUserId, + name, + slug: options?.slug || null, + description: options?.description || null, + difficulty: options?.difficulty || config.difficulty || 'pilot', + tags: options?.tags || [], + config, + mission_brief: options?.missionBrief || [], + level_type: 'private', + }) + .select() + .single(); + + if (error) { + console.error('[CloudLevelService] Failed to create level:', error); + return null; + } + + return data ? rowToEntry(data) : null; + } + + /** + * Update an existing level + */ + public async updateLevel( + id: string, + updates: { + name?: string; + slug?: string; + description?: string; + difficulty?: string; + tags?: string[]; + config?: LevelConfig; + missionBrief?: string[]; + } + ): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return null; + } + + const updateData: Record = {}; + if (updates.name !== undefined) updateData.name = updates.name; + if (updates.slug !== undefined) updateData.slug = updates.slug; + if (updates.description !== undefined) updateData.description = updates.description; + if (updates.difficulty !== undefined) updateData.difficulty = updates.difficulty; + if (updates.tags !== undefined) updateData.tags = updates.tags; + if (updates.config !== undefined) updateData.config = updates.config; + if (updates.missionBrief !== undefined) updateData.mission_brief = updates.missionBrief; + + const { data, error } = await client + .from('levels') + .update(updateData) + .eq('id', id) + .select() + .single(); + + if (error) { + console.error('[CloudLevelService] Failed to update level:', error); + return null; + } + + return data ? rowToEntry(data) : null; + } + + /** + * Delete a level + */ + public async deleteLevel(id: string): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + const { error } = await client + .from('levels') + .delete() + .eq('id', id); + + if (error) { + console.error('[CloudLevelService] Failed to delete level:', error); + return false; + } + + return true; + } + + // ========================================= + // PUBLISHING + // ========================================= + + /** + * Submit a level for review + */ + public async submitForReview(id: string): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + const { error } = await client.rpc('submit_level_for_review', { + level_id: id + }); + + if (error) { + console.error('[CloudLevelService] Failed to submit for review:', error); + return false; + } + + return true; + } + + /** + * Withdraw a submission (move back to private) + */ + public async withdrawSubmission(id: string): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + const { error } = await client + .from('levels') + .update({ level_type: 'private', submitted_at: null }) + .eq('id', id) + .eq('level_type', 'pending_review'); + + if (error) { + console.error('[CloudLevelService] Failed to withdraw submission:', error); + return false; + } + + return true; + } + + // ========================================= + // ADMIN FUNCTIONS + // ========================================= + + /** + * Get levels pending review (admin only) + */ + public async getPendingReviews(): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return []; + } + + const { data, error } = await client + .from('levels') + .select('*') + .eq('level_type', 'pending_review') + .order('submitted_at', { ascending: true }); + + if (error) { + console.error('[CloudLevelService] Failed to fetch pending reviews:', error); + return []; + } + + return (data || []).map(rowToEntry); + } + + /** + * Approve a level (admin only) + */ + public async approveLevel(id: string, notes?: string): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + const { error } = await client.rpc('approve_level', { + p_level_id: id, + p_notes: notes || null + }); + + if (error) { + console.error('[CloudLevelService] Failed to approve level:', error); + return false; + } + + return true; + } + + /** + * Reject a level (admin only) + */ + public async rejectLevel(id: string, notes: string): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + const { error } = await client.rpc('reject_level', { + p_level_id: id, + p_notes: notes + }); + + if (error) { + console.error('[CloudLevelService] Failed to reject level:', error); + return false; + } + + return true; + } + + // ========================================= + // STATS + // ========================================= + + /** + * Increment play count for a level + */ + public async incrementPlayCount(id: string): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) return; + + const { error } = await client.rpc('increment_play_count', { + p_level_id: id + }); + + if (error) { + console.error('[CloudLevelService] Failed to increment play count:', error); + } + } + + /** + * Increment completion count for a level + */ + public async incrementCompletionCount(id: string): Promise { + const client = SupabaseService.getInstance().getClient(); + if (!client) return; + + const { error } = await client.rpc('increment_completion_count', { + p_level_id: id + }); + + if (error) { + console.error('[CloudLevelService] Failed to increment completion count:', error); + } + } + + /** + * Rate a level (1-5 stars) + */ + public async rateLevel(levelId: string, rating: number): Promise { + if (rating < 1 || rating > 5) { + console.error('[CloudLevelService] Rating must be between 1 and 5'); + return false; + } + + const supabaseService = SupabaseService.getInstance(); + const client = await supabaseService.getAuthenticatedClient(); + if (!client) { + console.warn('[CloudLevelService] Not authenticated'); + return false; + } + + // Get internal user ID (UUID) + const internalUserId = await supabaseService.ensureUserExists(); + if (!internalUserId) { + console.warn('[CloudLevelService] No internal user ID available'); + return false; + } + + const { error } = await client + .from('level_ratings') + .upsert({ + level_id: levelId, + user_id: internalUserId, + rating + }, { + onConflict: 'level_id,user_id' + }); + + if (error) { + console.error('[CloudLevelService] Failed to rate level:', error); + return false; + } + + // TODO: Update avg_rating on levels table via trigger or here + + return true; + } + + // ========================================= + // ADMIN STATUS CHECK + // ========================================= + + /** + * Check if current user is an admin + */ + public async isCurrentUserAdmin(): Promise { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + return false; + } + + const user = AuthService.getInstance().getUser(); + if (!user?.sub) { + return false; + } + + const { data, error } = await client + .from('admins') + .select('is_active') + .eq('user_id', user.sub) + .eq('is_active', true) + .single(); + + if (error || !data) { + return false; + } + + return true; + } + + /** + * Get current user's admin permissions + */ + public async getAdminPermissions(): Promise<{ + canReviewLevels: boolean; + canManageAdmins: boolean; + canManageOfficial: boolean; + canViewAnalytics: boolean; + } | null> { + const client = await SupabaseService.getInstance().getAuthenticatedClient(); + if (!client) { + return null; + } + + const user = AuthService.getInstance().getUser(); + if (!user?.sub) { + return null; + } + + const { data, error } = await client + .from('admins') + .select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics') + .eq('user_id', user.sub) + .eq('is_active', true) + .single(); + + if (error || !data) { + return null; + } + + return { + canReviewLevels: data.can_review_levels, + canManageAdmins: data.can_manage_admins, + canManageOfficial: data.can_manage_official, + canViewAnalytics: data.can_view_analytics, + }; + } +} diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts index 3369020..f3b4e3d 100644 --- a/src/services/supabaseService.ts +++ b/src/services/supabaseService.ts @@ -90,4 +90,59 @@ export class SupabaseService { return this._authenticatedClient; } + + /** + * Ensure user exists in internal users table, creating if needed + * Maps Auth0 sub to internal UUID + * Returns the internal user ID (UUID) + */ + public async ensureUserExists(): Promise { + const client = await this.getAuthenticatedClient(); + if (!client) { + return null; + } + + const authService = AuthService.getInstance(); + const user = authService.getUser(); + if (!user?.sub) { + console.warn('[SupabaseService] No user sub available'); + return null; + } + + // Try to get existing user + const { data: existingUser, error: fetchError } = await client + .from('users') + .select('id') + .eq('auth0_sub', user.sub) + .single(); + + if (existingUser) { + return existingUser.id; + } + + // User doesn't exist, create them + if (fetchError && fetchError.code === 'PGRST116') { + const { data: newUser, error: insertError } = await client + .from('users') + .insert({ + auth0_sub: user.sub, + display_name: user.name || user.nickname || 'Player' + }) + .select('id') + .single(); + + if (insertError) { + console.error('[SupabaseService] Failed to create user:', insertError); + return null; + } + + return newUser?.id || null; + } + + if (fetchError) { + console.error('[SupabaseService] Failed to fetch user:', fetchError); + } + + return null; + } } diff --git a/src/ship/ship.ts b/src/ship/ship.ts index 91c38f2..258c109 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -431,7 +431,7 @@ export class Ship { () => this.handleResume(), () => this.handleNextLevel() ); - this._statusScreen.initialize(this._camera); + this._statusScreen.initialize(); } /** diff --git a/src/stores/levelRegistry.ts b/src/stores/levelRegistry.ts index a6e0fd0..734012a 100644 --- a/src/stores/levelRegistry.ts +++ b/src/stores/levelRegistry.ts @@ -1,11 +1,11 @@ -import { writable, get } from 'svelte/store'; -import { LevelRegistry, type LevelDirectoryEntry } from '../levels/storage/levelRegistry'; +import { writable } from 'svelte/store'; +import { LevelRegistry } from '../levels/storage/levelRegistry'; import type { LevelConfig } from '../levels/config/levelConfig'; +import type { CloudLevelEntry } from '../services/cloudLevelService'; export interface LevelRegistryState { isInitialized: boolean; - defaultLevels: Map; - customLevels: Map; + levels: Map; } function createLevelRegistryStore() { @@ -13,46 +13,41 @@ function createLevelRegistryStore() { const initial: LevelRegistryState = { isInitialized: false, - defaultLevels: new Map(), - customLevels: new Map(), + levels: new Map(), }; const { subscribe, set, update } = writable(initial); // Initialize registry (async () => { - await registry.initialize(); - update(state => ({ - ...state, - isInitialized: true, - defaultLevels: registry.getDefaultLevels(), - customLevels: registry.getCustomLevels(), - })); + try { + await registry.initialize(); + update(state => ({ + ...state, + isInitialized: true, + levels: registry.getAllLevels(), + })); + } catch (error) { + console.error('[LevelRegistryStore] Failed to initialize:', error); + } })(); return { subscribe, - getLevel: async (levelId: string): Promise => { - return await registry.getLevel(levelId); + getLevel: (levelId: string): LevelConfig | null => { + return registry.getLevel(levelId); + }, + getLevelEntry: (levelId: string): CloudLevelEntry | null => { + return registry.getLevelEntry(levelId); }, refresh: async () => { + registry.reset(); await registry.initialize(); update(state => ({ ...state, - defaultLevels: registry.getDefaultLevels(), - customLevels: registry.getCustomLevels(), + levels: registry.getAllLevels(), })); }, - deleteCustomLevel: (levelId: string): boolean => { - const success = registry.deleteCustomLevel(levelId); - if (success) { - update(state => ({ - ...state, - customLevels: registry.getCustomLevels(), - })); - } - return success; - }, }; } diff --git a/src/ui/hud/missionBrief.ts b/src/ui/hud/missionBrief.ts index 0684cd1..a88e980 100644 --- a/src/ui/hud/missionBrief.ts +++ b/src/ui/hud/missionBrief.ts @@ -10,7 +10,7 @@ import { DefaultScene } from "../../core/defaultScene"; import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core"; import debugLog from '../../core/debug'; import { LevelConfig } from "../../levels/config/levelConfig"; -import { LevelDirectoryEntry } from "../../levels/storage/levelRegistry"; +import { CloudLevelEntry } from "../../services/cloudLevelService"; /** * Mission brief display for VR @@ -46,8 +46,8 @@ export class MissionBrief { mesh.parent = ship; mesh.position = new Vector3(0,1,2.8); -// mesh.rotation = new Vector3(0, Math.PI, 0); - //mesh.renderingGroupId = 3; // Same as status screen for consistent rendering + mesh.rotation = new Vector3(0, 0, 0); + mesh.renderingGroupId = 3; // Same as status screen for consistent rendering mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection console.log('[MissionBrief] Mesh parented to ship at position:', mesh.position); console.log('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition()); @@ -93,7 +93,7 @@ export class MissionBrief { * @param triggerObservable - Observable that fires when trigger is pulled * @param onStart - Callback when start button is pressed */ - public show(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null, triggerObservable: Observable, onStart: () => void): void { + public show(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null, triggerObservable: Observable, onStart: () => void): void { console.log('[MissionBrief] ========== SHOW() CALLED =========='); console.log('[MissionBrief] Container exists:', !!this._container); console.log('[MissionBrief] AdvancedTexture exists:', !!this._advancedTexture); @@ -261,7 +261,7 @@ export class MissionBrief { /** * Get mission description text based on level config and directory entry */ - private getMissionDescription(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null): string { + private getMissionDescription(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null): string { const difficulty = levelConfig.difficulty.toUpperCase(); const name = directoryEntry?.name || levelConfig.metadata?.description || "Mission"; const description = directoryEntry?.description || "Clear the asteroid field"; @@ -276,7 +276,7 @@ export class MissionBrief { /** * Get objectives text based on level config and directory entry */ - private getObjectives(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null): string { + private getObjectives(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null): string { const asteroidCount = levelConfig.asteroids?.length || 0; // Use mission brief from directory if available diff --git a/src/ui/hud/statusScreen.ts b/src/ui/hud/statusScreen.ts index 0f1c83b..6c18933 100644 --- a/src/ui/hud/statusScreen.ts +++ b/src/ui/hud/statusScreen.ts @@ -86,8 +86,8 @@ export class StatusScreen { /** * Initialize the status screen mesh and UI */ - public initialize(camera: Camera): void { - this._camera = camera; + public initialize(): void { + this._camera = DefaultScene.XR.baseExperience.camera; // Create a plane mesh for the status screen this._screenMesh = MeshBuilder.CreatePlane( diff --git a/supabase/migrations/001_cloud_levels.sql b/supabase/migrations/001_cloud_levels.sql new file mode 100644 index 0000000..193e607 --- /dev/null +++ b/supabase/migrations/001_cloud_levels.sql @@ -0,0 +1,403 @@ +-- =========================================== +-- CLOUD LEVELS MIGRATION +-- Creates tables for cloud-based level storage +-- =========================================== + +-- =========================================== +-- ADMINS TABLE +-- =========================================== +CREATE TABLE IF NOT EXISTS admins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT UNIQUE NOT NULL, -- Auth0 sub claim + + -- Admin info + display_name TEXT, -- For audit logs + email TEXT, -- For notifications + + -- Permissions (granular control) + can_review_levels BOOLEAN DEFAULT true, + can_manage_admins BOOLEAN DEFAULT false, -- Super admin only + can_manage_official BOOLEAN DEFAULT false, -- Create/edit official levels + can_view_analytics BOOLEAN DEFAULT false, + + -- Status + is_active BOOLEAN DEFAULT true, -- Disable without deleting + expires_at TIMESTAMPTZ, -- Optional expiration + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by TEXT, -- user_id of admin who added them + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_admins_user_id ON admins(user_id); +CREATE INDEX IF NOT EXISTS idx_admins_active ON admins(is_active) WHERE is_active = true; + +-- =========================================== +-- LEVELS TABLE +-- =========================================== +CREATE TABLE IF NOT EXISTS levels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Ownership + user_id TEXT NOT NULL, -- Auth0 sub claim (creator) + + -- Identity & Metadata + slug TEXT UNIQUE, -- URL-friendly identifier, user-chosen, must be unique + name TEXT NOT NULL, + description TEXT, + difficulty TEXT NOT NULL, + estimated_time TEXT, + tags TEXT[] DEFAULT '{}', + + -- Level Configuration (the actual level data) + config JSONB NOT NULL, -- Full LevelConfig object + + -- Mission Brief (displayed before level starts) + mission_brief TEXT[] DEFAULT '{}', -- Array of objective strings + + -- Type & Status + level_type TEXT NOT NULL DEFAULT 'private', + -- 'official', 'private', 'pending_review', 'published', 'rejected' + + -- Unlock/Progression (for official levels) + sort_order INTEGER DEFAULT 0, -- Display order for official levels + unlock_requirements TEXT[] DEFAULT '{}', -- Level IDs that must be completed first + default_locked BOOLEAN DEFAULT false, + + -- Review (for user submissions) + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + reviewed_by TEXT, -- Admin user_id who reviewed + review_notes TEXT, -- Admin feedback (especially for rejections) + + -- Stats (to be populated by triggers/functions) + play_count INTEGER DEFAULT 0, + completion_count INTEGER DEFAULT 0, + avg_rating DECIMAL(3,2) DEFAULT 0, + rating_count INTEGER DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + CONSTRAINT valid_level_type CHECK ( + level_type IN ('official', 'private', 'pending_review', 'published', 'rejected') + ), + CONSTRAINT valid_difficulty CHECK ( + difficulty IN ('recruit', 'pilot', 'captain', 'commander', 'test') + ) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_levels_user_id ON levels(user_id); +CREATE INDEX IF NOT EXISTS idx_levels_type ON levels(level_type); +CREATE INDEX IF NOT EXISTS idx_levels_slug ON levels(slug); +CREATE INDEX IF NOT EXISTS idx_levels_official_order ON levels(sort_order) WHERE level_type = 'official'; +CREATE INDEX IF NOT EXISTS idx_levels_published ON levels(created_at DESC) WHERE level_type = 'published'; +CREATE INDEX IF NOT EXISTS idx_levels_pending ON levels(submitted_at) WHERE level_type = 'pending_review'; + +-- =========================================== +-- LEVEL RATINGS TABLE (Future) +-- =========================================== +CREATE TABLE IF NOT EXISTS level_ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + level_id UUID NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(level_id, user_id) -- One rating per user per level +); + +CREATE INDEX IF NOT EXISTS idx_ratings_level ON level_ratings(level_id); + +-- =========================================== +-- HELPER FUNCTIONS +-- =========================================== + +-- Helper function to check if current user is an active admin +CREATE OR REPLACE FUNCTION is_admin() +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM admins + WHERE user_id = auth.uid()::text + AND is_active = true + AND (expires_at IS NULL OR expires_at > NOW()) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Helper function to check specific permission +CREATE OR REPLACE FUNCTION has_admin_permission(permission TEXT) +RETURNS BOOLEAN AS $$ +DECLARE + admin_record admins%ROWTYPE; +BEGIN + SELECT * INTO admin_record FROM admins + WHERE user_id = auth.uid()::text + AND is_active = true + AND (expires_at IS NULL OR expires_at > NOW()); + + IF admin_record IS NULL THEN + RETURN false; + END IF; + + CASE permission + WHEN 'review_levels' THEN RETURN admin_record.can_review_levels; + WHEN 'manage_admins' THEN RETURN admin_record.can_manage_admins; + WHEN 'manage_official' THEN RETURN admin_record.can_manage_official; + WHEN 'view_analytics' THEN RETURN admin_record.can_view_analytics; + ELSE RETURN false; + END CASE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- SLUG VALIDATION +-- =========================================== + +-- Function to validate slug format (lowercase, alphanumeric, hyphens only) +CREATE OR REPLACE FUNCTION validate_slug(slug TEXT) +RETURNS BOOLEAN AS $$ +BEGIN + -- Allow NULL slugs (optional) + IF slug IS NULL THEN + RETURN true; + END IF; + + -- Must be 3-50 chars, lowercase alphanumeric with hyphens, no leading/trailing hyphens + RETURN slug ~ '^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$'; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Function to check if slug is available (callable from client) +CREATE OR REPLACE FUNCTION is_slug_available(check_slug TEXT, exclude_level_id UUID DEFAULT NULL) +RETURNS BOOLEAN AS $$ +BEGIN + IF check_slug IS NULL THEN + RETURN true; + END IF; + + RETURN NOT EXISTS ( + SELECT 1 FROM levels + WHERE slug = check_slug + AND (exclude_level_id IS NULL OR id != exclude_level_id) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Add constraint for slug format +ALTER TABLE levels ADD CONSTRAINT valid_slug_format + CHECK (validate_slug(slug)); + +-- =========================================== +-- ROW LEVEL SECURITY - ADMINS +-- =========================================== +ALTER TABLE admins ENABLE ROW LEVEL SECURITY; + +-- Only super admins can view/manage other admins +CREATE POLICY admins_select ON admins + FOR SELECT USING (has_admin_permission('manage_admins')); + +CREATE POLICY admins_insert ON admins + FOR INSERT WITH CHECK (has_admin_permission('manage_admins')); + +CREATE POLICY admins_update ON admins + FOR UPDATE USING (has_admin_permission('manage_admins')); + +CREATE POLICY admins_delete ON admins + FOR DELETE USING (has_admin_permission('manage_admins')); + +-- Users can check if they themselves are admin (for UI purposes) +CREATE POLICY admins_select_self ON admins + FOR SELECT USING (user_id = auth.uid()::text); + +-- =========================================== +-- ROW LEVEL SECURITY - LEVELS +-- =========================================== +ALTER TABLE levels ENABLE ROW LEVEL SECURITY; + +-- Everyone can read official and published levels (no auth required) +CREATE POLICY levels_read_public ON levels + FOR SELECT + USING (level_type IN ('official', 'published')); + +-- Authenticated users can read their own levels (any status) +CREATE POLICY levels_read_own ON levels + FOR SELECT + USING (auth.uid()::text = user_id); + +-- Admins with review permission can read all levels (for review queue) +CREATE POLICY levels_read_admin ON levels + FOR SELECT + USING (has_admin_permission('review_levels')); + +-- Users can insert their own levels (as private initially) +CREATE POLICY levels_insert_own ON levels + FOR INSERT + WITH CHECK ( + auth.uid()::text = user_id + AND level_type = 'private' + ); + +-- Admins can insert official levels +CREATE POLICY levels_insert_official ON levels + FOR INSERT + WITH CHECK ( + has_admin_permission('manage_official') + AND level_type = 'official' + ); + +-- Users can update their own non-official levels +CREATE POLICY levels_update_own ON levels + FOR UPDATE + USING ( + auth.uid()::text = user_id + AND level_type != 'official' + ); + +-- Admins can update any level (for review actions and official level management) +CREATE POLICY levels_update_admin ON levels + FOR UPDATE + USING ( + has_admin_permission('review_levels') + OR (has_admin_permission('manage_official') AND level_type = 'official') + ); + +-- Users can delete their own private/rejected levels +CREATE POLICY levels_delete_own ON levels + FOR DELETE + USING ( + auth.uid()::text = user_id + AND level_type IN ('private', 'rejected') + ); + +-- Admins can delete official levels +CREATE POLICY levels_delete_official ON levels + FOR DELETE + USING ( + has_admin_permission('manage_official') + AND level_type = 'official' + ); + +-- =========================================== +-- ROW LEVEL SECURITY - RATINGS +-- =========================================== +ALTER TABLE level_ratings ENABLE ROW LEVEL SECURITY; + +-- Anyone can read ratings +CREATE POLICY ratings_read ON level_ratings + FOR SELECT USING (true); + +-- Authenticated users can insert their own ratings +CREATE POLICY ratings_insert ON level_ratings + FOR INSERT WITH CHECK (auth.uid()::text = user_id); + +-- Users can update their own ratings +CREATE POLICY ratings_update ON level_ratings + FOR UPDATE USING (auth.uid()::text = user_id); + +-- Users can delete their own ratings +CREATE POLICY ratings_delete ON level_ratings + FOR DELETE USING (auth.uid()::text = user_id); + +-- =========================================== +-- WORKFLOW FUNCTIONS +-- =========================================== + +-- Function to submit level for review +CREATE OR REPLACE FUNCTION submit_level_for_review(level_id UUID) +RETURNS void AS $$ +BEGIN + UPDATE levels + SET + level_type = 'pending_review', + submitted_at = NOW(), + updated_at = NOW() + WHERE id = level_id + AND user_id = auth.uid()::text + AND level_type = 'private'; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to approve level (admin only) +CREATE OR REPLACE FUNCTION approve_level(p_level_id UUID, p_notes TEXT DEFAULT NULL) +RETURNS void AS $$ +BEGIN + IF NOT has_admin_permission('review_levels') THEN + RAISE EXCEPTION 'Permission denied'; + END IF; + + UPDATE levels + SET + level_type = 'published', + reviewed_at = NOW(), + reviewed_by = auth.uid()::text, + review_notes = p_notes, + updated_at = NOW() + WHERE id = p_level_id + AND level_type = 'pending_review'; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to reject level (admin only) +CREATE OR REPLACE FUNCTION reject_level(p_level_id UUID, p_notes TEXT) +RETURNS void AS $$ +BEGIN + IF NOT has_admin_permission('review_levels') THEN + RAISE EXCEPTION 'Permission denied'; + END IF; + + UPDATE levels + SET + level_type = 'rejected', + reviewed_at = NOW(), + reviewed_by = auth.uid()::text, + review_notes = p_notes, + updated_at = NOW() + WHERE id = p_level_id + AND level_type = 'pending_review'; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS levels_updated_at ON levels; +CREATE TRIGGER levels_updated_at + BEFORE UPDATE ON levels + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- =========================================== +-- STATS FUNCTIONS (for future use) +-- =========================================== + +-- Function to increment play count +CREATE OR REPLACE FUNCTION increment_play_count(p_level_id UUID) +RETURNS void AS $$ +BEGIN + UPDATE levels + SET play_count = play_count + 1 + WHERE id = p_level_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment completion count +CREATE OR REPLACE FUNCTION increment_completion_count(p_level_id UUID) +RETURNS void AS $$ +BEGIN + UPDATE levels + SET completion_count = completion_count + 1 + WHERE id = p_level_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/supabase/migrations/002_user_mapping.sql b/supabase/migrations/002_user_mapping.sql new file mode 100644 index 0000000..6e7e4b0 --- /dev/null +++ b/supabase/migrations/002_user_mapping.sql @@ -0,0 +1,257 @@ +-- =========================================== +-- USER MAPPING MIGRATION +-- Creates users table to map Auth0 IDs to internal UUIDs +-- =========================================== + +-- =========================================== +-- DROP DEPENDENT OBJECTS FIRST +-- =========================================== + +-- Drop all level policies (from 001 migration) +DROP POLICY IF EXISTS "levels_read_public" ON levels; +DROP POLICY IF EXISTS "levels_read_own" ON levels; +DROP POLICY IF EXISTS "levels_read_admin" ON levels; +DROP POLICY IF EXISTS "levels_insert_own" ON levels; +DROP POLICY IF EXISTS "levels_insert_official" ON levels; +DROP POLICY IF EXISTS "levels_update_own" ON levels; +DROP POLICY IF EXISTS "levels_update_admin" ON levels; +DROP POLICY IF EXISTS "levels_delete_own" ON levels; +DROP POLICY IF EXISTS "levels_delete_official" ON levels; + +-- Also drop any policies with different naming (in case) +DROP POLICY IF EXISTS "levels_select_public" ON levels; +DROP POLICY IF EXISTS "levels_select_own" ON levels; +DROP POLICY IF EXISTS "levels_admin_all" ON levels; + +-- Drop level_ratings policies +DROP POLICY IF EXISTS "level_ratings_select" ON level_ratings; +DROP POLICY IF EXISTS "level_ratings_insert_own" ON level_ratings; +DROP POLICY IF EXISTS "level_ratings_update_own" ON level_ratings; +DROP POLICY IF EXISTS "level_ratings_delete_own" ON level_ratings; +DROP POLICY IF EXISTS "ratings_read" ON level_ratings; +DROP POLICY IF EXISTS "ratings_insert_own" ON level_ratings; +DROP POLICY IF EXISTS "ratings_update_own" ON level_ratings; +DROP POLICY IF EXISTS "ratings_delete_own" ON level_ratings; + +-- Drop indexes that depend on user_id +DROP INDEX IF EXISTS idx_levels_user_id; + +-- Drop unique constraint on level_ratings (level_id, user_id) +ALTER TABLE level_ratings DROP CONSTRAINT IF EXISTS level_ratings_level_id_user_id_key; + +-- =========================================== +-- CLEANUP EXISTING DATA +-- This is safe since we only have seeded test data +-- =========================================== + +-- Remove existing level data (will be re-seeded) +DELETE FROM level_ratings; +DELETE FROM levels; + +-- Store existing admin auth0 IDs for re-creation +CREATE TEMP TABLE temp_admins AS +SELECT user_id as auth0_id, display_name, email, + can_review_levels, can_manage_admins, can_manage_official, can_view_analytics, + is_active, expires_at, created_at, created_by, notes +FROM admins; + +-- Clear admins table +DELETE FROM admins; + +-- =========================================== +-- USERS TABLE +-- Maps external Auth0 IDs to internal UUIDs +-- =========================================== +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + auth0_id TEXT UNIQUE NOT NULL, -- Auth0 sub claim (e.g., "facebook|123") + display_name TEXT, + email TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_login_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_auth0_id ON users(auth0_id); + +-- =========================================== +-- FUNCTION: Get or create internal user ID +-- =========================================== +CREATE OR REPLACE FUNCTION get_or_create_user_id(p_auth0_id TEXT) +RETURNS UUID AS $$ +DECLARE + v_user_id UUID; +BEGIN + -- Try to find existing user + SELECT id INTO v_user_id FROM users WHERE auth0_id = p_auth0_id; + + -- Create if not found + IF v_user_id IS NULL THEN + INSERT INTO users (auth0_id) VALUES (p_auth0_id) + RETURNING id INTO v_user_id; + END IF; + + RETURN v_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- FUNCTION: Get current user's internal ID from JWT +-- =========================================== +CREATE OR REPLACE FUNCTION auth_user_id() RETURNS UUID AS $$ +BEGIN + RETURN ( + SELECT id FROM users + WHERE auth0_id = auth.jwt() ->> 'sub' + ); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +-- =========================================== +-- ALTER LEVELS TABLE +-- Change user_id from TEXT to UUID +-- =========================================== +-- Use CASCADE to drop all dependent objects (policies, indexes, etc.) +ALTER TABLE levels + DROP COLUMN user_id CASCADE; + +ALTER TABLE levels + ADD COLUMN user_id UUID REFERENCES users(id); + +-- Make user_id required (can't be NOT NULL until we have data) +-- Will add constraint after re-seeding + +-- Recreate index for user_id +CREATE INDEX idx_levels_user_id ON levels(user_id); + +-- =========================================== +-- ALTER LEVEL_RATINGS TABLE +-- Change user_id from TEXT to UUID +-- =========================================== +-- Use CASCADE to drop all dependent objects +ALTER TABLE level_ratings + DROP COLUMN user_id CASCADE; + +ALTER TABLE level_ratings + ADD COLUMN user_id UUID REFERENCES users(id); + +-- Recreate unique constraint +ALTER TABLE level_ratings + ADD CONSTRAINT level_ratings_level_id_user_id_key UNIQUE (level_id, user_id); + +-- =========================================== +-- ALTER ADMINS TABLE +-- Add internal user reference, keep auth0 ID for lookup +-- =========================================== +ALTER TABLE admins + ADD COLUMN internal_user_id UUID REFERENCES users(id); + +-- =========================================== +-- RESTORE ADMINS WITH USER MAPPING +-- =========================================== +-- First create user records for existing admins +INSERT INTO users (auth0_id, display_name, email) +SELECT auth0_id, display_name, email FROM temp_admins +ON CONFLICT (auth0_id) DO NOTHING; + +-- Restore admins with internal user ID reference +INSERT INTO admins (user_id, internal_user_id, display_name, email, + can_review_levels, can_manage_admins, can_manage_official, can_view_analytics, + is_active, expires_at, created_at, created_by, notes) +SELECT + ta.auth0_id, + u.id, + ta.display_name, + ta.email, + ta.can_review_levels, + ta.can_manage_admins, + ta.can_manage_official, + ta.can_view_analytics, + ta.is_active, + ta.expires_at, + ta.created_at, + ta.created_by, + ta.notes +FROM temp_admins ta +JOIN users u ON u.auth0_id = ta.auth0_id; + +DROP TABLE temp_admins; + +-- =========================================== +-- RECREATE RLS POLICIES USING auth_user_id() +-- =========================================== + +-- Levels: Anyone can read official and published levels +CREATE POLICY "levels_select_public" ON levels FOR SELECT + USING (level_type IN ('official', 'published')); + +-- Levels: Users can read their own levels +CREATE POLICY "levels_select_own" ON levels FOR SELECT + USING (user_id = auth_user_id()); + +-- Levels: Users can create levels (assigned to themselves) +CREATE POLICY "levels_insert_own" ON levels FOR INSERT + WITH CHECK (user_id = auth_user_id()); + +-- Levels: Users can update their own non-official levels +CREATE POLICY "levels_update_own" ON levels FOR UPDATE + USING (user_id = auth_user_id() AND level_type != 'official'); + +-- Levels: Users can delete their own non-official levels +CREATE POLICY "levels_delete_own" ON levels FOR DELETE + USING (user_id = auth_user_id() AND level_type != 'official'); + +-- Levels: Admins have full access +-- Note: is_admin() is defined in 001 migration and uses auth.uid() internally +-- We need to update is_admin to use auth.jwt() ->> 'sub' for Auth0 compatibility +CREATE POLICY "levels_admin_all" ON levels FOR ALL + USING (is_admin()); + +-- Level ratings: Anyone can read +CREATE POLICY "level_ratings_select" ON level_ratings FOR SELECT + USING (true); + +-- Level ratings: Users can insert their own +CREATE POLICY "level_ratings_insert_own" ON level_ratings FOR INSERT + WITH CHECK (user_id = auth_user_id()); + +-- Level ratings: Users can update their own +CREATE POLICY "level_ratings_update_own" ON level_ratings FOR UPDATE + USING (user_id = auth_user_id()); + +-- Level ratings: Users can delete their own +CREATE POLICY "level_ratings_delete_own" ON level_ratings FOR DELETE + USING (user_id = auth_user_id()); + +-- =========================================== +-- UPDATE HELPER FUNCTIONS +-- =========================================== + +-- Update submit_level_for_review to use UUID +CREATE OR REPLACE FUNCTION submit_level_for_review(level_id UUID) +RETURNS void AS $$ +BEGIN + UPDATE levels + SET level_type = 'pending_review', + submitted_at = NOW() + WHERE id = level_id + AND user_id = auth_user_id() + AND level_type IN ('private', 'rejected'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- USERS TABLE RLS +-- =========================================== +ALTER TABLE users ENABLE ROW LEVEL SECURITY; + +-- Anyone can read basic user info +CREATE POLICY "users_select" ON users FOR SELECT + USING (true); + +-- Users are created via get_or_create_user_id function (SECURITY DEFINER) +-- Direct inserts not allowed via API + +-- Users can update their own profile +CREATE POLICY "users_update_own" ON users FOR UPDATE + USING (auth0_id = auth.jwt() ->> 'sub'); diff --git a/supabase/migrations/003_leaderboard_user_mapping.sql b/supabase/migrations/003_leaderboard_user_mapping.sql new file mode 100644 index 0000000..0f3d31d --- /dev/null +++ b/supabase/migrations/003_leaderboard_user_mapping.sql @@ -0,0 +1,51 @@ +-- =========================================== +-- LEADERBOARD USER MAPPING MIGRATION +-- Migrates leaderboard from Auth0 IDs to internal UUIDs +-- Normalizes player_name to users table +-- =========================================== + +-- =========================================== +-- CREATE USER RECORDS FOR EXISTING LEADERBOARD ENTRIES +-- =========================================== + +-- Create user records for any Auth0 IDs in leaderboard that don't have users yet +-- Uses player_name from most recent entry as initial display_name +-- DISTINCT ON ensures only one row per user_id (the most recent by created_at) +INSERT INTO users (auth0_id, display_name) +SELECT DISTINCT ON (l.user_id) l.user_id, l.player_name +FROM leaderboard l +WHERE l.user_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.auth0_id = l.user_id) +ORDER BY l.user_id, l.created_at DESC +ON CONFLICT (auth0_id) DO NOTHING; + +-- =========================================== +-- ADD AND POPULATE INTERNAL USER ID +-- =========================================== + +-- Add internal_user_id column (will replace user_id) +ALTER TABLE leaderboard + ADD COLUMN internal_user_id UUID REFERENCES users(id); + +-- Populate internal_user_id from existing user_id (auth0_id) +UPDATE leaderboard l +SET internal_user_id = u.id +FROM users u +WHERE l.user_id = u.auth0_id; + +-- Create index for new column +CREATE INDEX idx_leaderboard_internal_user_id ON leaderboard(internal_user_id); + +-- =========================================== +-- DROP OLD COLUMNS AND RENAME +-- =========================================== + +-- Drop old columns (player_name normalized to users table, user_id replaced by UUID) +ALTER TABLE leaderboard DROP COLUMN player_name; +ALTER TABLE leaderboard DROP COLUMN user_id; + +-- Rename internal_user_id to user_id for consistency +ALTER TABLE leaderboard RENAME COLUMN internal_user_id TO user_id; + +-- Rename index to match new column name +ALTER INDEX idx_leaderboard_internal_user_id RENAME TO idx_leaderboard_user_id;