Migrate to cloud-only level system using Supabase
All checks were successful
Build / build (push) Successful in 1m44s
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:
parent
c9d7b0f3a5
commit
b4baa2beba
207
scripts/manageAdmin.ts
Normal file
207
scripts/manageAdmin.ts
Normal 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
182
scripts/runMigration.ts
Normal 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
242
scripts/seedLevels.ts
Normal 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);
|
||||
});
|
||||
@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from 'svelte-routing';
|
||||
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry';
|
||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||
import type { CloudLevelEntry } from '../../services/cloudLevelService';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import { progressionStore } from '../../stores/progression';
|
||||
import { gameConfigStore } from '../../stores/gameConfig';
|
||||
import Button from '../shared/Button.svelte';
|
||||
|
||||
export let levelId: string;
|
||||
export let directoryEntry: LevelDirectoryEntry;
|
||||
export let isDefault: boolean = true;
|
||||
export let levelEntry: CloudLevelEntry;
|
||||
|
||||
async function handleLevelClick() {
|
||||
console.log('[LevelCard] Level clicked:', {
|
||||
levelId,
|
||||
levelName: directoryEntry.name,
|
||||
levelName: levelEntry.name,
|
||||
isUnlocked,
|
||||
isAuthenticated: $authStore.isAuthenticated,
|
||||
buttonText
|
||||
@ -43,15 +41,9 @@
|
||||
navigate(`/play/${levelId}`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm(`Are you sure you want to delete "${levelId}"?`)) {
|
||||
levelRegistryStore.deleteCustomLevel(levelId);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if level is unlocked - complex logic matching original implementation
|
||||
// Determine if level is unlocked
|
||||
$: {
|
||||
const isTutorial = progressionStore.isTutorial(directoryEntry.name);
|
||||
const isTutorial = progressionStore.isTutorial(levelEntry.name);
|
||||
const isAuthenticated = $authStore.isAuthenticated;
|
||||
const progressionEnabled = $gameConfigStore.progressionEnabled;
|
||||
|
||||
@ -65,11 +57,11 @@
|
||||
isUnlocked = false;
|
||||
lockReason = 'Sign in to unlock';
|
||||
buttonText = 'Sign In Required';
|
||||
} else if (progressionEnabled && isDefault) {
|
||||
} else if (progressionEnabled) {
|
||||
// Check sequential progression
|
||||
isUnlocked = progressionStore.isLevelUnlocked(directoryEntry.name, isDefault);
|
||||
isUnlocked = progressionStore.isLevelUnlocked(levelEntry.name, true);
|
||||
if (!isUnlocked) {
|
||||
const prevLevel = progressionStore.getPreviousLevelName(directoryEntry.name);
|
||||
const prevLevel = progressionStore.getPreviousLevelName(levelEntry.name);
|
||||
lockReason = prevLevel ? `Complete "${prevLevel}" to unlock` : 'Locked';
|
||||
buttonText = 'Locked';
|
||||
} else {
|
||||
@ -77,7 +69,7 @@
|
||||
buttonText = 'Play Level';
|
||||
}
|
||||
} else {
|
||||
// Custom levels or progression disabled - always unlocked when authenticated
|
||||
// Progression disabled - always unlocked when authenticated
|
||||
isUnlocked = true;
|
||||
lockReason = '';
|
||||
buttonText = 'Play Level';
|
||||
@ -93,23 +85,20 @@
|
||||
|
||||
<div class={cardClasses}>
|
||||
<div class="level-card-header">
|
||||
<h2 class="level-card-title">{directoryEntry.name}</h2>
|
||||
<h2 class="level-card-title">{levelEntry.name}</h2>
|
||||
{#if !isUnlocked}
|
||||
<div class="level-card-status level-card-status-locked">🔒</div>
|
||||
{/if}
|
||||
{#if !isDefault}
|
||||
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="level-meta">
|
||||
Difficulty: {directoryEntry.difficulty || 'unknown'}
|
||||
{#if directoryEntry.estimatedTime}
|
||||
• {directoryEntry.estimatedTime}
|
||||
Difficulty: {levelEntry.difficulty || 'unknown'}
|
||||
{#if levelEntry.estimatedTime}
|
||||
• {levelEntry.estimatedTime}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="level-card-description">{directoryEntry.description}</p>
|
||||
<p class="level-card-description">{levelEntry.description}</p>
|
||||
|
||||
{#if !isUnlocked && lockReason}
|
||||
<div class="level-lock-reason">{lockReason}</div>
|
||||
@ -122,12 +111,6 @@
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{#if !isDefault && isUnlocked}
|
||||
<Button variant="secondary" on:click={handleDelete} title="Delete level">
|
||||
🗑️
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import LevelCard from './LevelCard.svelte';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
|
||||
// Get default levels in order (must match directory.json)
|
||||
const DEFAULT_LEVEL_ORDER = [
|
||||
// Get levels in order (by sortOrder from Supabase)
|
||||
const LEVEL_ORDER = [
|
||||
'rookie-training',
|
||||
'asteroid-mania',
|
||||
'deep-space-patrol',
|
||||
@ -16,13 +15,10 @@
|
||||
|
||||
// Reactive declarations for store values
|
||||
$: isReady = $levelRegistryStore.isInitialized;
|
||||
$: defaultLevels = $levelRegistryStore.defaultLevels;
|
||||
$: customLevels = $levelRegistryStore.customLevels;
|
||||
$: levels = $levelRegistryStore.levels;
|
||||
</script>
|
||||
|
||||
<div id="mainDiv">
|
||||
|
||||
|
||||
<div id="levelSelect" class:ready={isReady}>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero">
|
||||
@ -41,42 +37,23 @@
|
||||
<div class="card-container" id="levelCardsContainer">
|
||||
{#if !isReady}
|
||||
<div class="loading-message">Loading levels...</div>
|
||||
{:else if defaultLevels.size === 0}
|
||||
{:else if levels.size === 0}
|
||||
<div class="no-levels-message">
|
||||
<h2>No Levels Found</h2>
|
||||
<p>No levels available. Please check your installation.</p>
|
||||
<p>No levels available. Please check your connection.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each DEFAULT_LEVEL_ORDER as levelId}
|
||||
{@const entry = defaultLevels.get(levelId)}
|
||||
{#each LEVEL_ORDER as levelId}
|
||||
{@const entry = levels.get(levelId)}
|
||||
{#if entry}
|
||||
<LevelCard
|
||||
{levelId}
|
||||
directoryEntry={entry.directoryEntry}
|
||||
isDefault={entry.isDefault}
|
||||
levelEntry={entry}
|
||||
/>
|
||||
{/if}
|
||||
{/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}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<string, LevelRegistryEntry> = new Map();
|
||||
private customLevels: Map<string, LevelRegistryEntry> = new Map();
|
||||
private directoryManifest: LevelDirectory | null = null;
|
||||
private levels: Map<string, CloudLevelEntry> = 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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
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;
|
||||
console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels');
|
||||
} catch (error) {
|
||||
console.error('[LevelRegistry] Failed to initialize:', error);
|
||||
throw error;
|
||||
}
|
||||
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<void> {
|
||||
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<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;
|
||||
public getAllLevels(): Map<string, CloudLevelEntry> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
685
src/services/cloudLevelService.ts
Normal file
685
src/services/cloudLevelService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -431,7 +431,7 @@ export class Ship {
|
||||
() => this.handleResume(),
|
||||
() => this.handleNextLevel()
|
||||
);
|
||||
this._statusScreen.initialize(this._camera);
|
||||
this._statusScreen.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>;
|
||||
customLevels: Map<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>;
|
||||
levels: Map<string, CloudLevelEntry>;
|
||||
}
|
||||
|
||||
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<LevelRegistryState>(initial);
|
||||
|
||||
// Initialize registry
|
||||
(async () => {
|
||||
try {
|
||||
await registry.initialize();
|
||||
update(state => ({
|
||||
...state,
|
||||
isInitialized: true,
|
||||
defaultLevels: registry.getDefaultLevels(),
|
||||
customLevels: registry.getCustomLevels(),
|
||||
levels: registry.getAllLevels(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[LevelRegistryStore] Failed to initialize:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
getLevel: async (levelId: string): Promise<LevelConfig | null> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<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] 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
|
||||
|
||||
@ -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(
|
||||
|
||||
403
supabase/migrations/001_cloud_levels.sql
Normal file
403
supabase/migrations/001_cloud_levels.sql
Normal 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;
|
||||
257
supabase/migrations/002_user_mapping.sql
Normal file
257
supabase/migrations/002_user_mapping.sql
Normal 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');
|
||||
51
supabase/migrations/003_leaderboard_user_mapping.sql
Normal file
51
supabase/migrations/003_leaderboard_user_mapping.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user