Migrate to cloud-only level system using Supabase
All checks were successful
Build / build (push) Successful in 1m44s

Remove all local level storage concepts and load levels exclusively from
Supabase cloud. Simplifies LevelRegistry from 380+ lines to ~50 lines.
Uses CloudLevelEntry directly throughout the codebase instead of wrapper
types like LevelDirectoryEntry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-28 17:26:24 -06:00
parent c9d7b0f3a5
commit b4baa2beba
16 changed files with 2171 additions and 465 deletions

207
scripts/manageAdmin.ts Normal file
View File

@ -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);
});

182
scripts/runMigration.ts Normal file
View File

@ -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<void> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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);
});

242
scripts/seedLevels.ts Normal file
View File

@ -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<string> {
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<void> {
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<void> {
// 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);
});

View File

@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import { navigate } from 'svelte-routing'; import { navigate } from 'svelte-routing';
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry'; import type { CloudLevelEntry } from '../../services/cloudLevelService';
import { levelRegistryStore } from '../../stores/levelRegistry';
import { authStore } from '../../stores/auth'; import { authStore } from '../../stores/auth';
import { progressionStore } from '../../stores/progression'; import { progressionStore } from '../../stores/progression';
import { gameConfigStore } from '../../stores/gameConfig'; import { gameConfigStore } from '../../stores/gameConfig';
import Button from '../shared/Button.svelte'; import Button from '../shared/Button.svelte';
export let levelId: string; export let levelId: string;
export let directoryEntry: LevelDirectoryEntry; export let levelEntry: CloudLevelEntry;
export let isDefault: boolean = true;
async function handleLevelClick() { async function handleLevelClick() {
console.log('[LevelCard] Level clicked:', { console.log('[LevelCard] Level clicked:', {
levelId, levelId,
levelName: directoryEntry.name, levelName: levelEntry.name,
isUnlocked, isUnlocked,
isAuthenticated: $authStore.isAuthenticated, isAuthenticated: $authStore.isAuthenticated,
buttonText buttonText
@ -43,15 +41,9 @@
navigate(`/play/${levelId}`); navigate(`/play/${levelId}`);
} }
async function handleDelete() { // Determine if level is unlocked
if (confirm(`Are you sure you want to delete "${levelId}"?`)) {
levelRegistryStore.deleteCustomLevel(levelId);
}
}
// Determine if level is unlocked - complex logic matching original implementation
$: { $: {
const isTutorial = progressionStore.isTutorial(directoryEntry.name); const isTutorial = progressionStore.isTutorial(levelEntry.name);
const isAuthenticated = $authStore.isAuthenticated; const isAuthenticated = $authStore.isAuthenticated;
const progressionEnabled = $gameConfigStore.progressionEnabled; const progressionEnabled = $gameConfigStore.progressionEnabled;
@ -65,11 +57,11 @@
isUnlocked = false; isUnlocked = false;
lockReason = 'Sign in to unlock'; lockReason = 'Sign in to unlock';
buttonText = 'Sign In Required'; buttonText = 'Sign In Required';
} else if (progressionEnabled && isDefault) { } else if (progressionEnabled) {
// Check sequential progression // Check sequential progression
isUnlocked = progressionStore.isLevelUnlocked(directoryEntry.name, isDefault); isUnlocked = progressionStore.isLevelUnlocked(levelEntry.name, true);
if (!isUnlocked) { if (!isUnlocked) {
const prevLevel = progressionStore.getPreviousLevelName(directoryEntry.name); const prevLevel = progressionStore.getPreviousLevelName(levelEntry.name);
lockReason = prevLevel ? `Complete "${prevLevel}" to unlock` : 'Locked'; lockReason = prevLevel ? `Complete "${prevLevel}" to unlock` : 'Locked';
buttonText = 'Locked'; buttonText = 'Locked';
} else { } else {
@ -77,7 +69,7 @@
buttonText = 'Play Level'; buttonText = 'Play Level';
} }
} else { } else {
// Custom levels or progression disabled - always unlocked when authenticated // Progression disabled - always unlocked when authenticated
isUnlocked = true; isUnlocked = true;
lockReason = ''; lockReason = '';
buttonText = 'Play Level'; buttonText = 'Play Level';
@ -93,23 +85,20 @@
<div class={cardClasses}> <div class={cardClasses}>
<div class="level-card-header"> <div class="level-card-header">
<h2 class="level-card-title">{directoryEntry.name}</h2> <h2 class="level-card-title">{levelEntry.name}</h2>
{#if !isUnlocked} {#if !isUnlocked}
<div class="level-card-status level-card-status-locked">🔒</div> <div class="level-card-status level-card-status-locked">🔒</div>
{/if} {/if}
{#if !isDefault}
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
{/if}
</div> </div>
<div class="level-meta"> <div class="level-meta">
Difficulty: {directoryEntry.difficulty || 'unknown'} Difficulty: {levelEntry.difficulty || 'unknown'}
{#if directoryEntry.estimatedTime} {#if levelEntry.estimatedTime}
{directoryEntry.estimatedTime} {levelEntry.estimatedTime}
{/if} {/if}
</div> </div>
<p class="level-card-description">{directoryEntry.description}</p> <p class="level-card-description">{levelEntry.description}</p>
{#if !isUnlocked && lockReason} {#if !isUnlocked && lockReason}
<div class="level-lock-reason">{lockReason}</div> <div class="level-lock-reason">{lockReason}</div>
@ -122,12 +111,6 @@
> >
{buttonText} {buttonText}
</Button> </Button>
{#if !isDefault && isUnlocked}
<Button variant="secondary" on:click={handleDelete} title="Delete level">
🗑️
</Button>
{/if}
</div> </div>
</div> </div>

View File

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { levelRegistryStore } from '../../stores/levelRegistry'; import { levelRegistryStore } from '../../stores/levelRegistry';
import { authStore } from '../../stores/auth';
import LevelCard from './LevelCard.svelte'; import LevelCard from './LevelCard.svelte';
import ProgressBar from './ProgressBar.svelte'; import ProgressBar from './ProgressBar.svelte';
// Get default levels in order (must match directory.json) // Get levels in order (by sortOrder from Supabase)
const DEFAULT_LEVEL_ORDER = [ const LEVEL_ORDER = [
'rookie-training', 'rookie-training',
'asteroid-mania', 'asteroid-mania',
'deep-space-patrol', 'deep-space-patrol',
@ -16,13 +15,10 @@
// Reactive declarations for store values // Reactive declarations for store values
$: isReady = $levelRegistryStore.isInitialized; $: isReady = $levelRegistryStore.isInitialized;
$: defaultLevels = $levelRegistryStore.defaultLevels; $: levels = $levelRegistryStore.levels;
$: customLevels = $levelRegistryStore.customLevels;
</script> </script>
<div id="mainDiv"> <div id="mainDiv">
<div id="levelSelect" class:ready={isReady}> <div id="levelSelect" class:ready={isReady}>
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero"> <div class="hero">
@ -41,42 +37,23 @@
<div class="card-container" id="levelCardsContainer"> <div class="card-container" id="levelCardsContainer">
{#if !isReady} {#if !isReady}
<div class="loading-message">Loading levels...</div> <div class="loading-message">Loading levels...</div>
{:else if defaultLevels.size === 0} {:else if levels.size === 0}
<div class="no-levels-message"> <div class="no-levels-message">
<h2>No Levels Found</h2> <h2>No Levels Found</h2>
<p>No levels available. Please check your installation.</p> <p>No levels available. Please check your connection.</p>
</div> </div>
{:else} {:else}
{#each DEFAULT_LEVEL_ORDER as levelId} {#each LEVEL_ORDER as levelId}
{@const entry = defaultLevels.get(levelId)} {@const entry = levels.get(levelId)}
{#if entry} {#if entry}
<LevelCard <LevelCard
{levelId} {levelId}
directoryEntry={entry.directoryEntry} levelEntry={entry}
isDefault={entry.isDefault}
/> />
{/if} {/if}
{/each} {/each}
{#if customLevels.size > 0}
<div style="grid-column: 1 / -1; margin-top: var(--space-2xl);">
<h3 class="level-header">Custom Levels</h3>
</div>
{#each Array.from(customLevels.entries()) as [levelId, entry]}
<LevelCard
{levelId}
directoryEntry={entry.directoryEntry}
isDefault={false}
/>
{/each}
{/if}
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -18,7 +18,8 @@ import debugLog from '../core/debug';
import {PhysicsRecorder} from "../replay/recording/physicsRecorder"; import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
import {getAnalytics} from "../analytics"; import {getAnalytics} from "../analytics";
import {MissionBrief} from "../ui/hud/missionBrief"; 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"; import { InputControlManager } from "../ship/input/inputControlManager";
export class Level1 implements Level { export class Level1 implements Level {
@ -166,7 +167,7 @@ export class Level1 implements Level {
this._missionBriefShown = true; this._missionBriefShown = true;
console.log('[Level1] showMissionBrief() called'); 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 // Try to get directory entry if we have a level ID
if (this._levelId) { if (this._levelId) {
@ -182,12 +183,12 @@ export class Level1 implements Level {
console.log('[Level1] Registry entry found:', !!registryEntry); console.log('[Level1] Registry entry found:', !!registryEntry);
if (registryEntry) { if (registryEntry) {
directoryEntry = registryEntry.directoryEntry; directoryEntry = registryEntry;
console.log('[Level1] Directory entry data:', { console.log('[Level1] Level entry data:', {
id: directoryEntry?.id, id: directoryEntry?.id,
slug: directoryEntry?.slug,
name: directoryEntry?.name, name: directoryEntry?.name,
description: directoryEntry?.description, description: directoryEntry?.description,
levelPath: directoryEntry?.levelPath,
missionBriefCount: directoryEntry?.missionBrief?.length || 0, missionBriefCount: directoryEntry?.missionBrief?.length || 0,
estimatedTime: directoryEntry?.estimatedTime, estimatedTime: directoryEntry?.estimatedTime,
difficulty: directoryEntry?.difficulty difficulty: directoryEntry?.difficulty
@ -199,11 +200,7 @@ export class Level1 implements Level {
console.log(` ${i + 1}. ${item}`); console.log(` ${i + 1}. ${item}`);
}); });
} else { } else {
console.warn('[Level1] ⚠️ No missionBrief found in directory entry!'); console.warn('[Level1] ⚠️ No missionBrief found in level entry!');
}
if (!directoryEntry?.levelPath) {
console.warn('[Level1] ⚠️ No levelPath found in directory entry!');
} }
} else { } else {
console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId); console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId);

View File

@ -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 * Singleton registry for managing levels from cloud (Supabase)
*/
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
*/ */
export class LevelRegistry { export class LevelRegistry {
private static instance: LevelRegistry | null = null; private static instance: LevelRegistry | null = null;
private levels: Map<string, CloudLevelEntry> = new Map();
private defaultLevels: Map<string, LevelRegistryEntry> = new Map();
private customLevels: Map<string, LevelRegistryEntry> = new Map();
private directoryManifest: LevelDirectory | null = null;
private initialized: boolean = false; private initialized: boolean = false;
private constructor() {} 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<void> { public async initialize(): Promise<void> {
if (this.initialized) { if (this.initialized) return;
return;
const cloudService = CloudLevelService.getInstance();
if (!cloudService.isAvailable()) {
throw new Error('Cloud service not available - cannot load levels');
}
const entries = await cloudService.getOfficialLevels();
for (const entry of entries) {
const key = entry.slug || entry.id;
this.levels.set(key, entry);
} }
try {
await this.loadDirectory();
this.loadCustomLevels();
this.initialized = true; this.initialized = true;
console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels'); console.log('[LevelRegistry] Loaded', this.levels.size, 'levels from cloud:',
} catch (error) { Array.from(this.levels.keys()));
console.error('[LevelRegistry] Failed to initialize:', error);
throw error;
}
} }
/** /**
* Check if running in development mode (for cache-busting HTTP requests) * Get a level config by ID/slug
*/ */
private isDevMode(): boolean { public getLevel(levelId: string): LevelConfig | null {
return window.location.hostname === 'localhost' || return this.levels.get(levelId)?.config || null;
window.location.hostname.includes('dev.') ||
window.location.port !== '';
} }
/** /**
* Load the directory.json manifest (always fresh from network) * Get full level entry by ID/slug
*/ */
private async loadDirectory(): Promise<void> { public getLevelEntry(levelId: string): CloudLevelEntry | null {
try { return this.levels.get(levelId) || null;
// 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.');
}
} }
/** /**
* Populate default level registry entries from directory * Get all levels
*/ */
private populateDefaultLevelEntries(): void { public getAllLevels(): Map<string, CloudLevelEntry> {
if (!this.directoryManifest) { return new Map(this.levels);
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<LevelConfig | null> {
// 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<void> {
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<string, LevelRegistryEntry> {
const all = new Map<string, LevelRegistryEntry>();
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<string, LevelRegistryEntry> {
return new Map(this.defaultLevels);
}
/**
* Get only custom levels
*/
public getCustomLevels(): Map<string, LevelRegistryEntry> {
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<boolean> {
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<void> {
// 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;
} }
/** /**
@ -391,17 +69,11 @@ export class LevelRegistry {
} }
/** /**
* Reset registry state (for testing or force reload) * Reset registry state
*/ */
public reset(): void { public reset(): void {
for (const entry of this.defaultLevels.values()) { this.levels.clear();
entry.config = null;
entry.loadedAt = undefined;
}
this.initialized = false; this.initialized = false;
this.directoryManifest = null;
console.log('[LevelRegistry] Reset complete. Call initialize() to reload.'); console.log('[LevelRegistry] Reset complete. Call initialize() to reload.');
} }
} }

View File

@ -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<CloudLevelEntry[]> {
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<CloudLevelEntry[]> {
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<CloudLevelEntry[]> {
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<CloudLevelEntry | null> {
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<CloudLevelEntry | null> {
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<boolean> {
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<CloudLevelEntry | null> {
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<CloudLevelEntry | null> {
const client = await SupabaseService.getInstance().getAuthenticatedClient();
if (!client) {
console.warn('[CloudLevelService] Not authenticated');
return null;
}
const updateData: Record<string, any> = {};
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<boolean> {
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<boolean> {
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<boolean> {
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<CloudLevelEntry[]> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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,
};
}
}

View File

@ -90,4 +90,59 @@ export class SupabaseService {
return this._authenticatedClient; 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<string | null> {
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;
}
} }

View File

@ -431,7 +431,7 @@ export class Ship {
() => this.handleResume(), () => this.handleResume(),
() => this.handleNextLevel() () => this.handleNextLevel()
); );
this._statusScreen.initialize(this._camera); this._statusScreen.initialize();
} }
/** /**

View File

@ -1,11 +1,11 @@
import { writable, get } from 'svelte/store'; import { writable } from 'svelte/store';
import { LevelRegistry, type LevelDirectoryEntry } from '../levels/storage/levelRegistry'; import { LevelRegistry } from '../levels/storage/levelRegistry';
import type { LevelConfig } from '../levels/config/levelConfig'; import type { LevelConfig } from '../levels/config/levelConfig';
import type { CloudLevelEntry } from '../services/cloudLevelService';
export interface LevelRegistryState { export interface LevelRegistryState {
isInitialized: boolean; isInitialized: boolean;
defaultLevels: Map<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>; levels: Map<string, CloudLevelEntry>;
customLevels: Map<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>;
} }
function createLevelRegistryStore() { function createLevelRegistryStore() {
@ -13,46 +13,41 @@ function createLevelRegistryStore() {
const initial: LevelRegistryState = { const initial: LevelRegistryState = {
isInitialized: false, isInitialized: false,
defaultLevels: new Map(), levels: new Map(),
customLevels: new Map(),
}; };
const { subscribe, set, update } = writable<LevelRegistryState>(initial); const { subscribe, set, update } = writable<LevelRegistryState>(initial);
// Initialize registry // Initialize registry
(async () => { (async () => {
try {
await registry.initialize(); await registry.initialize();
update(state => ({ update(state => ({
...state, ...state,
isInitialized: true, isInitialized: true,
defaultLevels: registry.getDefaultLevels(), levels: registry.getAllLevels(),
customLevels: registry.getCustomLevels(),
})); }));
} catch (error) {
console.error('[LevelRegistryStore] Failed to initialize:', error);
}
})(); })();
return { return {
subscribe, subscribe,
getLevel: async (levelId: string): Promise<LevelConfig | null> => { getLevel: (levelId: string): LevelConfig | null => {
return await registry.getLevel(levelId); return registry.getLevel(levelId);
},
getLevelEntry: (levelId: string): CloudLevelEntry | null => {
return registry.getLevelEntry(levelId);
}, },
refresh: async () => { refresh: async () => {
registry.reset();
await registry.initialize(); await registry.initialize();
update(state => ({ update(state => ({
...state, ...state,
defaultLevels: registry.getDefaultLevels(), levels: registry.getAllLevels(),
customLevels: registry.getCustomLevels(),
})); }));
}, },
deleteCustomLevel: (levelId: string): boolean => {
const success = registry.deleteCustomLevel(levelId);
if (success) {
update(state => ({
...state,
customLevels: registry.getCustomLevels(),
}));
}
return success;
},
}; };
} }

View File

@ -10,7 +10,7 @@ import { DefaultScene } from "../../core/defaultScene";
import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core"; import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core";
import debugLog from '../../core/debug'; import debugLog from '../../core/debug';
import { LevelConfig } from "../../levels/config/levelConfig"; import { LevelConfig } from "../../levels/config/levelConfig";
import { LevelDirectoryEntry } from "../../levels/storage/levelRegistry"; import { CloudLevelEntry } from "../../services/cloudLevelService";
/** /**
* Mission brief display for VR * Mission brief display for VR
@ -46,8 +46,8 @@ export class MissionBrief {
mesh.parent = ship; mesh.parent = ship;
mesh.position = new Vector3(0,1,2.8); mesh.position = new Vector3(0,1,2.8);
// mesh.rotation = new Vector3(0, Math.PI, 0); mesh.rotation = new Vector3(0, 0, 0);
//mesh.renderingGroupId = 3; // Same as status screen for consistent rendering mesh.renderingGroupId = 3; // Same as status screen for consistent rendering
mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection 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 parented to ship at position:', mesh.position);
console.log('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition()); 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 triggerObservable - Observable that fires when trigger is pulled
* @param onStart - Callback when start button is pressed * @param onStart - Callback when start button is pressed
*/ */
public show(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null, triggerObservable: Observable<void>, onStart: () => void): void { public show(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null, triggerObservable: Observable<void>, onStart: () => void): void {
console.log('[MissionBrief] ========== SHOW() CALLED =========='); console.log('[MissionBrief] ========== SHOW() CALLED ==========');
console.log('[MissionBrief] Container exists:', !!this._container); console.log('[MissionBrief] Container exists:', !!this._container);
console.log('[MissionBrief] AdvancedTexture exists:', !!this._advancedTexture); 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 * 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 difficulty = levelConfig.difficulty.toUpperCase();
const name = directoryEntry?.name || levelConfig.metadata?.description || "Mission"; const name = directoryEntry?.name || levelConfig.metadata?.description || "Mission";
const description = directoryEntry?.description || "Clear the asteroid field"; 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 * 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; const asteroidCount = levelConfig.asteroids?.length || 0;
// Use mission brief from directory if available // Use mission brief from directory if available

View File

@ -86,8 +86,8 @@ export class StatusScreen {
/** /**
* Initialize the status screen mesh and UI * Initialize the status screen mesh and UI
*/ */
public initialize(camera: Camera): void { public initialize(): void {
this._camera = camera; this._camera = DefaultScene.XR.baseExperience.camera;
// Create a plane mesh for the status screen // Create a plane mesh for the status screen
this._screenMesh = MeshBuilder.CreatePlane( this._screenMesh = MeshBuilder.CreatePlane(

View File

@ -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;

View File

@ -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');

View File

@ -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;