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">
|
<script lang="ts">
|
||||||
import { navigate } from 'svelte-routing';
|
import { navigate } from 'svelte-routing';
|
||||||
import type { LevelDirectoryEntry } from '../../levels/storage/levelRegistry';
|
import type { CloudLevelEntry } from '../../services/cloudLevelService';
|
||||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
import { progressionStore } from '../../stores/progression';
|
import { progressionStore } from '../../stores/progression';
|
||||||
import { gameConfigStore } from '../../stores/gameConfig';
|
import { gameConfigStore } from '../../stores/gameConfig';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
|
|
||||||
export let levelId: string;
|
export let levelId: string;
|
||||||
export let directoryEntry: LevelDirectoryEntry;
|
export let levelEntry: CloudLevelEntry;
|
||||||
export let isDefault: boolean = true;
|
|
||||||
|
|
||||||
async function handleLevelClick() {
|
async function handleLevelClick() {
|
||||||
console.log('[LevelCard] Level clicked:', {
|
console.log('[LevelCard] Level clicked:', {
|
||||||
levelId,
|
levelId,
|
||||||
levelName: directoryEntry.name,
|
levelName: levelEntry.name,
|
||||||
isUnlocked,
|
isUnlocked,
|
||||||
isAuthenticated: $authStore.isAuthenticated,
|
isAuthenticated: $authStore.isAuthenticated,
|
||||||
buttonText
|
buttonText
|
||||||
@ -43,15 +41,9 @@
|
|||||||
navigate(`/play/${levelId}`);
|
navigate(`/play/${levelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
// Determine if level is unlocked
|
||||||
if (confirm(`Are you sure you want to delete "${levelId}"?`)) {
|
|
||||||
levelRegistryStore.deleteCustomLevel(levelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if level is unlocked - complex logic matching original implementation
|
|
||||||
$: {
|
$: {
|
||||||
const isTutorial = progressionStore.isTutorial(directoryEntry.name);
|
const isTutorial = progressionStore.isTutorial(levelEntry.name);
|
||||||
const isAuthenticated = $authStore.isAuthenticated;
|
const isAuthenticated = $authStore.isAuthenticated;
|
||||||
const progressionEnabled = $gameConfigStore.progressionEnabled;
|
const progressionEnabled = $gameConfigStore.progressionEnabled;
|
||||||
|
|
||||||
@ -65,11 +57,11 @@
|
|||||||
isUnlocked = false;
|
isUnlocked = false;
|
||||||
lockReason = 'Sign in to unlock';
|
lockReason = 'Sign in to unlock';
|
||||||
buttonText = 'Sign In Required';
|
buttonText = 'Sign In Required';
|
||||||
} else if (progressionEnabled && isDefault) {
|
} else if (progressionEnabled) {
|
||||||
// Check sequential progression
|
// Check sequential progression
|
||||||
isUnlocked = progressionStore.isLevelUnlocked(directoryEntry.name, isDefault);
|
isUnlocked = progressionStore.isLevelUnlocked(levelEntry.name, true);
|
||||||
if (!isUnlocked) {
|
if (!isUnlocked) {
|
||||||
const prevLevel = progressionStore.getPreviousLevelName(directoryEntry.name);
|
const prevLevel = progressionStore.getPreviousLevelName(levelEntry.name);
|
||||||
lockReason = prevLevel ? `Complete "${prevLevel}" to unlock` : 'Locked';
|
lockReason = prevLevel ? `Complete "${prevLevel}" to unlock` : 'Locked';
|
||||||
buttonText = 'Locked';
|
buttonText = 'Locked';
|
||||||
} else {
|
} else {
|
||||||
@ -77,7 +69,7 @@
|
|||||||
buttonText = 'Play Level';
|
buttonText = 'Play Level';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Custom levels or progression disabled - always unlocked when authenticated
|
// Progression disabled - always unlocked when authenticated
|
||||||
isUnlocked = true;
|
isUnlocked = true;
|
||||||
lockReason = '';
|
lockReason = '';
|
||||||
buttonText = 'Play Level';
|
buttonText = 'Play Level';
|
||||||
@ -93,23 +85,20 @@
|
|||||||
|
|
||||||
<div class={cardClasses}>
|
<div class={cardClasses}>
|
||||||
<div class="level-card-header">
|
<div class="level-card-header">
|
||||||
<h2 class="level-card-title">{directoryEntry.name}</h2>
|
<h2 class="level-card-title">{levelEntry.name}</h2>
|
||||||
{#if !isUnlocked}
|
{#if !isUnlocked}
|
||||||
<div class="level-card-status level-card-status-locked">🔒</div>
|
<div class="level-card-status level-card-status-locked">🔒</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isDefault}
|
|
||||||
<div class="level-card-badge level-card-badge-custom">CUSTOM</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="level-meta">
|
<div class="level-meta">
|
||||||
Difficulty: {directoryEntry.difficulty || 'unknown'}
|
Difficulty: {levelEntry.difficulty || 'unknown'}
|
||||||
{#if directoryEntry.estimatedTime}
|
{#if levelEntry.estimatedTime}
|
||||||
• {directoryEntry.estimatedTime}
|
• {levelEntry.estimatedTime}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="level-card-description">{directoryEntry.description}</p>
|
<p class="level-card-description">{levelEntry.description}</p>
|
||||||
|
|
||||||
{#if !isUnlocked && lockReason}
|
{#if !isUnlocked && lockReason}
|
||||||
<div class="level-lock-reason">{lockReason}</div>
|
<div class="level-lock-reason">{lockReason}</div>
|
||||||
@ -122,12 +111,6 @@
|
|||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{#if !isDefault && isUnlocked}
|
|
||||||
<Button variant="secondary" on:click={handleDelete} title="Delete level">
|
|
||||||
🗑️
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||||
import { authStore } from '../../stores/auth';
|
|
||||||
import LevelCard from './LevelCard.svelte';
|
import LevelCard from './LevelCard.svelte';
|
||||||
import ProgressBar from './ProgressBar.svelte';
|
import ProgressBar from './ProgressBar.svelte';
|
||||||
|
|
||||||
// Get default levels in order (must match directory.json)
|
// Get levels in order (by sortOrder from Supabase)
|
||||||
const DEFAULT_LEVEL_ORDER = [
|
const LEVEL_ORDER = [
|
||||||
'rookie-training',
|
'rookie-training',
|
||||||
'asteroid-mania',
|
'asteroid-mania',
|
||||||
'deep-space-patrol',
|
'deep-space-patrol',
|
||||||
@ -16,13 +15,10 @@
|
|||||||
|
|
||||||
// Reactive declarations for store values
|
// Reactive declarations for store values
|
||||||
$: isReady = $levelRegistryStore.isInitialized;
|
$: isReady = $levelRegistryStore.isInitialized;
|
||||||
$: defaultLevels = $levelRegistryStore.defaultLevels;
|
$: levels = $levelRegistryStore.levels;
|
||||||
$: customLevels = $levelRegistryStore.customLevels;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="mainDiv">
|
<div id="mainDiv">
|
||||||
|
|
||||||
|
|
||||||
<div id="levelSelect" class:ready={isReady}>
|
<div id="levelSelect" class:ready={isReady}>
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
@ -41,42 +37,23 @@
|
|||||||
<div class="card-container" id="levelCardsContainer">
|
<div class="card-container" id="levelCardsContainer">
|
||||||
{#if !isReady}
|
{#if !isReady}
|
||||||
<div class="loading-message">Loading levels...</div>
|
<div class="loading-message">Loading levels...</div>
|
||||||
{:else if defaultLevels.size === 0}
|
{:else if levels.size === 0}
|
||||||
<div class="no-levels-message">
|
<div class="no-levels-message">
|
||||||
<h2>No Levels Found</h2>
|
<h2>No Levels Found</h2>
|
||||||
<p>No levels available. Please check your installation.</p>
|
<p>No levels available. Please check your connection.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each DEFAULT_LEVEL_ORDER as levelId}
|
{#each LEVEL_ORDER as levelId}
|
||||||
{@const entry = defaultLevels.get(levelId)}
|
{@const entry = levels.get(levelId)}
|
||||||
{#if entry}
|
{#if entry}
|
||||||
<LevelCard
|
<LevelCard
|
||||||
{levelId}
|
{levelId}
|
||||||
directoryEntry={entry.directoryEntry}
|
levelEntry={entry}
|
||||||
isDefault={entry.isDefault}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if customLevels.size > 0}
|
|
||||||
<div style="grid-column: 1 / -1; margin-top: var(--space-2xl);">
|
|
||||||
<h3 class="level-header">Custom Levels</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each Array.from(customLevels.entries()) as [levelId, entry]}
|
|
||||||
<LevelCard
|
|
||||||
{levelId}
|
|
||||||
directoryEntry={entry.directoryEntry}
|
|
||||||
isDefault={false}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,7 +18,8 @@ import debugLog from '../core/debug';
|
|||||||
import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
|
import {PhysicsRecorder} from "../replay/recording/physicsRecorder";
|
||||||
import {getAnalytics} from "../analytics";
|
import {getAnalytics} from "../analytics";
|
||||||
import {MissionBrief} from "../ui/hud/missionBrief";
|
import {MissionBrief} from "../ui/hud/missionBrief";
|
||||||
import {LevelRegistry, LevelDirectoryEntry} from "./storage/levelRegistry";
|
import {LevelRegistry} from "./storage/levelRegistry";
|
||||||
|
import type {CloudLevelEntry} from "../services/cloudLevelService";
|
||||||
import { InputControlManager } from "../ship/input/inputControlManager";
|
import { InputControlManager } from "../ship/input/inputControlManager";
|
||||||
|
|
||||||
export class Level1 implements Level {
|
export class Level1 implements Level {
|
||||||
@ -166,7 +167,7 @@ export class Level1 implements Level {
|
|||||||
this._missionBriefShown = true;
|
this._missionBriefShown = true;
|
||||||
console.log('[Level1] showMissionBrief() called');
|
console.log('[Level1] showMissionBrief() called');
|
||||||
|
|
||||||
let directoryEntry: LevelDirectoryEntry | null = null;
|
let directoryEntry: CloudLevelEntry | null = null;
|
||||||
|
|
||||||
// Try to get directory entry if we have a level ID
|
// Try to get directory entry if we have a level ID
|
||||||
if (this._levelId) {
|
if (this._levelId) {
|
||||||
@ -182,12 +183,12 @@ export class Level1 implements Level {
|
|||||||
console.log('[Level1] Registry entry found:', !!registryEntry);
|
console.log('[Level1] Registry entry found:', !!registryEntry);
|
||||||
|
|
||||||
if (registryEntry) {
|
if (registryEntry) {
|
||||||
directoryEntry = registryEntry.directoryEntry;
|
directoryEntry = registryEntry;
|
||||||
console.log('[Level1] Directory entry data:', {
|
console.log('[Level1] Level entry data:', {
|
||||||
id: directoryEntry?.id,
|
id: directoryEntry?.id,
|
||||||
|
slug: directoryEntry?.slug,
|
||||||
name: directoryEntry?.name,
|
name: directoryEntry?.name,
|
||||||
description: directoryEntry?.description,
|
description: directoryEntry?.description,
|
||||||
levelPath: directoryEntry?.levelPath,
|
|
||||||
missionBriefCount: directoryEntry?.missionBrief?.length || 0,
|
missionBriefCount: directoryEntry?.missionBrief?.length || 0,
|
||||||
estimatedTime: directoryEntry?.estimatedTime,
|
estimatedTime: directoryEntry?.estimatedTime,
|
||||||
difficulty: directoryEntry?.difficulty
|
difficulty: directoryEntry?.difficulty
|
||||||
@ -199,11 +200,7 @@ export class Level1 implements Level {
|
|||||||
console.log(` ${i + 1}. ${item}`);
|
console.log(` ${i + 1}. ${item}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Level1] ⚠️ No missionBrief found in directory entry!');
|
console.warn('[Level1] ⚠️ No missionBrief found in level entry!');
|
||||||
}
|
|
||||||
|
|
||||||
if (!directoryEntry?.levelPath) {
|
|
||||||
console.warn('[Level1] ⚠️ No levelPath found in directory entry!');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId);
|
console.error('[Level1] ❌ No registry entry found for level ID:', this._levelId);
|
||||||
|
|||||||
@ -1,52 +1,12 @@
|
|||||||
import {LevelConfig} from "../config/levelConfig";
|
import { LevelConfig } from "../config/levelConfig";
|
||||||
|
import { CloudLevelService, CloudLevelEntry } from "../../services/cloudLevelService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Level directory entry from directory.json manifest
|
* Singleton registry for managing levels from cloud (Supabase)
|
||||||
*/
|
|
||||||
export interface LevelDirectoryEntry {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
levelPath: string;
|
|
||||||
missionBrief?: string[];
|
|
||||||
estimatedTime?: string;
|
|
||||||
difficulty?: string;
|
|
||||||
unlockRequirements?: string[];
|
|
||||||
tags?: string[];
|
|
||||||
defaultLocked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Directory manifest structure
|
|
||||||
*/
|
|
||||||
export interface LevelDirectory {
|
|
||||||
version: string;
|
|
||||||
levels: LevelDirectoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registry entry combining directory info with loaded config
|
|
||||||
*/
|
|
||||||
export interface LevelRegistryEntry {
|
|
||||||
directoryEntry: LevelDirectoryEntry;
|
|
||||||
config: LevelConfig | null; // null if not yet loaded
|
|
||||||
isDefault: boolean;
|
|
||||||
loadedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CUSTOM_LEVELS_KEY = 'space-game-custom-levels';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton registry for managing both default and custom levels
|
|
||||||
* Always fetches fresh from network - no caching
|
|
||||||
*/
|
*/
|
||||||
export class LevelRegistry {
|
export class LevelRegistry {
|
||||||
private static instance: LevelRegistry | null = null;
|
private static instance: LevelRegistry | null = null;
|
||||||
|
private levels: Map<string, CloudLevelEntry> = new Map();
|
||||||
private defaultLevels: Map<string, LevelRegistryEntry> = new Map();
|
|
||||||
private customLevels: Map<string, LevelRegistryEntry> = new Map();
|
|
||||||
private directoryManifest: LevelDirectory | null = null;
|
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
@ -59,328 +19,46 @@ export class LevelRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the registry by loading directory and levels
|
* Initialize the registry by loading levels from cloud
|
||||||
*/
|
*/
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
if (this.initialized) {
|
if (this.initialized) return;
|
||||||
return;
|
|
||||||
|
const cloudService = CloudLevelService.getInstance();
|
||||||
|
if (!cloudService.isAvailable()) {
|
||||||
|
throw new Error('Cloud service not available - cannot load levels');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const entries = await cloudService.getOfficialLevels();
|
||||||
await this.loadDirectory();
|
for (const entry of entries) {
|
||||||
this.loadCustomLevels();
|
const key = entry.slug || entry.id;
|
||||||
this.initialized = true;
|
this.levels.set(key, entry);
|
||||||
console.log('[LevelRegistry] Initialized with', this.defaultLevels.size, 'default levels');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LevelRegistry] Failed to initialize:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[LevelRegistry] Loaded', this.levels.size, 'levels from cloud:',
|
||||||
|
Array.from(this.levels.keys()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if running in development mode (for cache-busting HTTP requests)
|
* Get a level config by ID/slug
|
||||||
*/
|
*/
|
||||||
private isDevMode(): boolean {
|
public getLevel(levelId: string): LevelConfig | null {
|
||||||
return window.location.hostname === 'localhost' ||
|
return this.levels.get(levelId)?.config || null;
|
||||||
window.location.hostname.includes('dev.') ||
|
|
||||||
window.location.port !== '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the directory.json manifest (always fresh from network)
|
* Get full level entry by ID/slug
|
||||||
*/
|
*/
|
||||||
private async loadDirectory(): Promise<void> {
|
public getLevelEntry(levelId: string): CloudLevelEntry | null {
|
||||||
try {
|
return this.levels.get(levelId) || null;
|
||||||
// Add cache-busting in dev mode to avoid browser HTTP cache
|
|
||||||
const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : '';
|
|
||||||
const response = await fetch(`/levels/directory.json${cacheBuster}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch directory: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.directoryManifest = await response.json();
|
|
||||||
console.log('[LevelRegistry] Loaded directory with', this.directoryManifest?.levels?.length || 0, 'levels');
|
|
||||||
|
|
||||||
this.populateDefaultLevelEntries();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[LevelRegistry] Failed to load directory:', error);
|
|
||||||
throw new Error('Unable to load level directory. Please check your connection.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate default level registry entries from directory
|
* Get all levels
|
||||||
*/
|
*/
|
||||||
private populateDefaultLevelEntries(): void {
|
public getAllLevels(): Map<string, CloudLevelEntry> {
|
||||||
if (!this.directoryManifest) {
|
return new Map(this.levels);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.defaultLevels.clear();
|
|
||||||
|
|
||||||
for (const entry of this.directoryManifest.levels) {
|
|
||||||
this.defaultLevels.set(entry.id, {
|
|
||||||
directoryEntry: entry,
|
|
||||||
config: null, // Lazy load
|
|
||||||
isDefault: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[LevelRegistry] Level IDs:', Array.from(this.defaultLevels.keys()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load custom levels from localStorage
|
|
||||||
*/
|
|
||||||
private loadCustomLevels(): void {
|
|
||||||
this.customLevels.clear();
|
|
||||||
|
|
||||||
const stored = localStorage.getItem(CUSTOM_LEVELS_KEY);
|
|
||||||
if (!stored) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const levelsArray: [string, LevelConfig][] = JSON.parse(stored);
|
|
||||||
|
|
||||||
for (const [id, config] of levelsArray) {
|
|
||||||
this.customLevels.set(id, {
|
|
||||||
directoryEntry: {
|
|
||||||
id,
|
|
||||||
name: config.metadata?.description || id,
|
|
||||||
description: config.metadata?.description || '',
|
|
||||||
version: config.version || '1.0',
|
|
||||||
levelPath: '',
|
|
||||||
difficulty: config.difficulty,
|
|
||||||
missionBrief: [],
|
|
||||||
defaultLocked: false
|
|
||||||
},
|
|
||||||
config,
|
|
||||||
isDefault: false,
|
|
||||||
loadedAt: new Date()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load custom levels from localStorage:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a level config by ID (loads if not yet loaded)
|
|
||||||
*/
|
|
||||||
public async getLevel(levelId: string): Promise<LevelConfig | null> {
|
|
||||||
// Check default levels first
|
|
||||||
const defaultEntry = this.defaultLevels.get(levelId);
|
|
||||||
if (defaultEntry) {
|
|
||||||
if (!defaultEntry.config) {
|
|
||||||
await this.loadDefaultLevel(levelId);
|
|
||||||
}
|
|
||||||
return defaultEntry.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check custom levels
|
|
||||||
const customEntry = this.customLevels.get(levelId);
|
|
||||||
return customEntry?.config || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a default level's config from JSON (always fresh from network)
|
|
||||||
*/
|
|
||||||
private async loadDefaultLevel(levelId: string): Promise<void> {
|
|
||||||
const entry = this.defaultLevels.get(levelId);
|
|
||||||
if (!entry || entry.config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelPath = `/levels/${entry.directoryEntry.levelPath}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add cache-busting in dev mode
|
|
||||||
const cacheBuster = this.isDevMode() ? `?v=${Date.now()}` : '';
|
|
||||||
const response = await fetch(`${levelPath}${cacheBuster}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch level: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.config = await response.json();
|
|
||||||
entry.loadedAt = new Date();
|
|
||||||
console.log('[LevelRegistry] Loaded level:', levelId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[LevelRegistry] Failed to load level ${levelId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all level registry entries (default + custom)
|
|
||||||
*/
|
|
||||||
public getAllLevels(): Map<string, LevelRegistryEntry> {
|
|
||||||
const all = new Map<string, LevelRegistryEntry>();
|
|
||||||
|
|
||||||
for (const [id, entry] of this.defaultLevels) {
|
|
||||||
all.set(id, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, entry] of this.customLevels) {
|
|
||||||
all.set(id, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only default levels
|
|
||||||
*/
|
|
||||||
public getDefaultLevels(): Map<string, LevelRegistryEntry> {
|
|
||||||
return new Map(this.defaultLevels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only custom levels
|
|
||||||
*/
|
|
||||||
public getCustomLevels(): Map<string, LevelRegistryEntry> {
|
|
||||||
return new Map(this.customLevels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a custom level
|
|
||||||
*/
|
|
||||||
public saveCustomLevel(levelId: string, config: LevelConfig): void {
|
|
||||||
if (!config.metadata) {
|
|
||||||
config.metadata = {
|
|
||||||
author: 'Player',
|
|
||||||
description: levelId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.metadata.type === 'default') {
|
|
||||||
delete config.metadata.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.customLevels.set(levelId, {
|
|
||||||
directoryEntry: {
|
|
||||||
id: levelId,
|
|
||||||
name: config.metadata.description || levelId,
|
|
||||||
description: config.metadata.description || '',
|
|
||||||
version: config.version || '1.0',
|
|
||||||
levelPath: '',
|
|
||||||
difficulty: config.difficulty,
|
|
||||||
missionBrief: [],
|
|
||||||
defaultLocked: false
|
|
||||||
},
|
|
||||||
config,
|
|
||||||
isDefault: false,
|
|
||||||
loadedAt: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
this.saveCustomLevelsToStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a custom level
|
|
||||||
*/
|
|
||||||
public deleteCustomLevel(levelId: string): boolean {
|
|
||||||
const deleted = this.customLevels.delete(levelId);
|
|
||||||
if (deleted) {
|
|
||||||
this.saveCustomLevelsToStorage();
|
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy a default level to custom levels with a new ID
|
|
||||||
*/
|
|
||||||
public async copyDefaultToCustom(defaultLevelId: string, newCustomId: string): Promise<boolean> {
|
|
||||||
const config = await this.getLevel(defaultLevelId);
|
|
||||||
if (!config) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clonedConfig: LevelConfig = JSON.parse(JSON.stringify(config));
|
|
||||||
|
|
||||||
clonedConfig.metadata = {
|
|
||||||
...clonedConfig.metadata,
|
|
||||||
type: undefined,
|
|
||||||
author: 'Player',
|
|
||||||
description: `Copy of ${defaultLevelId}`,
|
|
||||||
originalDefault: defaultLevelId
|
|
||||||
};
|
|
||||||
|
|
||||||
this.saveCustomLevel(newCustomId, clonedConfig);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist custom levels to localStorage
|
|
||||||
*/
|
|
||||||
private saveCustomLevelsToStorage(): void {
|
|
||||||
const levelsArray: [string, LevelConfig][] = [];
|
|
||||||
|
|
||||||
for (const [id, entry] of this.customLevels) {
|
|
||||||
if (entry.config) {
|
|
||||||
levelsArray.push([id, entry.config]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(CUSTOM_LEVELS_KEY, JSON.stringify(levelsArray));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force refresh all default levels from network
|
|
||||||
*/
|
|
||||||
public async refreshDefaultLevels(): Promise<void> {
|
|
||||||
// Clear in-memory configs
|
|
||||||
for (const entry of this.defaultLevels.values()) {
|
|
||||||
entry.config = null;
|
|
||||||
entry.loadedAt = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload directory
|
|
||||||
await this.loadDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export custom levels as JSON for backup/sharing
|
|
||||||
*/
|
|
||||||
public exportCustomLevels(): string {
|
|
||||||
const levelsArray: [string, LevelConfig][] = [];
|
|
||||||
|
|
||||||
for (const [id, entry] of this.customLevels) {
|
|
||||||
if (entry.config) {
|
|
||||||
levelsArray.push([id, entry.config]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(levelsArray, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import custom levels from JSON
|
|
||||||
*/
|
|
||||||
public importCustomLevels(jsonString: string): number {
|
|
||||||
try {
|
|
||||||
const levelsArray: [string, LevelConfig][] = JSON.parse(jsonString);
|
|
||||||
let importCount = 0;
|
|
||||||
|
|
||||||
for (const [id, config] of levelsArray) {
|
|
||||||
this.saveCustomLevel(id, config);
|
|
||||||
importCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return importCount;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to import custom levels:', error);
|
|
||||||
throw new Error('Invalid custom levels JSON format');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get directory manifest
|
|
||||||
*/
|
|
||||||
public getDirectory(): LevelDirectory | null {
|
|
||||||
return this.directoryManifest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -391,17 +69,11 @@ export class LevelRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset registry state (for testing or force reload)
|
* Reset registry state
|
||||||
*/
|
*/
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
for (const entry of this.defaultLevels.values()) {
|
this.levels.clear();
|
||||||
entry.config = null;
|
|
||||||
entry.loadedAt = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.directoryManifest = null;
|
|
||||||
|
|
||||||
console.log('[LevelRegistry] Reset complete. Call initialize() to reload.');
|
console.log('[LevelRegistry] Reset complete. Call initialize() to reload.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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.handleResume(),
|
||||||
() => this.handleNextLevel()
|
() => this.handleNextLevel()
|
||||||
);
|
);
|
||||||
this._statusScreen.initialize(this._camera);
|
this._statusScreen.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { writable, get } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { LevelRegistry, type LevelDirectoryEntry } from '../levels/storage/levelRegistry';
|
import { LevelRegistry } from '../levels/storage/levelRegistry';
|
||||||
import type { LevelConfig } from '../levels/config/levelConfig';
|
import type { LevelConfig } from '../levels/config/levelConfig';
|
||||||
|
import type { CloudLevelEntry } from '../services/cloudLevelService';
|
||||||
|
|
||||||
export interface LevelRegistryState {
|
export interface LevelRegistryState {
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
defaultLevels: Map<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>;
|
levels: Map<string, CloudLevelEntry>;
|
||||||
customLevels: Map<string, { config: LevelConfig | null; directoryEntry: LevelDirectoryEntry; isDefault: boolean }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLevelRegistryStore() {
|
function createLevelRegistryStore() {
|
||||||
@ -13,46 +13,41 @@ function createLevelRegistryStore() {
|
|||||||
|
|
||||||
const initial: LevelRegistryState = {
|
const initial: LevelRegistryState = {
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
defaultLevels: new Map(),
|
levels: new Map(),
|
||||||
customLevels: new Map(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { subscribe, set, update } = writable<LevelRegistryState>(initial);
|
const { subscribe, set, update } = writable<LevelRegistryState>(initial);
|
||||||
|
|
||||||
// Initialize registry
|
// Initialize registry
|
||||||
(async () => {
|
(async () => {
|
||||||
await registry.initialize();
|
try {
|
||||||
update(state => ({
|
await registry.initialize();
|
||||||
...state,
|
update(state => ({
|
||||||
isInitialized: true,
|
...state,
|
||||||
defaultLevels: registry.getDefaultLevels(),
|
isInitialized: true,
|
||||||
customLevels: registry.getCustomLevels(),
|
levels: registry.getAllLevels(),
|
||||||
}));
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LevelRegistryStore] Failed to initialize:', error);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
getLevel: async (levelId: string): Promise<LevelConfig | null> => {
|
getLevel: (levelId: string): LevelConfig | null => {
|
||||||
return await registry.getLevel(levelId);
|
return registry.getLevel(levelId);
|
||||||
|
},
|
||||||
|
getLevelEntry: (levelId: string): CloudLevelEntry | null => {
|
||||||
|
return registry.getLevelEntry(levelId);
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
|
registry.reset();
|
||||||
await registry.initialize();
|
await registry.initialize();
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
defaultLevels: registry.getDefaultLevels(),
|
levels: registry.getAllLevels(),
|
||||||
customLevels: registry.getCustomLevels(),
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
deleteCustomLevel: (levelId: string): boolean => {
|
|
||||||
const success = registry.deleteCustomLevel(levelId);
|
|
||||||
if (success) {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
customLevels: registry.getCustomLevels(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { DefaultScene } from "../../core/defaultScene";
|
|||||||
import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core";
|
import {Mesh, MeshBuilder, Vector3, Observable, Observer} from "@babylonjs/core";
|
||||||
import debugLog from '../../core/debug';
|
import debugLog from '../../core/debug';
|
||||||
import { LevelConfig } from "../../levels/config/levelConfig";
|
import { LevelConfig } from "../../levels/config/levelConfig";
|
||||||
import { LevelDirectoryEntry } from "../../levels/storage/levelRegistry";
|
import { CloudLevelEntry } from "../../services/cloudLevelService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mission brief display for VR
|
* Mission brief display for VR
|
||||||
@ -46,8 +46,8 @@ export class MissionBrief {
|
|||||||
|
|
||||||
mesh.parent = ship;
|
mesh.parent = ship;
|
||||||
mesh.position = new Vector3(0,1,2.8);
|
mesh.position = new Vector3(0,1,2.8);
|
||||||
// mesh.rotation = new Vector3(0, Math.PI, 0);
|
mesh.rotation = new Vector3(0, 0, 0);
|
||||||
//mesh.renderingGroupId = 3; // Same as status screen for consistent rendering
|
mesh.renderingGroupId = 3; // Same as status screen for consistent rendering
|
||||||
mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
|
mesh.metadata = { uiPickable: true }; // TAG: VR UI - allow pointer selection
|
||||||
console.log('[MissionBrief] Mesh parented to ship at position:', mesh.position);
|
console.log('[MissionBrief] Mesh parented to ship at position:', mesh.position);
|
||||||
console.log('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition());
|
console.log('[MissionBrief] Mesh absolute position:', mesh.getAbsolutePosition());
|
||||||
@ -93,7 +93,7 @@ export class MissionBrief {
|
|||||||
* @param triggerObservable - Observable that fires when trigger is pulled
|
* @param triggerObservable - Observable that fires when trigger is pulled
|
||||||
* @param onStart - Callback when start button is pressed
|
* @param onStart - Callback when start button is pressed
|
||||||
*/
|
*/
|
||||||
public show(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null, triggerObservable: Observable<void>, onStart: () => void): void {
|
public show(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null, triggerObservable: Observable<void>, onStart: () => void): void {
|
||||||
console.log('[MissionBrief] ========== SHOW() CALLED ==========');
|
console.log('[MissionBrief] ========== SHOW() CALLED ==========');
|
||||||
console.log('[MissionBrief] Container exists:', !!this._container);
|
console.log('[MissionBrief] Container exists:', !!this._container);
|
||||||
console.log('[MissionBrief] AdvancedTexture exists:', !!this._advancedTexture);
|
console.log('[MissionBrief] AdvancedTexture exists:', !!this._advancedTexture);
|
||||||
@ -261,7 +261,7 @@ export class MissionBrief {
|
|||||||
/**
|
/**
|
||||||
* Get mission description text based on level config and directory entry
|
* Get mission description text based on level config and directory entry
|
||||||
*/
|
*/
|
||||||
private getMissionDescription(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null): string {
|
private getMissionDescription(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null): string {
|
||||||
const difficulty = levelConfig.difficulty.toUpperCase();
|
const difficulty = levelConfig.difficulty.toUpperCase();
|
||||||
const name = directoryEntry?.name || levelConfig.metadata?.description || "Mission";
|
const name = directoryEntry?.name || levelConfig.metadata?.description || "Mission";
|
||||||
const description = directoryEntry?.description || "Clear the asteroid field";
|
const description = directoryEntry?.description || "Clear the asteroid field";
|
||||||
@ -276,7 +276,7 @@ export class MissionBrief {
|
|||||||
/**
|
/**
|
||||||
* Get objectives text based on level config and directory entry
|
* Get objectives text based on level config and directory entry
|
||||||
*/
|
*/
|
||||||
private getObjectives(levelConfig: LevelConfig, directoryEntry: LevelDirectoryEntry | null): string {
|
private getObjectives(levelConfig: LevelConfig, directoryEntry: CloudLevelEntry | null): string {
|
||||||
const asteroidCount = levelConfig.asteroids?.length || 0;
|
const asteroidCount = levelConfig.asteroids?.length || 0;
|
||||||
|
|
||||||
// Use mission brief from directory if available
|
// Use mission brief from directory if available
|
||||||
|
|||||||
@ -86,8 +86,8 @@ export class StatusScreen {
|
|||||||
/**
|
/**
|
||||||
* Initialize the status screen mesh and UI
|
* Initialize the status screen mesh and UI
|
||||||
*/
|
*/
|
||||||
public initialize(camera: Camera): void {
|
public initialize(): void {
|
||||||
this._camera = camera;
|
this._camera = DefaultScene.XR.baseExperience.camera;
|
||||||
|
|
||||||
// Create a plane mesh for the status screen
|
// Create a plane mesh for the status screen
|
||||||
this._screenMesh = MeshBuilder.CreatePlane(
|
this._screenMesh = MeshBuilder.CreatePlane(
|
||||||
|
|||||||
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