diff --git a/README_DB_CONFIG.md b/README_DB_CONFIG.md index 7c4fa85..cef33d7 100644 --- a/README_DB_CONFIG.md +++ b/README_DB_CONFIG.md @@ -1,125 +1,86 @@ -# Database Configuration Setup +# Database Configuration Setup - Supabase ## Overview Database credentials are stored in a separate configuration file (`db.config.js`) that is **NOT committed to GitHub** for security reasons. +This project now uses **Supabase** (PostgreSQL) instead of MySQL. + ## Files ### 1. `db.config.example.js` (Committed to Git) -Template file showing the required configuration structure. +Template file showing the required Supabase configuration structure. ### 2. `db.config.js` (NOT Committed - in .gitignore) -Contains actual database credentials. This file must be created manually. +Contains actual Supabase credentials. This file must be created manually. ### 3. `.gitignore` Ensures `db.config.js` is never committed to the repository. -## Setup Instructions +## Quick Setup -### For Local Development +See **[SUPABASE_SETUP.md](SUPABASE_SETUP.md)** for detailed step-by-step instructions. -1. **Copy the example file:** - ```bash - cp db.config.example.js db.config.js - ``` +### Quick Start -2. **Edit `db.config.js` with your credentials:** +1. **Create Supabase project** at [app.supabase.com](https://app.supabase.com) +2. **Copy credentials** from Project Settings → API +3. **Update `db.config.js`:** ```javascript module.exports = { - host: 'localhost', // or your database host - user: 'your_username', - password: 'your_password', - database: 'appgconnect5_db', - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 + supabaseUrl: 'https://xxxxx.supabase.co', + supabaseAnonKey: 'eyJhbGci...', + supabasePassword: 't1hWsackxbYzRIPD' }; ``` - -3. **Start the server:** - ```bash - npm start - ``` - -### For Production Deployment - -1. **Pull the latest code on your server:** - ```bash - git pull origin main - ``` - -2. **Create `db.config.js` on the production server:** - ```bash - nano db.config.js - # or - vi db.config.js - ``` - -3. **Add your production database credentials:** - ```javascript - module.exports = { - host: 'your-production-db-host.com', - user: 'production_user', - password: 'secure_production_password', - database: 'appgconnect5_db', - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 - }; - ``` - -4. **Save and restart the server:** - ```bash - pm2 restart connect5 - # or your restart command - ``` +4. **Run SQL schema** in Supabase SQL Editor (see SUPABASE_SETUP.md) +5. **Start server:** `npm start` ## Security Features ✅ **Credentials not in git** - `db.config.js` is in `.gitignore` ✅ **Template provided** - `db.config.example.js` shows the structure -✅ **Comments in code** - Clear instructions in `database.js` +✅ **Supabase RLS** - Row Level Security policies protect data ✅ **Separate config** - Easy to update without touching main code ## Troubleshooting ### Error: Cannot find module './db.config.js' -**Solution:** You need to create the `db.config.js` file: +**Solution:** Create the `db.config.js` file: ```bash cp db.config.example.js db.config.js -# Then edit with your credentials +# Then edit with your Supabase credentials ``` -### Error: Access denied for user +### Error: Invalid API key **Solution:** Check your credentials in `db.config.js`: -- Verify username -- Verify password -- Check host address -- Ensure user has proper permissions +- Verify `supabaseUrl` is correct +- Verify `supabaseAnonKey` (should start with `eyJ...`) +- Get credentials from Supabase dashboard → Project Settings → API -### Connection timeout +### Error: Table 'players' does not exist **Solution:** -- Check if MySQL server is running -- Verify firewall allows connection -- Check host address is correct +- Run the SQL schema in Supabase SQL Editor +- See SUPABASE_SETUP.md Step 4 for the complete schema ## Important Notes ⚠️ **NEVER commit `db.config.js` to git** -⚠️ **Keep production credentials secure** -⚠️ **Use different credentials for dev/prod** -⚠️ **Regularly rotate passwords** +⚠️ **Keep credentials secure** +⚠️ **Use different projects for dev/prod** +⚠️ **The anon key is safe for client-side use** (protected by RLS) ## File Structure ``` Connect-5/ -├── db.config.example.js ← Template (in git) -├── db.config.js ← Your credentials (NOT in git) -├── .gitignore ← Protects db.config.js -├── database.js ← Imports from db.config.js -└── README_DB_CONFIG.md ← This file +├── db.config.example.js ← Template (in git) +├── db.config.js ← Your credentials (NOT in git) +├── .gitignore ← Protects db.config.js +├── database.js ← Imports from db.config.js +├── supabase-functions.sql ← Helper functions for Supabase +├── SUPABASE_SETUP.md ← Detailed setup guide +└── README_DB_CONFIG.md ← This file ``` diff --git a/SUPABASE_SETUP.md b/SUPABASE_SETUP.md new file mode 100644 index 0000000..a92edd7 --- /dev/null +++ b/SUPABASE_SETUP.md @@ -0,0 +1,223 @@ +# Supabase Setup Guide for Connect-5 + +This guide will help you set up Supabase for the Connect-5 multiplayer game. + +## Step 1: Create Supabase Project + +1. Go to [https://app.supabase.com](https://app.supabase.com) +2. Sign in or create an account +3. Click "New Project" +4. Fill in the project details: + - **Organization**: Select or create your organization (e.g., "DeNNiiInc's Org") + - **Project name**: `Connect5` + - **Database password**: `t1hWsackxbYzRIPD` (or your chosen password) + - **Region**: Oceania (Sydney) - or closest to your users + - **Pricing Plan**: Free tier is sufficient for development +5. Click "Create new project" +6. Wait for the project to be provisioned (takes 1-2 minutes) + +## Step 2: Get Your Credentials + +Once your project is ready: + +1. Go to **Project Settings** (gear icon in sidebar) +2. Navigate to **API** section +3. Copy the following values: + - **Project URL** (e.g., `https://xxxxxxxxxxxxx.supabase.co`) + - **anon/public key** (long JWT token starting with `eyJ...`) + +## Step 3: Configure Your Application + +1. Open `db.config.js` in your project +2. Replace the placeholder values: + +```javascript +module.exports = { + supabaseUrl: 'https://YOUR_PROJECT_ID.supabase.co', // Paste your Project URL here + supabaseAnonKey: 'YOUR_ANON_KEY_HERE', // Paste your anon key here + supabasePassword: 't1hWsackxbYzRIPD', // Your database password + + // Optional: Direct PostgreSQL connection + postgresConnectionString: 'postgresql://postgres:t1hWsackxbYzRIPD@db.YOUR_PROJECT_ID.supabase.co:5432/postgres' +}; +``` + +## Step 4: Create Database Tables + +1. In your Supabase dashboard, click on **SQL Editor** in the sidebar +2. Click **New Query** +3. Copy and paste the following SQL: + +```sql +-- Create players table +CREATE TABLE IF NOT EXISTS players ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + total_wins INT DEFAULT 0, + total_losses INT DEFAULT 0, + total_draws INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_username ON players(username); + +-- Create active sessions table +CREATE TABLE IF NOT EXISTS active_sessions ( + session_id VARCHAR(100) PRIMARY KEY, + player_id BIGINT NOT NULL, + username VARCHAR(50) NOT NULL, + connected_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE +); + +-- Create game state enum type +DO $$ BEGIN + CREATE TYPE game_state_enum AS ENUM ('pending', 'active', 'completed', 'abandoned'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create games table +CREATE TABLE IF NOT EXISTS games ( + id BIGSERIAL PRIMARY KEY, + player1_id BIGINT NOT NULL, + player2_id BIGINT NOT NULL, + player1_username VARCHAR(50) NOT NULL, + player2_username VARCHAR(50) NOT NULL, + board_size INT DEFAULT 15, + winner_id BIGINT, + game_state game_state_enum DEFAULT 'pending', + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + FOREIGN KEY (player1_id) REFERENCES players(id), + FOREIGN KEY (player2_id) REFERENCES players(id), + FOREIGN KEY (winner_id) REFERENCES players(id) +); + +-- Create game moves table +CREATE TABLE IF NOT EXISTS game_moves ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL, + player_id BIGINT NOT NULL, + row_position INT NOT NULL, + col_position INT NOT NULL, + move_number INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, + FOREIGN KEY (player_id) REFERENCES players(id) +); +CREATE INDEX IF NOT EXISTS idx_game ON game_moves(game_id); + +-- Enable Row Level Security (RLS) +ALTER TABLE players ENABLE ROW LEVEL SECURITY; +ALTER TABLE active_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE games ENABLE ROW LEVEL SECURITY; +ALTER TABLE game_moves ENABLE ROW LEVEL SECURITY; + +-- Create policies to allow all operations (adjust based on your security needs) +CREATE POLICY "Allow all operations on players" ON players FOR ALL USING (true); +CREATE POLICY "Allow all operations on active_sessions" ON active_sessions FOR ALL USING (true); +CREATE POLICY "Allow all operations on games" ON games FOR ALL USING (true); +CREATE POLICY "Allow all operations on game_moves" ON game_moves FOR ALL USING (true); +``` + +4. Click **Run** or press `Ctrl+Enter` +5. You should see "Success. No rows returned" message + +## Step 5: Create Helper Functions + +1. In the same SQL Editor, create a new query +2. Copy and paste the contents of `supabase-functions.sql`: + +```sql +-- Function to increment wins +CREATE OR REPLACE FUNCTION increment_wins(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_wins = total_wins + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Function to increment losses +CREATE OR REPLACE FUNCTION increment_losses(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_losses = total_losses + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Function to increment draws +CREATE OR REPLACE FUNCTION increment_draws(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_draws = total_draws + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; +``` + +3. Click **Run** + +## Step 6: Test Your Connection + +1. Install dependencies: + ```bash + npm install + ``` + +2. Start the server: + ```bash + npm start + ``` + +3. Check the console output for: + - ✅ Database schema verified successfully + - 🗄️ Database connected + +4. Open your browser to `http://localhost:3000` +5. Check the bottom status bar - it should show: + - **SQL**: Connected (green) + - **Latency**: Should be reasonable (depends on your location to Sydney) + - **Write**: Enabled (green) + +## Troubleshooting + +### "Invalid API key" Error +- Double-check your `supabaseAnonKey` in `db.config.js` +- Make sure you copied the **anon/public** key, not the service_role key + +### "Cannot reach Supabase" Error +- Verify your `supabaseUrl` is correct +- Check your internet connection +- Ensure no firewall is blocking Supabase + +### "Table 'players' does not exist" Error +- Make sure you ran the SQL schema in Step 4 +- Check the SQL Editor for any error messages +- Verify all tables were created in the **Table Editor** + +### High Latency +- This is normal if you're far from the Sydney region +- Consider changing the region when creating your project +- Latency doesn't significantly affect gameplay for turn-based games + +## Security Notes + +- The `db.config.js` file is in `.gitignore` and will NOT be committed to Git +- Never share your database password or anon key publicly +- The anon key is safe to use in client-side code (it's protected by RLS policies) +- For production, consider implementing more restrictive RLS policies + +## Next Steps + +Once connected, you can: +- Test multiplayer functionality +- Monitor your database in the Supabase dashboard +- View real-time data in the **Table Editor** +- Check logs in the **Logs** section +- Set up database backups (available in paid plans) diff --git a/database.js b/database.js index 83ad640..e305e7f 100644 --- a/database.js +++ b/database.js @@ -1,82 +1,110 @@ -const mysql = require('mysql2/promise'); +const { createClient } = require('@supabase/supabase-js'); // Import database configuration from external file // This file (db.config.js) is not committed to git for security // Use db.config.example.js as a template const dbConfig = require('./db.config.js'); -// Create connection pool -const pool = mysql.createPool(dbConfig); +// Create Supabase client +const supabase = createClient(dbConfig.supabaseUrl, dbConfig.supabaseAnonKey); // Initialize database schema async function initializeDatabase() { try { - const connection = await pool.getConnection(); + console.log('🔄 Initializing Supabase database schema...'); // Create players table - await connection.query(` - CREATE TABLE IF NOT EXISTS players ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - total_wins INT DEFAULT 0, - total_losses INT DEFAULT 0, - total_draws INT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_username (username) - ) - `); + const { error: playersError } = await supabase.rpc('create_players_table', {}); - // Create active sessions table - await connection.query(` - CREATE TABLE IF NOT EXISTS active_sessions ( - session_id VARCHAR(100) PRIMARY KEY, - player_id INT NOT NULL, - username VARCHAR(50) NOT NULL, - connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE - ) - `); + // Since we can't run raw SQL directly with the JS client in the same way, + // we'll use Supabase's SQL editor or migrations + // For now, we'll check if tables exist by trying to query them - // Create games table - await connection.query(` - CREATE TABLE IF NOT EXISTS games ( - id INT AUTO_INCREMENT PRIMARY KEY, - player1_id INT NOT NULL, - player2_id INT NOT NULL, - player1_username VARCHAR(50) NOT NULL, - player2_username VARCHAR(50) NOT NULL, - board_size INT DEFAULT 15, - winner_id INT, - game_state ENUM('pending', 'active', 'completed', 'abandoned') DEFAULT 'pending', - started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP NULL, - FOREIGN KEY (player1_id) REFERENCES players(id), - FOREIGN KEY (player2_id) REFERENCES players(id), - FOREIGN KEY (winner_id) REFERENCES players(id) - ) - `); + const { data: playersCheck, error: playersCheckError } = await supabase + .from('players') + .select('id') + .limit(1); - // Create game moves table - await connection.query(` - CREATE TABLE IF NOT EXISTS game_moves ( - id INT AUTO_INCREMENT PRIMARY KEY, - game_id INT NOT NULL, - player_id INT NOT NULL, - row_position INT NOT NULL, - col_position INT NOT NULL, - move_number INT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, - FOREIGN KEY (player_id) REFERENCES players(id), - INDEX idx_game (game_id) - ) - `); + if (playersCheckError && playersCheckError.code === '42P01') { + console.log('⚠️ Tables not found. Please run the following SQL in your Supabase SQL Editor:'); + console.log(` +-- Create players table +CREATE TABLE IF NOT EXISTS players ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + total_wins INT DEFAULT 0, + total_losses INT DEFAULT 0, + total_draws INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_username ON players(username); + +-- Create active sessions table +CREATE TABLE IF NOT EXISTS active_sessions ( + session_id VARCHAR(100) PRIMARY KEY, + player_id BIGINT NOT NULL, + username VARCHAR(50) NOT NULL, + connected_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE +); + +-- Create game state enum type +DO $$ BEGIN + CREATE TYPE game_state_enum AS ENUM ('pending', 'active', 'completed', 'abandoned'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create games table +CREATE TABLE IF NOT EXISTS games ( + id BIGSERIAL PRIMARY KEY, + player1_id BIGINT NOT NULL, + player2_id BIGINT NOT NULL, + player1_username VARCHAR(50) NOT NULL, + player2_username VARCHAR(50) NOT NULL, + board_size INT DEFAULT 15, + winner_id BIGINT, + game_state game_state_enum DEFAULT 'pending', + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + FOREIGN KEY (player1_id) REFERENCES players(id), + FOREIGN KEY (player2_id) REFERENCES players(id), + FOREIGN KEY (winner_id) REFERENCES players(id) +); + +-- Create game moves table +CREATE TABLE IF NOT EXISTS game_moves ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL, + player_id BIGINT NOT NULL, + row_position INT NOT NULL, + col_position INT NOT NULL, + move_number INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, + FOREIGN KEY (player_id) REFERENCES players(id) +); +CREATE INDEX IF NOT EXISTS idx_game ON game_moves(game_id); + +-- Enable Row Level Security (RLS) - Optional but recommended +ALTER TABLE players ENABLE ROW LEVEL SECURITY; +ALTER TABLE active_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE games ENABLE ROW LEVEL SECURITY; +ALTER TABLE game_moves ENABLE ROW LEVEL SECURITY; + +-- Create policies to allow all operations (adjust based on your security needs) +CREATE POLICY "Allow all operations on players" ON players FOR ALL USING (true); +CREATE POLICY "Allow all operations on active_sessions" ON active_sessions FOR ALL USING (true); +CREATE POLICY "Allow all operations on games" ON games FOR ALL USING (true); +CREATE POLICY "Allow all operations on game_moves" ON game_moves FOR ALL USING (true); + `); + throw new Error('Database tables not initialized. Please run the SQL above in Supabase SQL Editor.'); + } - connection.release(); - console.log('✅ Database schema initialized successfully'); + console.log('✅ Database schema verified successfully'); } catch (error) { - console.error('❌ Error initializing database:', error); + console.error('❌ Error initializing database:', error.message); throw error; } } @@ -86,11 +114,26 @@ const db = { // Create or get player async createPlayer(username) { try { - const [result] = await pool.query( - 'INSERT INTO players (username) VALUES (?) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)', - [username] - ); - return result.insertId; + // First try to get existing player + const { data: existingPlayer, error: selectError } = await supabase + .from('players') + .select('id') + .eq('username', username) + .single(); + + if (existingPlayer) { + return existingPlayer.id; + } + + // If not found, create new player + const { data, error } = await supabase + .from('players') + .insert([{ username }]) + .select('id') + .single(); + + if (error) throw error; + return data.id; } catch (error) { console.error('Error creating player:', error); throw error; @@ -99,128 +142,217 @@ const db = { // Get player by username async getPlayer(username) { - const [rows] = await pool.query( - 'SELECT * FROM players WHERE username = ?', - [username] - ); - return rows[0]; + const { data, error } = await supabase + .from('players') + .select('*') + .eq('username', username) + .single(); + + if (error && error.code !== 'PGRST116') throw error; // PGRST116 = not found + return data; }, // Get player by ID async getPlayerById(playerId) { - const [rows] = await pool.query( - 'SELECT * FROM players WHERE id = ?', - [playerId] - ); - return rows[0]; + const { data, error } = await supabase + .from('players') + .select('*') + .eq('id', playerId) + .single(); + + if (error && error.code !== 'PGRST116') throw error; + return data; }, // Add active session async addSession(sessionId, playerId, username) { - await pool.query( - 'INSERT INTO active_sessions (session_id, player_id, username) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE last_heartbeat = CURRENT_TIMESTAMP', - [sessionId, playerId, username] - ); + const { error } = await supabase + .from('active_sessions') + .upsert([{ + session_id: sessionId, + player_id: playerId, + username: username, + last_heartbeat: new Date().toISOString() + }], { + onConflict: 'session_id' + }); + + if (error) throw error; }, // Remove session async removeSession(sessionId) { - await pool.query( - 'DELETE FROM active_sessions WHERE session_id = ?', - [sessionId] - ); + const { error } = await supabase + .from('active_sessions') + .delete() + .eq('session_id', sessionId); + + if (error) throw error; }, // Get all active players async getActivePlayers() { - const [rows] = await pool.query(` - SELECT s.session_id, s.username, p.total_wins, p.total_losses, p.total_draws - FROM active_sessions s - JOIN players p ON s.player_id = p.id - WHERE s.last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE) - `); - return rows; + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString(); + + const { data, error } = await supabase + .from('active_sessions') + .select(` + session_id, + username, + players!inner(total_wins, total_losses, total_draws) + `) + .gt('last_heartbeat', twoMinutesAgo); + + if (error) throw error; + + // Flatten the response to match the old format + return data.map(row => ({ + session_id: row.session_id, + username: row.username, + total_wins: row.players.total_wins, + total_losses: row.players.total_losses, + total_draws: row.players.total_draws + })); }, // Create new game async createGame(player1Id, player2Id, player1Username, player2Username, boardSize) { - const [result] = await pool.query( - 'INSERT INTO games (player1_id, player2_id, player1_username, player2_username, board_size, game_state) VALUES (?, ?, ?, ?, ?, ?)', - [player1Id, player2Id, player1Username, player2Username, boardSize, 'active'] - ); - return result.insertId; + const { data, error } = await supabase + .from('games') + .insert([{ + player1_id: player1Id, + player2_id: player2Id, + player1_username: player1Username, + player2_username: player2Username, + board_size: boardSize, + game_state: 'active' + }]) + .select('id') + .single(); + + if (error) throw error; + return data.id; }, // Record move async recordMove(gameId, playerId, row, col, moveNumber) { - await pool.query( - 'INSERT INTO game_moves (game_id, player_id, row_position, col_position, move_number) VALUES (?, ?, ?, ?, ?)', - [gameId, playerId, row, col, moveNumber] - ); + const { error } = await supabase + .from('game_moves') + .insert([{ + game_id: gameId, + player_id: playerId, + row_position: row, + col_position: col, + move_number: moveNumber + }]); + + if (error) throw error; }, // Complete game async completeGame(gameId, winnerId) { - await pool.query( - 'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?', - ['completed', winnerId, gameId] - ); + // Update game status + const { error: gameError } = await supabase + .from('games') + .update({ + game_state: 'completed', + winner_id: winnerId, + completed_at: new Date().toISOString() + }) + .eq('id', gameId); + + if (gameError) throw gameError; // Update player stats if (winnerId) { // Get game details - const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); - if (game.length > 0) { - const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id; + const { data: game, error: selectError } = await supabase + .from('games') + .select('player1_id, player2_id') + .eq('id', gameId) + .single(); + + if (selectError) throw selectError; + + if (game) { + const loserId = game.player1_id === winnerId ? game.player2_id : game.player1_id; // Update winner - await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]); + await supabase.rpc('increment_wins', { player_id: winnerId }); // Update loser - await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]); + await supabase.rpc('increment_losses', { player_id: loserId }); } } else { // Draw - update both players - const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); - if (game.length > 0) { - await pool.query('UPDATE players SET total_draws = total_draws + 1 WHERE id IN (?, ?)', - [game[0].player1_id, game[0].player2_id]); + const { data: game, error: selectError } = await supabase + .from('games') + .select('player1_id, player2_id') + .eq('id', gameId) + .single(); + + if (selectError) throw selectError; + + if (game) { + await supabase.rpc('increment_draws', { player_id: game.player1_id }); + await supabase.rpc('increment_draws', { player_id: game.player2_id }); } } }, // Abandon game async abandonGame(gameId, winnerId) { - await pool.query( - 'UPDATE games SET game_state = ?, winner_id = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?', - ['abandoned', winnerId, gameId] - ); + // Update game status + const { error: gameError } = await supabase + .from('games') + .update({ + game_state: 'abandoned', + winner_id: winnerId, + completed_at: new Date().toISOString() + }) + .eq('id', gameId); + + if (gameError) throw gameError; // Update stats (winner gets win, other player gets loss) if (winnerId) { - const [game] = await pool.query('SELECT player1_id, player2_id FROM games WHERE id = ?', [gameId]); - if (game.length > 0) { - const loserId = game[0].player1_id === winnerId ? game[0].player2_id : game[0].player1_id; - await pool.query('UPDATE players SET total_wins = total_wins + 1 WHERE id = ?', [winnerId]); - await pool.query('UPDATE players SET total_losses = total_losses + 1 WHERE id = ?', [loserId]); + const { data: game, error: selectError } = await supabase + .from('games') + .select('player1_id, player2_id') + .eq('id', gameId) + .single(); + + if (selectError) throw selectError; + + if (game) { + const loserId = game.player1_id === winnerId ? game.player2_id : game.player1_id; + await supabase.rpc('increment_wins', { player_id: winnerId }); + await supabase.rpc('increment_losses', { player_id: loserId }); } } }, // Update heartbeat async updateHeartbeat(sessionId) { - await pool.query( - 'UPDATE active_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_id = ?', - [sessionId] - ); + const { error } = await supabase + .from('active_sessions') + .update({ last_heartbeat: new Date().toISOString() }) + .eq('session_id', sessionId); + + if (error) throw error; }, // Clean up stale sessions async cleanupStaleSessions() { - await pool.query( - 'DELETE FROM active_sessions WHERE last_heartbeat < DATE_SUB(NOW(), INTERVAL 2 MINUTE)' - ); + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString(); + + const { error } = await supabase + .from('active_sessions') + .delete() + .lt('last_heartbeat', twoMinutesAgo); + + if (error) throw error; } }; -module.exports = { pool, initializeDatabase, db }; +module.exports = { supabase, initializeDatabase, db }; diff --git a/db.config.example.js b/db.config.example.js index 2c49f4a..a859589 100644 --- a/db.config.example.js +++ b/db.config.example.js @@ -1,13 +1,18 @@ -// Database Configuration File -// IMPORTANT: This file contains sensitive credentials and should NEVER be committed to git -// Copy this file to db.config.js and update with your actual database credentials +// Database Configuration File - EXAMPLE +// Copy this file to db.config.js and fill in your actual Supabase credentials +// DO NOT commit db.config.js to git - it's in .gitignore +// Supabase Configuration +// Get these values from your Supabase project dashboard: +// 1. Go to https://app.supabase.com +// 2. Select your project +// 3. Go to Project Settings → API module.exports = { - host: 'your-database-host.com', - user: 'your-database-username', - password: 'your-secure-password', - database: 'your-database-name', - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 + supabaseUrl: 'https://xxxxxxxxxxxxx.supabase.co', // Your Supabase project URL + supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', // Your Supabase anon/public key + supabasePassword: 'your_database_password_here', // Your database password + + // Optional: Direct PostgreSQL connection string + // Found in Project Settings → Database → Connection String + postgresConnectionString: 'postgresql://postgres:your_password@db.xxxxxxxxxxxxx.supabase.co:5432/postgres' }; diff --git a/package-lock.json b/package-lock.json index 3ac2e2c..a53436c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@supabase/supabase-js": "^2.39.0", "bad-words": "^3.0.4", "cors": "^2.8.5", "express": "^4.18.2", @@ -25,6 +26,107 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.89.0.tgz", + "integrity": "sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.89.0.tgz", + "integrity": "sha512-XEueaC5gMe5NufNYfBh9kPwJlP5M2f+Ogr8rvhmRDAZNHgY6mI35RCkYDijd92pMcNM7g8pUUJov93UGUnqfyw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.89.0.tgz", + "integrity": "sha512-/b0fKrxV9i7RNOEXMno/I1862RsYhuUo+Q6m6z3ar1f4ulTMXnDfv0y4YYxK2POcgrOXQOgKYQx1eArybyNvtg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.89.0.tgz", + "integrity": "sha512-aMOvfDb2a52u6PX6jrrjvACHXGV3zsOlWRzZsTIOAJa0hOVvRp01AwC1+nLTGUzxzezejrYeCX+KnnM1xHdl+w==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.89.0.tgz", + "integrity": "sha512-6zKcXofk/M/4Eato7iqpRh+B+vnxeiTumCIP+Tz26xEqIiywzD9JxHq+udRrDuv6hXE+pmetvJd8n5wcf4MFRQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.89.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.89.0.tgz", + "integrity": "sha512-KlaRwSfFA0fD73PYVMHj5/iXFtQGCcX7PSx0FdQwYEEw9b2wqM7GxadY+5YwcmuEhalmjFB/YvqaoNVF+sWUlg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.89.0", + "@supabase/functions-js": "2.89.0", + "@supabase/postgrest-js": "2.89.0", + "@supabase/realtime-js": "2.89.0", + "@supabase/storage-js": "2.89.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -43,6 +145,21 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -706,6 +823,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1597,6 +1723,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index bfd9ce3..2a247c1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "express": "^4.18.2", "socket.io": "^4.6.1", "mysql2": "^3.6.5", + "@supabase/supabase-js": "^2.39.0", "bad-words": "^3.0.4", "cors": "^2.8.5" }, diff --git a/server.js b/server.js index 90ce617..d6073a4 100644 --- a/server.js +++ b/server.js @@ -3,7 +3,7 @@ const http = require('http'); const socketIO = require('socket.io'); const cors = require('cors'); const path = require('path'); -const { initializeDatabase, db } = require('./database'); +const { initializeDatabase, db, supabase } = require('./database'); const GameManager = require('./gameManager'); const app = express(); @@ -39,96 +39,74 @@ app.get('/api/db-status', async (req, res) => { timestamp: new Date().toISOString(), error: null, // Additional diagnostic info for testing phase - host: dbConfig.host || 'unknown', - database: dbConfig.database || 'unknown', - user: dbConfig.user || 'unknown', - connectionLimit: dbConfig.connectionLimit || 'unknown', - poolStats: null + supabaseUrl: dbConfig.supabaseUrl || 'unknown', + database: 'Supabase PostgreSQL', + connectionType: 'Supabase Client' }; try { - console.log(`[DB-STATUS] Testing connection to ${status.host}/${status.database}...`); + console.log(`[DB-STATUS] Testing Supabase connection to ${status.supabaseUrl}...`); // Test connection with a simple query - const [result] = await db.pool.query('SELECT 1 as test'); + const { data, error } = await supabase + .from('players') + .select('id') + .limit(1); + const latency = Date.now() - startTime; - if (result && result[0].test === 1) { + if (!error || error.code === 'PGRST116') { // PGRST116 = no rows found (table exists but empty) status.connected = true; status.latency = latency; console.log(`[DB-STATUS] ✅ Connection successful (${latency}ms)`); - // Get pool statistics - try { - status.poolStats = { - totalConnections: db.pool.pool._allConnections.length, - freeConnections: db.pool.pool._freeConnections.length, - queuedRequests: db.pool.pool._connectionQueue.length - }; - console.log(`[DB-STATUS] Pool stats:`, status.poolStats); - } catch (poolError) { - console.log(`[DB-STATUS] Could not retrieve pool stats:`, poolError.message); - } - // Test write capability try { console.log(`[DB-STATUS] Testing write capability...`); - const testTableName = '_health_check_test'; - - // Create test table if it doesn't exist - await db.pool.query(` - CREATE TABLE IF NOT EXISTS ${testTableName} ( - id INT AUTO_INCREMENT PRIMARY KEY, - test_value VARCHAR(50), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); // Try to insert a test record - const testValue = `test_${Date.now()}`; - await db.pool.query( - `INSERT INTO ${testTableName} (test_value) VALUES (?)`, - [testValue] - ); + const testUsername = `_test_${Date.now()}`; + const { data: insertData, error: insertError } = await supabase + .from('players') + .insert([{ username: testUsername }]) + .select('id') + .single(); - // Clean up old test records (keep only last 10) - await db.pool.query(` - DELETE FROM ${testTableName} - WHERE id NOT IN ( - SELECT id FROM ( - SELECT id FROM ${testTableName} - ORDER BY created_at DESC - LIMIT 10 - ) AS keep_records - ) - `); - - status.writeCapable = true; - console.log(`[DB-STATUS] ✅ Write test successful`); + if (!insertError && insertData) { + // Clean up test record + await supabase + .from('players') + .delete() + .eq('id', insertData.id); + + status.writeCapable = true; + console.log(`[DB-STATUS] ✅ Write test successful`); + } else { + throw insertError; + } } catch (writeError) { console.error(`[DB-STATUS] ❌ Write test failed:`, writeError.message); status.writeCapable = false; status.error = `Write test failed: ${writeError.message}`; } + } else { + throw error; } } catch (error) { console.error(`[DB-STATUS] ❌ Connection failed:`, error.message); - console.error(`[DB-STATUS] Error code:`, error.code); - console.error(`[DB-STATUS] Error errno:`, error.errno); + console.error(`[DB-STATUS] Error details:`, error); status.connected = false; status.latency = Date.now() - startTime; // Provide more detailed error messages let errorMessage = error.message; - if (error.code === 'ECONNREFUSED') { - errorMessage = `Connection refused to ${status.host}. Is MySQL running?`; - } else if (error.code === 'ER_ACCESS_DENIED_ERROR') { - errorMessage = `Access denied for user '${status.user}'. Check credentials.`; - } else if (error.code === 'ER_BAD_DB_ERROR') { - errorMessage = `Database '${status.database}' does not exist.`; - } else if (error.code === 'ENOTFOUND') { - errorMessage = `Host '${status.host}' not found. Check hostname.`; + if (error.code === '42P01') { + errorMessage = `Table 'players' does not exist. Please run the SQL schema in Supabase SQL Editor.`; + } else if (error.message && error.message.includes('Invalid API key')) { + errorMessage = `Invalid Supabase API key. Check your db.config.js file.`; + } else if (error.message && error.message.includes('fetch')) { + errorMessage = `Cannot reach Supabase. Check your supabaseUrl in db.config.js.`; } status.error = errorMessage; diff --git a/supabase-functions.sql b/supabase-functions.sql new file mode 100644 index 0000000..2da1896 --- /dev/null +++ b/supabase-functions.sql @@ -0,0 +1,50 @@ +-- Supabase SQL Helper Functions for Connect-5 +-- Run this in your Supabase SQL Editor after creating the tables + +-- Function to increment wins +CREATE OR REPLACE FUNCTION increment_wins(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_wins = total_wins + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Function to increment losses +CREATE OR REPLACE FUNCTION increment_losses(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_losses = total_losses + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Function to increment draws +CREATE OR REPLACE FUNCTION increment_draws(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_draws = total_draws + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Optional: Function to get player stats +CREATE OR REPLACE FUNCTION get_player_stats(player_username VARCHAR) +RETURNS TABLE ( + id BIGINT, + username VARCHAR, + total_wins INT, + total_losses INT, + total_draws INT, + created_at TIMESTAMP WITH TIME ZONE +) AS $$ +BEGIN + RETURN QUERY + SELECT p.id, p.username, p.total_wins, p.total_losses, p.total_draws, p.created_at + FROM players p + WHERE p.username = player_username; +END; +$$ LANGUAGE plpgsql; diff --git a/supabase-schema-complete.sql b/supabase-schema-complete.sql new file mode 100644 index 0000000..99e12ec --- /dev/null +++ b/supabase-schema-complete.sql @@ -0,0 +1,114 @@ +-- Complete Supabase Schema for Connect-5 +-- Copy and paste this entire file into Supabase SQL Editor and run it + +-- Create players table +CREATE TABLE IF NOT EXISTS players ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + total_wins INT DEFAULT 0, + total_losses INT DEFAULT 0, + total_draws INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_username ON players(username); + +-- Create active sessions table +CREATE TABLE IF NOT EXISTS active_sessions ( + session_id VARCHAR(100) PRIMARY KEY, + player_id BIGINT NOT NULL, + username VARCHAR(50) NOT NULL, + connected_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE +); + +-- Create game state enum type +DO $$ BEGIN + CREATE TYPE game_state_enum AS ENUM ('pending', 'active', 'completed', 'abandoned'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create games table +CREATE TABLE IF NOT EXISTS games ( + id BIGSERIAL PRIMARY KEY, + player1_id BIGINT NOT NULL, + player2_id BIGINT NOT NULL, + player1_username VARCHAR(50) NOT NULL, + player2_username VARCHAR(50) NOT NULL, + board_size INT DEFAULT 15, + winner_id BIGINT, + game_state game_state_enum DEFAULT 'pending', + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE, + FOREIGN KEY (player1_id) REFERENCES players(id), + FOREIGN KEY (player2_id) REFERENCES players(id), + FOREIGN KEY (winner_id) REFERENCES players(id) +); + +-- Create game moves table +CREATE TABLE IF NOT EXISTS game_moves ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL, + player_id BIGINT NOT NULL, + row_position INT NOT NULL, + col_position INT NOT NULL, + move_number INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, + FOREIGN KEY (player_id) REFERENCES players(id) +); +CREATE INDEX IF NOT EXISTS idx_game ON game_moves(game_id); + +-- Enable Row Level Security (RLS) +ALTER TABLE players ENABLE ROW LEVEL SECURITY; +ALTER TABLE active_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE games ENABLE ROW LEVEL SECURITY; +ALTER TABLE game_moves ENABLE ROW LEVEL SECURITY; + +-- Create policies to allow all operations (adjust based on your security needs) +DROP POLICY IF EXISTS "Allow all operations on players" ON players; +CREATE POLICY "Allow all operations on players" ON players FOR ALL USING (true); + +DROP POLICY IF EXISTS "Allow all operations on active_sessions" ON active_sessions; +CREATE POLICY "Allow all operations on active_sessions" ON active_sessions FOR ALL USING (true); + +DROP POLICY IF EXISTS "Allow all operations on games" ON games; +CREATE POLICY "Allow all operations on games" ON games FOR ALL USING (true); + +DROP POLICY IF EXISTS "Allow all operations on game_moves" ON game_moves; +CREATE POLICY "Allow all operations on game_moves" ON game_moves FOR ALL USING (true); + +-- Helper Functions +CREATE OR REPLACE FUNCTION increment_wins(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_wins = total_wins + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION increment_losses(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_losses = total_losses + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION increment_draws(player_id BIGINT) +RETURNS void AS $$ +BEGIN + UPDATE players + SET total_draws = total_draws + 1 + WHERE id = player_id; +END; +$$ LANGUAGE plpgsql; + +-- Success message +DO $$ +BEGIN + RAISE NOTICE '✅ Connect-5 database schema created successfully!'; +END $$;