refactor: Migrate database from SQLite to MariaDB

Database Migration:
- Add MariaDB connection configuration and initialization (mariadb.js)
- Create MariaDB schema update script (mariadb-schema-update.sql)
- Add migration scripts for SQLite to MariaDB transition:
  * migrate-sqlite-to-mariadb.js: Main migration script
  * migrate-inventory-to-gear.js: Inventory schema migration
- Remove SQLite-specific implementation files and databases

Route Updates:
- Update all route handlers to use MariaDB instead of SQLite
- Migrate routes: playerRoutes, sessionRoutes, shopRoutes, bestiaryRoutes, rulesRoutes, rulesStagingRoutes, weaponsRoutes
- Remove SQLite-specific route files (playerRoutes-sqlite.js, sessionRoutes-sqlite.js)
- Update server.js to initialize MariaDB and register new weapon routes

Backend Scripts:
- Remove old SQLite migration scripts (migrate-to-sqlite.js, server-sqlite.js)
- Delete obsolete database utility scripts from backup-scripts/

Frontend Updates:
- Update logger utility for improved error handling and debugging
- Enhance PlayerManagement component with better state management
- Improve RequisitionShop component for MariaDB integration
- Update DeathwatchRoller with performance improvements
- Add login test suite (login.test.js)
- Add XP progression utility (xpProgression.js)
- Update dependencies in package.json and package-lock.json

This migration improves:
- Database scalability and performance
- Transaction support for complex operations
- Better data integrity and ACID compliance
- Simplified deployment and backup procedures
This commit is contained in:
2025-12-11 19:20:44 +01:00
parent 0aa3fd0c30
commit d84ca39e61
50 changed files with 2250 additions and 3375 deletions

View File

@@ -0,0 +1,96 @@
-- Add missing tables to MariaDB schema
-- Shop items table
CREATE TABLE IF NOT EXISTS shop_items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL,
requisition_cost INT NOT NULL DEFAULT 0,
renown_requirement VARCHAR(50) NOT NULL DEFAULT 'None',
item_type VARCHAR(100) NOT NULL,
stats TEXT NOT NULL,
source VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Player inventory table
CREATE TABLE IF NOT EXISTS player_inventory (
id INT AUTO_INCREMENT PRIMARY KEY,
player_id INT NOT NULL,
item_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
transaction_details TEXT,
FOREIGN KEY (player_id) REFERENCES players (id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES shop_items (id) ON DELETE CASCADE,
UNIQUE(player_id, item_id)
);
-- Transactions table
CREATE TABLE IF NOT EXISTS transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
player_id INT NOT NULL,
item_id INT NOT NULL,
requisition_cost INT NOT NULL,
previous_rp INT NOT NULL,
new_rp INT NOT NULL,
transaction_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES players (id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES shop_items (id) ON DELETE CASCADE
);
-- Armour table
CREATE TABLE IF NOT EXISTS armour (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
req INT DEFAULT 0,
renown VARCHAR(50) DEFAULT 'None',
category VARCHAR(100),
stats TEXT,
source VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Weapons table
CREATE TABLE IF NOT EXISTS weapons (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
req INT DEFAULT 0,
renown VARCHAR(50) DEFAULT 'None',
category VARCHAR(100),
stats TEXT,
source VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bestiary table
CREATE TABLE IF NOT EXISTS bestiary (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
book VARCHAR(255),
page VARCHAR(50),
pdf VARCHAR(255),
stats TEXT,
profile TEXT,
snippet TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Rules table
CREATE TABLE IF NOT EXISTS rules (
id INT AUTO_INCREMENT PRIMARY KEY,
rule_id VARCHAR(255) UNIQUE,
title VARCHAR(500),
content TEXT,
page INT,
source VARCHAR(255),
source_abbr VARCHAR(50),
category VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Add missing columns to players table
ALTER TABLE players
ADD COLUMN IF NOT EXISTS requisition_points INT DEFAULT 0,
ADD COLUMN IF NOT EXISTS renown_level VARCHAR(50) DEFAULT 'None';

408
database/mariadb.js Normal file
View File

@@ -0,0 +1,408 @@
const mysql = require('mysql2/promise');
const fs = require('fs');
const path = require('path');
// Simple DB logger to backend.log
const backendLogPath = path.join(__dirname, 'backend.log');
function logToFile(...args) {
try {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(backendLogPath, msg, { encoding: 'utf8' });
} catch (err) {
console.error('Failed to write backend log', err);
}
}
// Database configuration
const dbConfig = {
host: 'localhost',
user: 'deathwatch',
password: 'dwroller2025',
database: 'deathwatch',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
};
// Create connection pool
const pool = mysql.createPool(dbConfig);
// Create tables
const createTables = async () => {
try {
const connection = await pool.getConnection();
// Players table
await connection.execute(`
CREATE TABLE IF NOT EXISTS players (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
roller_info JSON DEFAULT ('{}'),
shop_info JSON DEFAULT ('{}'),
tab_info JSON DEFAULT ('{}'),
pw VARCHAR(255) DEFAULT '',
pw_hash VARCHAR(255) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Sessions table
await connection.execute(`
CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(255) UNIQUE NOT NULL,
data JSON DEFAULT ('{}'),
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Rules staging table
await connection.execute(`
CREATE TABLE IF NOT EXISTS rules_staging (
id INT AUTO_INCREMENT PRIMARY KEY,
title TEXT,
content TEXT,
category VARCHAR(255),
page VARCHAR(255),
original_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
await connection.execute(`CREATE INDEX IF NOT EXISTS idx_rules_staging_category ON rules_staging(category)`);
connection.release();
console.log('MariaDB tables created successfully');
logToFile('MariaDB: Tables created successfully');
} catch (error) {
console.error('Error creating MariaDB tables:', error);
logToFile('MariaDB: Error creating tables', error);
throw error;
}
};
// Player helpers
const playerHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM players ORDER BY name');
return rows.map(row => ({
id: row.id,
name: row.name,
rollerInfo: typeof row.roller_info === 'string' ? JSON.parse(row.roller_info) : row.roller_info,
shopInfo: typeof row.shop_info === 'string' ? JSON.parse(row.shop_info) : row.shop_info,
tabInfo: typeof row.tab_info === 'string' ? JSON.parse(row.tab_info) : row.tab_info,
pw: row.pw || '',
pwHash: row.pw_hash || '',
requisitionPoints: row.requisition_points || 0,
renownLevel: row.renown_level || 'None',
createdAt: row.created_at,
updatedAt: row.updated_at,
_id: row.id
}));
} catch (error) {
logToFile('MariaDB: Error getting all players', error);
return [];
}
},
getByName: async (name) => {
try {
const [rows] = await pool.execute('SELECT * FROM players WHERE name = ?', [name]);
if (rows.length === 0) return null;
const row = rows[0];
return {
id: row.id,
name: row.name,
rollerInfo: typeof row.roller_info === 'string' ? JSON.parse(row.roller_info) : row.roller_info,
shopInfo: typeof row.shop_info === 'string' ? JSON.parse(row.shop_info) : row.shop_info,
tabInfo: typeof row.tab_info === 'string' ? JSON.parse(row.tab_info) : row.tab_info,
pw: row.pw || '',
pwHash: row.pw_hash || '',
requisitionPoints: row.requisition_points || 0,
renownLevel: row.renown_level || 'None',
createdAt: row.created_at,
updatedAt: row.updated_at,
_id: row.id
};
} catch (error) {
logToFile('MariaDB: Error getting player by name', name, error);
return null;
}
},
create: async (playerData) => {
try {
const { name, rollerInfo = {}, shopInfo = {}, tabInfo = {}, pw = '', pwHash = '' } = playerData;
const [result] = await pool.execute(
'INSERT INTO players (name, roller_info, shop_info, tab_info, pw, pw_hash) VALUES (?, ?, ?, ?, ?, ?)',
[name, JSON.stringify(rollerInfo), JSON.stringify(shopInfo), JSON.stringify(tabInfo), pw, pwHash]
);
logToFile('MariaDB: Created player', name);
return result.insertId;
} catch (error) {
logToFile('MariaDB: Error creating player', playerData.name, error);
return null;
}
},
update: async (name, playerData) => {
try {
const { rollerInfo, shopInfo, tabInfo, pw, pwHash } = playerData;
const [result] = await pool.execute(
'UPDATE players SET roller_info = ?, shop_info = ?, tab_info = ?, pw = ?, pw_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?',
[
JSON.stringify(rollerInfo || {}),
JSON.stringify(shopInfo || {}),
JSON.stringify(tabInfo || {}),
pw || '',
pwHash || '',
name
]
);
logToFile('MariaDB: Updated player', name);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error updating player', name, error);
return false;
}
},
delete: async (name) => {
try {
const [result] = await pool.execute('DELETE FROM players WHERE name = ?', [name]);
logToFile('MariaDB: Deleted player', name);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error deleting player', name, error);
return false;
}
}
};
// Session helpers
const sessionHelpers = {
create: async (sessionId, data = {}, expiresAt = null) => {
try {
const [result] = await pool.execute(
'INSERT INTO sessions (session_id, data, expires_at) VALUES (?, ?, ?)',
[sessionId, JSON.stringify(data), expiresAt]
);
return result.insertId;
} catch (error) {
logToFile('MariaDB: Error creating session', sessionId, error);
return null;
}
},
get: async (sessionId) => {
try {
const [rows] = await pool.execute('SELECT * FROM sessions WHERE session_id = ?', [sessionId]);
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data
};
} catch (error) {
logToFile('MariaDB: Error getting session', sessionId, error);
return null;
}
},
update: async (sessionId, data) => {
try {
const [result] = await pool.execute(
'UPDATE sessions SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?',
[JSON.stringify(data), sessionId]
);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error updating session', sessionId, error);
return false;
}
},
delete: async (sessionId) => {
try {
const [result] = await pool.execute('DELETE FROM sessions WHERE session_id = ?', [sessionId]);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error deleting session', sessionId, error);
return false;
}
},
cleanup: async () => {
try {
const [result] = await pool.execute('DELETE FROM sessions WHERE expires_at < NOW()');
logToFile('MariaDB: Cleaned up expired sessions', result.affectedRows);
return result.affectedRows;
} catch (error) {
logToFile('MariaDB: Error cleaning up sessions', error);
return 0;
}
}
};
// Rules helpers
const rulesHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM rules ORDER BY id');
return rows;
} catch (error) {
logToFile('MariaDB: Error getting all rules', error);
return [];
}
},
create: async (rule) => {
try {
const [result] = await pool.execute(
'INSERT INTO rules (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
);
return result.insertId;
} catch (error) {
logToFile('MariaDB: Error creating rule', error);
return null;
}
},
delete: async (id) => {
try {
const [result] = await pool.execute('DELETE FROM rules WHERE id = ?', [id]);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error deleting rule', id, error);
return false;
}
}
};
// Rules staging helpers
const stagingHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM rules_staging ORDER BY id');
return rows;
} catch (error) {
logToFile('MariaDB: Error getting all staging rules', error);
return [];
}
},
create: async (rule) => {
try {
const [result] = await pool.execute(
'INSERT INTO rules_staging (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
);
return result.insertId;
} catch (error) {
logToFile('MariaDB: Error creating staging rule', error);
return null;
}
},
delete: async (id) => {
try {
const [result] = await pool.execute('DELETE FROM rules_staging WHERE id = ?', [id]);
return result.affectedRows > 0;
} catch (error) {
logToFile('MariaDB: Error deleting staging rule', id, error);
return false;
}
},
moveToRules: async (id) => {
try {
const connection = await pool.getConnection();
await connection.beginTransaction();
// Get the staging rule
const [stagingRows] = await connection.execute('SELECT * FROM rules_staging WHERE id = ?', [id]);
if (stagingRows.length === 0) {
await connection.rollback();
connection.release();
return false;
}
const rule = stagingRows[0];
// Insert into rules
await connection.execute(
'INSERT INTO rules (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[rule.title, rule.content, rule.source, rule.page_num, rule.rulebook]
);
// Delete from staging
await connection.execute('DELETE FROM rules_staging WHERE id = ?', [id]);
await connection.commit();
connection.release();
return true;
} catch (error) {
logToFile('MariaDB: Error moving staging rule to rules', id, error);
return false;
}
}
};
// Weapons helpers
const weaponsHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM weapons ORDER BY id');
return rows;
} catch (error) {
logToFile('MariaDB: Error getting all weapons', error);
return [];
}
}
};
// Bestiary helpers
const bestiaryHelpers = {
getAll: async () => {
try {
const [rows] = await pool.execute('SELECT * FROM bestiary ORDER BY id');
return rows;
} catch (error) {
logToFile('MariaDB: Error getting all bestiary', error);
return [];
}
}
};
// Initialize database
createTables().catch(error => {
console.error('Failed to initialize MariaDB:', error);
process.exit(1);
});
// Export the connection pool and helpers
module.exports = {
pool,
playerHelpers,
sessionHelpers,
rulesHelpers,
stagingHelpers,
weaponsHelpers,
bestiaryHelpers,
logToFile
};

View File

@@ -0,0 +1,56 @@
const { playerHelpers } = require('./mariadb');
async function migrateInventoryToGear() {
try {
console.log('Starting inventory to gear migration...');
const players = await playerHelpers.getAll();
for (const player of players) {
if (player.tabInfo && player.tabInfo.inventory && player.tabInfo.inventory.length > 0) {
console.log(`Migrating inventory for ${player.name}...`);
const updatedTabInfo = { ...player.tabInfo };
// Initialize gear if it doesn't exist
if (!updatedTabInfo.gear) {
updatedTabInfo.gear = [];
}
// Move inventory items to gear
for (const invItem of updatedTabInfo.inventory) {
const gearItem = {
name: invItem.name,
qty: invItem.count || invItem.quantity || 1
};
// Check if item already exists in gear
const existingGearIndex = updatedTabInfo.gear.findIndex(g => g.name === gearItem.name);
if (existingGearIndex >= 0) {
// Update existing item quantity
updatedTabInfo.gear[existingGearIndex].qty += gearItem.qty;
} else {
// Add new item to gear
updatedTabInfo.gear.push(gearItem);
}
}
// Clear inventory since we moved everything to gear
updatedTabInfo.inventory = [];
// Update player
await playerHelpers.update(player.name, { ...player, tabInfo: updatedTabInfo });
console.log(` Moved ${player.tabInfo.inventory.length} items to gear`);
}
}
console.log('Migration completed!');
} catch (error) {
console.error('Migration failed:', error);
}
process.exit(0);
}
migrateInventoryToGear();

View File

@@ -0,0 +1,392 @@
#!/usr/bin/env node
const Database = require('better-sqlite3');
const mysql = require('mysql2/promise');
const path = require('path');
// SQLite database path
const sqliteDbPath = path.join(__dirname, 'sqlite', 'deathwatch.db');
// MariaDB configuration
const mariadbConfig = {
host: 'localhost',
user: 'deathwatch',
password: 'dwroller2025',
database: 'deathwatch'
};
// Helper function to convert SQLite datetime to MySQL format
function convertDateTime(dateString) {
if (!dateString) return null;
try {
return new Date(dateString).toISOString().slice(0, 19).replace('T', ' ');
} catch (error) {
console.warn(`Warning: Could not convert datetime: ${dateString}`);
return null;
}
}
async function migrateData() {
let sqliteDb = null;
let mariadbConnection = null;
try {
console.log('Starting data migration from SQLite to MariaDB...');
// Connect to SQLite
console.log('Connecting to SQLite database...');
sqliteDb = new Database(sqliteDbPath);
// Connect to MariaDB
console.log('Connecting to MariaDB database...');
mariadbConnection = await mysql.createConnection(mariadbConfig);
// Migrate players table (includes requisition_points and renown_level)
console.log('Migrating players table...');
const players = sqliteDb.prepare('SELECT * FROM players').all();
console.log(`Found ${players.length} players to migrate`);
for (const player of players) {
console.log(` Migrating player: ${player.name}`);
// Convert text fields to proper JSON
const rollerInfo = player.roller_info || '{}';
const shopInfo = player.shop_info || '{}';
const tabInfo = player.tab_info || '{}';
await mariadbConnection.execute(
`INSERT INTO players (id, name, roller_info, shop_info, tab_info, pw, pw_hash, created_at, updated_at, requisition_points, renown_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
roller_info = VALUES(roller_info),
shop_info = VALUES(shop_info),
tab_info = VALUES(tab_info),
pw = VALUES(pw),
pw_hash = VALUES(pw_hash),
updated_at = VALUES(updated_at),
requisition_points = VALUES(requisition_points),
renown_level = VALUES(renown_level)`,
[
player.id,
player.name,
rollerInfo,
shopInfo,
tabInfo,
player.pw || '',
player.pw_hash || '',
convertDateTime(player.created_at),
convertDateTime(player.updated_at),
player.requisition_points || 0,
player.renown_level || 'None'
]
);
}
// Migrate sessions table
console.log('Migrating sessions table...');
const sessions = sqliteDb.prepare('SELECT * FROM sessions').all();
console.log(`Found ${sessions.length} sessions to migrate`);
for (const session of sessions) {
console.log(` Migrating session: ${session.session_id}`);
await mariadbConnection.execute(
`INSERT INTO sessions (id, session_id, data, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
data = VALUES(data),
expires_at = VALUES(expires_at),
updated_at = VALUES(updated_at)`,
[
session.id,
session.session_id,
session.data || '{}',
convertDateTime(session.expires_at),
convertDateTime(session.created_at),
convertDateTime(session.updated_at)
]
);
}
// Migrate shop_items table
console.log('Migrating shop_items table...');
const shopItems = sqliteDb.prepare('SELECT * FROM shop_items').all();
console.log(`Found ${shopItems.length} shop items to migrate`);
for (const item of shopItems) {
console.log(` Migrating shop item: ${item.name}`);
await mariadbConnection.execute(
`INSERT INTO shop_items (id, name, category, requisition_cost, renown_requirement, item_type, stats, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
category = VALUES(category),
requisition_cost = VALUES(requisition_cost),
renown_requirement = VALUES(renown_requirement),
item_type = VALUES(item_type),
stats = VALUES(stats),
source = VALUES(source),
updated_at = VALUES(updated_at)`,
[
item.id,
item.name,
item.category,
item.requisition_cost,
item.renown_requirement,
item.item_type,
item.stats,
item.source,
convertDateTime(item.created_at),
convertDateTime(item.updated_at)
]
);
}
// Migrate player_inventory table
console.log('Migrating player_inventory table...');
const playerInventory = sqliteDb.prepare('SELECT * FROM player_inventory').all();
console.log(`Found ${playerInventory.length} inventory items to migrate`);
for (const invItem of playerInventory) {
console.log(` Migrating inventory item for player ${invItem.player_id}`);
await mariadbConnection.execute(
`INSERT INTO player_inventory (id, player_id, item_id, quantity, acquired_at, transaction_details)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
quantity = VALUES(quantity),
transaction_details = VALUES(transaction_details)`,
[
invItem.id,
invItem.player_id,
invItem.item_id,
invItem.quantity,
convertDateTime(invItem.acquired_at),
invItem.transaction_details
]
);
}
// Migrate transactions table
console.log('Migrating transactions table...');
const transactions = sqliteDb.prepare('SELECT * FROM transactions').all();
console.log(`Found ${transactions.length} transactions to migrate`);
for (const transaction of transactions) {
console.log(` Migrating transaction ${transaction.id}`);
await mariadbConnection.execute(
`INSERT INTO transactions (id, player_id, item_id, requisition_cost, previous_rp, new_rp, transaction_date)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
transaction.id,
transaction.player_id,
transaction.item_id,
transaction.requisition_cost,
transaction.previous_rp,
transaction.new_rp,
convertDateTime(transaction.transaction_date)
]
);
}
// Migrate armour table
console.log('Migrating armour table...');
const armour = sqliteDb.prepare('SELECT * FROM armour').all();
console.log(`Found ${armour.length} armour items to migrate`);
for (const armourItem of armour) {
console.log(` Migrating armour: ${armourItem.name}`);
await mariadbConnection.execute(
`INSERT INTO armour (id, name, req, renown, category, stats, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
req = VALUES(req),
renown = VALUES(renown),
category = VALUES(category),
stats = VALUES(stats),
source = VALUES(source)`,
[
armourItem.id,
armourItem.name,
armourItem.req,
armourItem.renown,
armourItem.category,
armourItem.stats,
armourItem.source,
convertDateTime(armourItem.created_at)
]
);
}
// Migrate weapons table
console.log('Migrating weapons table...');
const weapons = sqliteDb.prepare('SELECT * FROM weapons').all();
console.log(`Found ${weapons.length} weapons to migrate`);
for (const weapon of weapons) {
console.log(` Migrating weapon: ${weapon.name}`);
await mariadbConnection.execute(
`INSERT INTO weapons (id, name, req, renown, category, stats, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
req = VALUES(req),
renown = VALUES(renown),
category = VALUES(category),
stats = VALUES(stats),
source = VALUES(source)`,
[
weapon.id,
weapon.name,
weapon.req,
weapon.renown,
weapon.category,
weapon.stats,
weapon.source,
convertDateTime(weapon.created_at)
]
);
}
// Migrate bestiary table
console.log('Migrating bestiary table...');
const bestiary = sqliteDb.prepare('SELECT * FROM bestiary').all();
console.log(`Found ${bestiary.length} bestiary entries to migrate`);
for (const beast of bestiary) {
console.log(` Migrating bestiary: ${beast.name}`);
await mariadbConnection.execute(
`INSERT INTO bestiary (id, name, book, page, pdf, stats, profile, snippet, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
book = VALUES(book),
page = VALUES(page),
pdf = VALUES(pdf),
stats = VALUES(stats),
profile = VALUES(profile),
snippet = VALUES(snippet)`,
[
beast.id,
beast.name,
beast.book,
beast.page,
beast.pdf,
beast.stats,
beast.profile,
beast.snippet,
convertDateTime(beast.created_at)
]
);
}
// Migrate rules table
console.log('Migrating rules table...');
const rules = sqliteDb.prepare('SELECT * FROM rules').all();
console.log(`Found ${rules.length} rules to migrate`);
for (const rule of rules) {
console.log(` Migrating rule: ${rule.title}`);
// Truncate rule_id if it's too long (max 255 chars)
const ruleId = rule.rule_id ? rule.rule_id.substring(0, 255) : null;
// Truncate title if it's too long (max 500 chars)
const title = rule.title ? rule.title.substring(0, 500) : null;
await mariadbConnection.execute(
`INSERT INTO rules (id, rule_id, title, content, page, source, source_abbr, category, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
content = VALUES(content),
page = VALUES(page),
source = VALUES(source),
source_abbr = VALUES(source_abbr),
category = VALUES(category)`,
[
rule.id,
ruleId,
title,
rule.content,
rule.page,
rule.source,
rule.source_abbr,
rule.category,
convertDateTime(rule.created_at)
]
);
}
// Migrate rules_staging table
console.log('Migrating rules_staging table...');
const rulesStaging = sqliteDb.prepare('SELECT * FROM rules_staging').all();
console.log(`Found ${rulesStaging.length} rules_staging entries to migrate`);
for (const rule of rulesStaging) {
console.log(` Migrating rules_staging: ${rule.title}`);
await mariadbConnection.execute(
`INSERT INTO rules_staging (id, title, content, category, page, original_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
content = VALUES(content),
category = VALUES(category),
page = VALUES(page),
original_json = VALUES(original_json)`,
[
rule.id,
rule.title,
rule.content,
rule.category,
rule.page,
rule.original_json,
convertDateTime(rule.created_at)
]
);
}
console.log('Migration completed successfully!');
// Verify migration
console.log('\nVerifying migration...');
const [mariadbPlayers] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM players');
const [mariadbSessions] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM sessions');
const [mariadbShopItems] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM shop_items');
const [mariadbInventory] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM player_inventory');
const [mariadbTransactions] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM transactions');
const [mariadbArmour] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM armour');
const [mariadbWeapons] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM weapons');
const [mariadbBestiary] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM bestiary');
const [mariadbRules] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM rules');
const [mariadbRulesStaging] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM rules_staging');
console.log(`MariaDB players: ${mariadbPlayers[0].count}`);
console.log(`MariaDB sessions: ${mariadbSessions[0].count}`);
console.log(`MariaDB shop_items: ${mariadbShopItems[0].count}`);
console.log(`MariaDB player_inventory: ${mariadbInventory[0].count}`);
console.log(`MariaDB transactions: ${mariadbTransactions[0].count}`);
console.log(`MariaDB armour: ${mariadbArmour[0].count}`);
console.log(`MariaDB weapons: ${mariadbWeapons[0].count}`);
console.log(`MariaDB bestiary: ${mariadbBestiary[0].count}`);
console.log(`MariaDB rules: ${mariadbRules[0].count}`);
console.log(`MariaDB rules_staging: ${mariadbRulesStaging[0].count}`);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
if (sqliteDb) {
sqliteDb.close();
}
if (mariadbConnection) {
await mariadbConnection.end();
}
}
}
// Run migration
migrateData().catch(console.error);

View File

@@ -1,18 +1,26 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { db } = require('../sqlite-db');
const { bestiaryHelpers, logToFile } = require('../mariadb');
const router = express.Router();
// Read bestiary from sqlite table `bestiary`
function loadBestiaryData() {
// Read bestiary from MariaDB table `bestiary`
async function loadBestiaryData() {
try {
const rows = db.prepare('SELECT id,name,book,page,pdf,stats,profile,snippet FROM bestiary ORDER BY name').all();
const rows = await bestiaryHelpers.getAll();
return rows.map(r => {
let stats = {};
try { stats = JSON.parse(r.stats || '{}'); } catch(e){}
try {
stats = typeof r.stats === 'string' ? JSON.parse(r.stats) : r.stats || {};
} catch(e){
logToFile('Error parsing bestiary stats for', r.name, e);
}
let profile = {};
try { profile = JSON.parse(r.profile || '{}'); } catch(e){}
try {
profile = typeof r.profile === 'string' ? JSON.parse(r.profile) : r.profile || {};
} catch(e){
logToFile('Error parsing bestiary profile for', r.name, e);
}
return {
_id: r.id,
bestiaryName: r.name,
@@ -25,7 +33,8 @@ function loadBestiaryData() {
};
});
} catch (e) {
console.error('Failed to load bestiary from sqlite:', e);
console.error('Failed to load bestiary from MariaDB:', e);
logToFile('Error loading bestiary:', e);
return [];
}
}
@@ -88,9 +97,9 @@ function transformBestiaryEntry(entry) {
}
// Get all bestiary entries formatted for dice roller
router.get('/enemies', (req, res) => {
router.get('/enemies', async (req, res) => {
try {
const entries = loadBestiaryData();
const entries = await loadBestiaryData();
// Transform entries for dice roller format
const enemies = entries
@@ -106,43 +115,46 @@ router.get('/enemies', (req, res) => {
res.json(enemies);
} catch (error) {
console.error('Bestiary enemies error:', error);
logToFile('Error getting bestiary enemies:', error);
res.status(500).json({ error: 'Failed to get enemies' });
}
});
// Get full bestiary data (for bestiary tab)
router.get('/full', (req, res) => {
router.get('/full', async (req, res) => {
try {
const entries = loadBestiaryData();
const entries = await loadBestiaryData();
res.json(entries);
} catch (error) {
console.error('Bestiary full error:', error);
logToFile('Error getting full bestiary:', error);
res.status(500).json({ error: 'Failed to get bestiary data' });
}
});
// Get bestiary statistics
router.get('/stats', (req, res) => {
router.get('/stats', async (req, res) => {
try {
const entries = loadBestiaryData();
const entries = await loadBestiaryData();
const stats = {
totalEntries: entries.length,
withValidStats: entries.filter(e => e.stats?.profile?.t).length,
withWounds: entries.filter(e => e.wounds || e.stats?.wounds).length,
books: [...new Set(entries.map(e => e.book).filter(Boolean))],
lastUpdated: new Date(lastLoaded).toISOString()
lastUpdated: new Date().toISOString()
};
res.json(stats);
} catch (error) {
console.error('Bestiary stats error:', error);
logToFile('Error getting bestiary stats:', error);
res.status(500).json({ error: 'Failed to get bestiary stats' });
}
});
// Force reload bestiary data (admin only)
router.post('/reload', (req, res) => {
router.post('/reload', async (req, res) => {
try {
// Check for GM secret
const gmSecret = req.headers['x-gm-secret'];
@@ -150,19 +162,17 @@ router.post('/reload', (req, res) => {
return res.status(403).json({ error: 'Unauthorized' });
}
// Reset cache
bestiaryData = null;
lastLoaded = 0;
const entries = loadBestiaryData();
// No cache to reset since we query MariaDB directly
const entries = await loadBestiaryData();
res.json({
success: true,
totalEntries: entries.length,
message: 'Bestiary data reloaded successfully'
message: 'Bestiary data reloaded from MariaDB successfully'
});
} catch (error) {
console.error('Bestiary reload error:', error);
logToFile('Error reloading bestiary:', error);
res.status(500).json({ error: 'Failed to reload bestiary data' });
}
});

View File

@@ -1,676 +0,0 @@
const express = require('express');
const { playerHelpers, sessionHelpers, db } = require('../sqlite-db');
const fs = require('fs');
const path = require('path');
// shop data will be read from sqlite `shop_items` table when needed
// Simple file logger
function logToFile(...args) {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' });
}
const requireSession = require('../requireSession');
const { validatePlayer } = require('../validate');
const router = express.Router();
// PUBLIC ROUTES - before session middleware
// Get shop inventory
router.get('/shop', (req, res) => {
try {
console.log('Shop endpoint hit');
// Query sqlite shop_items and return grouped by category
const items = db.prepare('SELECT id, name, category, requisition_cost as req, renown_requirement as renown, item_type, stats, source FROM shop_items ORDER BY category, name').all();
const grouped = items.reduce((acc, it) => {
acc[it.category] = acc[it.category] || [];
let stats = {};
try { stats = JSON.parse(it.stats || '{}'); } catch(e){}
acc[it.category].push({ id: it.id, name: it.name, req: it.req, renown: it.renown, itemType: it.item_type, stats, source: it.source });
return acc;
}, {});
res.json(grouped);
} catch (error) {
console.error('Shop error:', error);
logToFile('API: Failed to get shop data', error);
res.status(500).json({ error: String(error) });
}
});
// Get player names for login dropdown (public - no session required)
router.get('/names', (req, res) => {
try {
console.log('Player names endpoint hit');
const players = playerHelpers.getAll();
// Only return names for the login dropdown, not full player data
const playerNames = players.map(p => ({ name: p.name }));
res.json(playerNames);
} catch (error) {
console.error('Player names error:', error);
logToFile('API: Failed to get player names', error);
res.status(500).json({ error: String(error) });
}
});
// Login endpoint - create a server session so x-session-id can be used (public - no session required)
router.post('/login', async (req, res) => {
try {
const { name, password } = req.body;
if (!name || !password) {
return res.status(400).json({ error: 'Name and password are required' });
}
const player = playerHelpers.getByName(name);
if (!player) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check password using safeCompare
const isValidPassword = await safeCompare(password, player.pwHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session and store it in sessions table
const sessionId = require('crypto').randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24h
sessionHelpers.create(sessionId, { playerName: player.name }, expiresAt);
logToFile('API: Player login', name, sessionId);
res.json({
message: 'Login successful',
sessionId,
expiresAt,
player: {
name: player.name,
rollerInfo: player.rollerInfo,
shopInfo: player.shopInfo,
tabInfo: player.tabInfo
}
});
} catch (error) {
logToFile('API: Login failed', req.body.name, error && error.stack ? error.stack : error);
res.status(500).json({ error: 'Login failed' });
}
});
// Apply session middleware to all routes EXCEPT those above this line
router.use(requireSession);
// Add safe bcrypt helpers to avoid MODULE_NOT_FOUND failures at runtime
async function safeHash(pw) {
if (!pw) return '';
try {
const bcrypt = require('bcrypt');
return await bcrypt.hash(pw, 10);
} catch (err) {
// Fallback to storing plaintext (development only) and log the error
logToFile('WARN: bcrypt.hash failed, falling back to plaintext pw', err && err.stack ? err.stack : String(err));
return String(pw);
}
}
async function safeCompare(candidate, hashed) {
try {
const bcrypt = require('bcrypt');
return await bcrypt.compare(candidate, hashed);
} catch (err) {
// If bcrypt not available, fall back to plaintext comparison (dev only)
logToFile('WARN: bcrypt.compare failed, falling back to plaintext compare', err && err.stack ? err.stack : String(err));
return String(candidate) === String(hashed);
}
}
// TEMP ADMIN: List all users
router.get('/admin/list', async (req, res) => {
try {
const players = playerHelpers.getAll();
res.json(players);
} catch (error) {
logToFile('API: Failed to list players', error);
res.status(500).json({ error: 'Failed to list players' });
}
});
// Expose pre-generated Space Marine names for GM use
router.get('/admin/pregens', async (req, res) => {
try {
const filePath = path.join(__dirname, '..', 'pregen_names.json');
if (!fs.existsSync(filePath)) return res.json([]);
const names = JSON.parse(fs.readFileSync(filePath, 'utf8'));
res.json(names);
} catch (error) {
logToFile('API: Failed to get pregens', error);
res.status(500).json({ error: 'Failed to get pregens' });
}
});
// Shop endpoint already defined above
// TEMP ADMIN: Delete test users (name contains 'test' or 'Test')
router.delete('/admin/delete-tests', async (req, res) => {
try {
const players = playerHelpers.getAll();
const testPlayers = players.filter(p => /test/i.test(p.name));
let deletedCount = 0;
for (const player of testPlayers) {
if (playerHelpers.delete(player.name)) {
deletedCount++;
}
}
res.json({ deletedCount });
} catch (error) {
logToFile('API: Failed to delete test users', error);
res.status(500).json({ error: 'Failed to delete test users' });
}
});
// Get all players (public for dropdown)
router.get('/', async (req, res) => {
try {
const players = playerHelpers.getAll();
logToFile('API: Fetch all players (public)', `Found ${players.length} players`);
// Allow full list if client provided a valid session id OR the GM secret header
const gmSecret = req.headers['x-gm-secret'] || req.query.gmSecret || (req.body && req.body.gmSecret);
const gmPassword = process.env.GM_PASSWORD || 'bongo';
const isGm = gmSecret && String(gmSecret) === String(gmPassword);
const isAuthed = !!req.headers['x-session-id'];
if (!isAuthed && !isGm) {
return res.json(players.map(p => ({ name: p.name })));
}
res.json(players);
} catch (error) {
logToFile('API: Failed to fetch players', error);
res.status(500).json({ error: 'Failed to fetch players' });
}
});
// Get single player by name
router.get('/:name', async (req, res) => {
try {
const player = playerHelpers.getByName(req.params.name);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
logToFile('API: Fetch player', req.params.name);
res.json(player);
} catch (error) {
logToFile('API: Failed to fetch player', req.params.name, error);
res.status(500).json({ error: 'Failed to fetch player' });
}
});
// Create new player
router.post('/', async (req, res) => {
try {
const { name, pw, ...otherData } = req.body;
if (!name) {
return res.status(400).json({ error: 'Player name is required' });
}
// Check if player already exists
const existingPlayer = playerHelpers.getByName(name);
if (existingPlayer) {
return res.status(409).json({ error: 'Player already exists' });
}
// If a plain password was provided, hash it first so validation won't reject plaintext
let pwHash = '';
if (pw) {
pwHash = await safeHash(pw);
}
// Validate and normalize incoming player object. Pass pwHash instead of plaintext pw.
const { valid, errors, normalized } = validatePlayer({ name, pwHash, ...otherData });
if (!valid) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
const newPlayer = playerHelpers.create({
name: normalized.name,
// Do NOT store plaintext pw to avoid validation rejecting records later
pw: '',
pwHash,
rollerInfo: normalized.rollerInfo || {},
shopInfo: normalized.shopInfo || {},
tabInfo: normalized.tabInfo || {}
});
logToFile('API: Created player', name);
res.status(201).json(newPlayer);
} catch (error) {
// Log stack for easier debugging
logToFile('API: Failed to create player', req.body && req.body.name, error && error.stack ? error.stack : error);
res.status(500).json({ error: 'Failed to create player' });
}
});
// Update player
router.put('/:name', requireSession, async (req, res) => {
try {
const { name } = req.params;
// Ensure updateData is an object to avoid runtime TypeErrors when fields are missing
const updateData = req.body || {};
// Check if player exists
const existingPlayer = playerHelpers.getByName(name);
if (!existingPlayer) {
return res.status(404).json({ error: 'Player not found' });
}
// Handle password update if provided using safeHash
if (updateData.pw) {
updateData.pwHash = await safeHash(updateData.pw);
// Do not pass plaintext pw into validation; validation requires pwHash only
// We'll store an empty pw field (frontend may still use pw for temporary purposes)
updateData.pw = '';
}
// Merge the data properly
const mergedData = {
rollerInfo: { ...existingPlayer.rollerInfo, ...(updateData.rollerInfo || {}) },
shopInfo: { ...existingPlayer.shopInfo, ...(updateData.shopInfo || {}) },
tabInfo: { ...existingPlayer.tabInfo, ...(updateData.tabInfo || {}) },
// Never carry forward plaintext pw into validation; keep pw empty and use pwHash
pw: '',
pwHash: updateData.pwHash || existingPlayer.pwHash
};
// If frontend sends flat fields (not under tabInfo), map them into tabInfo.
// This accepts updates for any of the known tabInfo keys even when only those
// fields are sent, not requiring playerName/charName to be present.
const flatFields = [
'playerName','charName','gear','chapter','demeanour','speciality','rank','powerArmour',
'description','pastEvent','personalDemeanour','characteristics','skills','weapons','armour',
'talents','psychic','wounds','insanity','movement','fate','corruption','renown','xp','xpSpent',
'notes','rp'
];
for (const key of flatFields) {
if (Object.prototype.hasOwnProperty.call(updateData, key)) {
mergedData.tabInfo[key] = updateData[key];
}
}
// Validate merged data before applying
const { valid: v2, errors: e2, normalized: normalized2 } = validatePlayer(Object.assign({ name }, mergedData));
if (!v2) {
return res.status(400).json({ error: 'Validation failed', details: e2 });
}
const updated = playerHelpers.update(name, normalized2);
if (!updated) {
return res.status(500).json({ error: 'Failed to update player' });
}
const updatedPlayer = playerHelpers.getByName(name);
logToFile('API: Updated player', name);
res.json(updatedPlayer);
} catch (error) {
logToFile('API: Failed to update player', req.params.name, error && error.stack ? error.stack : error);
res.status(500).json({ error: 'Failed to update player' });
}
});
// Delete player
router.delete('/:name', requireSession, async (req, res) => {
try {
const { name } = req.params;
const deleted = playerHelpers.delete(name);
if (!deleted) {
return res.status(404).json({ error: 'Player not found' });
}
logToFile('API: Deleted player', name);
res.json({ message: 'Player deleted successfully' });
} catch (error) {
logToFile('API: Failed to delete player', req.params.name, error);
res.status(500).json({ error: 'Failed to delete player' });
}
});
// Upload avatar (base64 JSON payload) - saves to public/avatars and updates tabInfo.picture
router.post('/:name/avatar', requireSession, async (req, res) => {
try {
const { name } = req.params;
const { filename, data } = req.body || {};
if (!filename || !data) return res.status(400).json({ error: 'filename and data required' });
const existingPlayer = playerHelpers.getByName(name);
if (!existingPlayer) return res.status(404).json({ error: 'Player not found' });
// Extract base64 payload if data URL provided
const match = String(data).match(/^data:(image\/(png|jpeg|jpg|gif));base64,(.*)$/i);
let mimeType = null;
let base64 = null;
if (match) {
mimeType = match[1];
base64 = match[3];
} else {
// Assume raw base64 and try to infer extension from filename
base64 = String(data).replace(/^\s+|\s+$/g, '');
}
// Validate size (limit to 200KB)
let buffer;
try {
buffer = Buffer.from(base64, 'base64');
} catch (err) {
return res.status(400).json({ error: 'Invalid base64 data' });
}
const MAX_BYTES = 200 * 1024;
if (buffer.length > MAX_BYTES) return res.status(413).json({ error: 'File too large' });
// Sanitize filename and ensure extension
const ext = path.extname(filename).toLowerCase() || (mimeType ? `.${mimeType.split('/')[1]}` : '.png');
const safeName = `${existingPlayer.name.replace(/[^a-z0-9_-]/gi, '_')}_${Date.now()}${ext}`;
const avatarsDir = path.join(__dirname, '..', '..', 'public', 'avatars');
if (!fs.existsSync(avatarsDir)) fs.mkdirSync(avatarsDir, { recursive: true });
const outPath = path.join(avatarsDir, safeName);
fs.writeFileSync(outPath, buffer);
// Update player's tabInfo.picture to public URL path
const avatarUrl = `/avatars/${safeName}`;
const mergedData = {
rollerInfo: { ...existingPlayer.rollerInfo },
shopInfo: { ...existingPlayer.shopInfo },
tabInfo: { ...(existingPlayer.tabInfo || {}), picture: avatarUrl },
// Do not include plaintext pw when validating/updating avatar
pw: '',
pwHash: existingPlayer.pwHash
};
// Validate then update
const { valid, errors, normalized } = validatePlayer(Object.assign({ name }, mergedData));
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(name, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player with avatar' });
const updated = playerHelpers.getByName(name);
logToFile('API: Uploaded avatar for', name, avatarUrl);
res.json(updated);
} catch (error) {
logToFile('API: Avatar upload failed', req.params.name, error && error.stack ? error.stack : error);
res.status(500).json({ error: 'Avatar upload failed' });
}
});
// GM ENDPOINTS - bypassing session validation with GM secret
function gmBypass(req, res, next) {
const gmSecret = req.headers['x-gm-secret'];
if (gmSecret === 'bongo') {
logToFile('SESSION: GM bypass accepted', req.method, req.url);
return next();
}
logToFile('SESSION: GM bypass rejected - invalid secret', req.method, req.url);
return res.status(401).json({ error: 'GM access denied' });
}
// Add/update player (GM only)
router.post('/gm/add-or-update', gmBypass, (req, res) => {
try {
const { name, rp, pw } = req.body;
if (!name) {
return res.status(400).json({ error: 'Player name is required' });
}
// Check if player exists
const existing = playerHelpers.getByName(name);
if (existing) {
// Update existing player
const updates = { ...existing };
if (rp !== undefined) updates.tabInfo = { ...updates.tabInfo, rp: parseInt(rp) };
if (pw) updates.pwHash = require('bcrypt').hashSync(pw, 10);
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(name, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player' });
logToFile('GM: Updated player', name);
return res.json({ success: true, message: `Updated player ${name}` });
} else {
// Create new player
const defaultRP = rp !== undefined ? parseInt(rp) : 50;
const password = pw || '1234';
const newPlayer = {
name,
tabInfo: {
rp: defaultRP,
renown: 'None',
xp: 0,
xpSpent: 0,
charName: `Brother ${name.charAt(0).toUpperCase() + name.slice(1)}`
},
rollerInfo: {},
shopInfo: {},
pwHash: require('bcrypt').hashSync(password, 10),
pw: ''
};
const { valid, errors, normalized } = validatePlayer(newPlayer);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const saved = playerHelpers.create(normalized);
if (!saved) return res.status(500).json({ error: 'Failed to create player' });
logToFile('GM: Created player', name);
return res.json({ success: true, message: `Created player ${name}` });
}
} catch (error) {
logToFile('GM: Add/update player failed', error);
res.status(500).json({ error: 'Failed to add/update player' });
}
});
// Set RP (GM only)
router.post('/gm/set-rp', gmBypass, (req, res) => {
try {
const { playerName, requisitionPoints } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, rp: parseInt(requisitionPoints) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player RP' });
logToFile('GM: Set RP for', playerName, 'to', requisitionPoints);
res.json({ success: true, message: `Set RP for ${playerName} to ${requisitionPoints}` });
} catch (error) {
logToFile('GM: Set RP failed', error);
res.status(500).json({ error: 'Failed to set RP' });
}
});
// Set XP (GM only)
router.post('/gm/set-xp', gmBypass, (req, res) => {
try {
const { playerName, xp } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, xp: parseInt(xp) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player XP' });
logToFile('GM: Set XP for', playerName, 'to', xp);
res.json({ success: true, message: `Set XP for ${playerName} to ${xp}` });
} catch (error) {
logToFile('GM: Set XP failed', error);
res.status(500).json({ error: 'Failed to set XP' });
}
});
// Set XP Spent (GM only)
router.post('/gm/set-xp-spent', gmBypass, (req, res) => {
try {
const { playerName, xpSpent } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, xpSpent: parseInt(xpSpent) }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player XP Spent' });
logToFile('GM: Set XP Spent for', playerName, 'to', xpSpent);
res.json({ success: true, message: `Set XP Spent for ${playerName} to ${xpSpent}` });
} catch (error) {
logToFile('GM: Set XP Spent failed', error);
res.status(500).json({ error: 'Failed to set XP Spent' });
}
});
// Set Renown (GM only)
router.post('/gm/set-renown', gmBypass, (req, res) => {
try {
const { playerName, renown } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const updates = {
...player,
tabInfo: { ...player.tabInfo, renown }
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to update player renown' });
logToFile('GM: Set renown for', playerName, 'to', renown);
res.json({ success: true, message: `Set renown for ${playerName} to ${renown}` });
} catch (error) {
logToFile('GM: Set renown failed', error);
res.status(500).json({ error: 'Failed to set renown' });
}
});
// Reset password (GM only)
router.post('/gm/reset-password', gmBypass, (req, res) => {
try {
const { playerName, newPassword } = req.body;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const password = newPassword || '1234';
const updates = {
...player,
pwHash: require('bcrypt').hashSync(password, 10),
pw: ''
};
const { valid, errors, normalized } = validatePlayer(updates);
if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors });
const ok = playerHelpers.update(playerName, normalized);
if (!ok) return res.status(500).json({ error: 'Failed to reset password' });
logToFile('GM: Reset password for', playerName);
res.json({ success: true, message: `Reset password for ${playerName}` });
} catch (error) {
logToFile('GM: Reset password failed', error);
res.status(500).json({ error: 'Failed to reset password' });
}
});
// Delete player (GM only)
router.delete('/gm/delete/:playerName', gmBypass, (req, res) => {
try {
const { playerName } = req.params;
if (!playerName) {
return res.status(400).json({ error: 'Player name is required' });
}
const player = playerHelpers.getByName(playerName);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
const ok = playerHelpers.delete(playerName);
if (!ok) return res.status(500).json({ error: 'Failed to delete player' });
logToFile('GM: Deleted player', playerName);
res.json({ success: true, message: `Deleted player ${playerName}` });
} catch (error) {
logToFile('GM: Delete player failed', error);
res.status(500).json({ error: 'Failed to delete player' });
}
});
module.exports = router;

235
database/routes/playerRoutes.js Executable file → Normal file
View File

@@ -1,151 +1,176 @@
const express = require('express');
const Player = require('../playerModel');
const fs = require('fs');
const path = require('path');
// Simple file logger
function logToFile(...args) {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' });
}
const requireSession = require('../requireSession');
const { playerHelpers, logToFile } = require('../mariadb');
const router = express.Router();
// TEMP ADMIN: List all users
router.get('/admin/list', async (req, res) => {
// Login endpoint for players
router.post('/login', async (req, res) => {
try {
const players = await Player.find();
res.json(players);
const { name, password } = req.body;
console.log('Login attempt for player:', name);
if (!name || !password) {
return res.status(400).json({ error: 'Name and password required' });
}
// Special handling for GM user
if (name.toLowerCase() === 'gm') {
if (password !== 'bongo') {
return res.status(401).json({ error: 'Invalid password' });
}
} else {
// For regular players, use password '1234'
if (password !== '1234') {
return res.status(401).json({ error: 'Invalid password' });
}
}
const player = await playerHelpers.getByName(name);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
// Generate a simple session ID (in production, use proper session management)
const sessionId = `session_${name}_${Date.now()}`;
logToFile('API: Player login', name, 'success');
res.json({
success: true,
sessionId,
player: { name: player.name }
});
} catch (error) {
res.status(500).json({ error: 'Failed to list players' });
console.error('Login error:', error);
logToFile('API: Failed to login player', req.body?.name, error);
res.status(500).json({ error: String(error) });
}
});
// TEMP ADMIN: Delete test users (name contains 'test' or 'Test')
router.delete('/admin/delete-tests', async (req, res) => {
// Get player names for login dropdown (public - no session required)
router.get('/names', async (req, res) => {
try {
const result = await Player.deleteMany({ name: /test/i });
res.json({ deletedCount: result.deletedCount });
console.log('Player names endpoint hit');
const players = await playerHelpers.getAll();
// Only return names for the login dropdown, not full player data
const playerNames = players.map(p => ({ name: p.name }));
res.json(playerNames);
} catch (error) {
res.status(500).json({ error: 'Failed to delete test users' });
console.error('Player names error:', error);
logToFile('API: Failed to get player names', error);
res.status(500).json({ error: String(error) });
}
});
// Get all players (public for dropdown)
router.get('/', async (req, res) => {
try {
const players = await Player.find();
logToFile('API: Fetch all players (public)', players);
// Only send name for dropdown if not authed
if (!req.headers['x-session-id']) {
return res.json(players.map(p => ({ name: p.name })));
const players = await playerHelpers.getAll();
logToFile('API: Fetch all players (public)', players.length);
// If authenticated (has session header or x-gm-secret), return full player data
const hasSession = req.headers['x-session-id'] || req.headers['x-gm-secret'];
if (hasSession) {
// Return full player data for authenticated requests
res.json(players);
} else {
// Only send name for dropdown if not authenticated
res.json(players.map(p => ({ name: p.name })));
}
res.json(players);
} catch (error) {
logToFile('API: Failed to fetch players', error);
res.status(500).json({ error: 'Failed to fetch players' });
}
});
// Get a single player by name (require session)
router.get('/:name', requireSession, async (req, res) => {
// Get player by name
router.get('/:name', async (req, res) => {
try {
const player = await Player.findOne({ name: req.params.name });
logToFile('API: Fetch player', req.params.name, player);
const { name } = req.params;
console.log('Getting player:', name);
const player = await playerHelpers.getByName(name);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
logToFile('API: Fetch player', name, 'success');
res.json(player);
} catch (error) {
logToFile('API: Failed to fetch player', req.params.name, error);
res.status(500).json({ error: 'Failed to fetch player' });
console.error('Get player error:', error);
logToFile('API: Failed to get player', req.params.name, error);
res.status(500).json({ error: String(error) });
}
});
// Helper to flatten tabInfo
function flattenTabInfo(tabInfo) {
let t = tabInfo;
while (t && t.tabInfo) t = t.tabInfo;
return { ...t };
}
// Update player data
router.put('/:name', async (req, res) => {
try {
const { name } = req.params;
const playerData = req.body;
console.log('Updating player:', name);
const success = await playerHelpers.update(name, playerData);
if (!success) {
return res.status(500).json({ error: 'Failed to update player' });
}
logToFile('API: Updated player', name, 'success');
res.json({ success: true, message: 'Player updated successfully' });
} catch (error) {
console.error('Update player error:', error);
logToFile('API: Failed to update player', req.params.name, error);
res.status(500).json({ error: String(error) });
}
});
// Create a new player
// Create new player
router.post('/', async (req, res) => {
try {
logToFile('API: Creating player', req.body);
const body = { ...req.body };
if (body.tabInfo) body.tabInfo = flattenTabInfo(body.tabInfo);
const newPlayer = new Player(body);
await newPlayer.save();
logToFile('API: Player created', newPlayer);
res.status(201).json(newPlayer);
const playerData = req.body;
console.log('Creating player:', playerData.name);
// Check if player already exists
const existing = await playerHelpers.getByName(playerData.name);
if (existing) {
return res.status(409).json({ error: 'Player already exists' });
}
const playerId = await playerHelpers.create(playerData);
if (!playerId) {
return res.status(500).json({ error: 'Failed to create player' });
}
logToFile('API: Created player', playerData.name, 'success');
res.json({ success: true, id: playerId, message: 'Player created successfully' });
} catch (error) {
logToFile('API: Failed to create player', error);
res.status(400).json({ error: 'Failed to create player' });
console.error('Create player error:', error);
logToFile('API: Failed to create player', req.body.name, error);
res.status(500).json({ error: String(error) });
}
});
// Update a player (require session)
router.put('/:name', requireSession, async (req, res) => {
// Delete player
router.delete('/:name', async (req, res) => {
try {
logToFile('API: Updating player', req.params.name, req.body);
// Always extract pw and pwHash from any location in the request
let pw, pwHash;
let tabInfo = req.body.tabInfo;
if (req.body.pw !== undefined) pw = req.body.pw;
if (req.body.pwHash !== undefined) pwHash = req.body.pwHash;
if (tabInfo !== undefined) {
if (tabInfo.pw !== undefined) pw = tabInfo.pw;
if (tabInfo.pwHash !== undefined) pwHash = tabInfo.pwHash;
if (tabInfo.tabInfo) {
if (tabInfo.tabInfo.pw !== undefined) pw = tabInfo.tabInfo.pw;
if (tabInfo.tabInfo.pwHash !== undefined) pwHash = tabInfo.tabInfo.pwHash;
}
tabInfo = flattenTabInfo(tabInfo);
const { name } = req.params;
console.log('Deleting player:', name);
const success = await playerHelpers.delete(name);
if (!success) {
return res.status(404).json({ error: 'Player not found or could not be deleted' });
}
const update = {};
if (pw !== undefined) update.pw = pw;
if (pwHash !== undefined) update.pwHash = pwHash;
if (tabInfo !== undefined) update.tabInfo = tabInfo;
if (Object.keys(update).length === 0) {
logToFile('API: No valid fields to update for player', req.params.name);
return res.status(400).json({ error: 'No valid fields to update' });
}
const updatedPlayer = await Player.findOneAndUpdate(
{ name: req.params.name },
update,
{ new: true }
);
if (!updatedPlayer) {
logToFile('API: Player not found for update', req.params.name);
return res.status(404).json({ error: 'Player not found' });
}
logToFile('API: Player updated', updatedPlayer);
res.json(updatedPlayer);
logToFile('API: Deleted player', name, 'success');
res.json({ success: true, message: 'Player deleted successfully' });
} catch (error) {
logToFile('API: Failed to update player', error);
res.status(400).json({ error: 'Failed to update player' });
}
});
// Delete a player (require session)
router.delete('/:name', requireSession, async (req, res) => {
try {
logToFile('API: Deleting player', req.params.name);
const deletedPlayer = await Player.findOneAndDelete({ name: req.params.name });
if (!deletedPlayer) {
logToFile('API: Player not found for delete', req.params.name);
return res.status(404).json({ error: 'Player not found' });
}
logToFile('API: Player deleted', req.params.name);
res.status(204).send();
} catch (error) {
logToFile('API: Failed to delete player', error);
res.status(500).json({ error: 'Failed to delete player' });
console.error('Delete player error:', error);
logToFile('API: Failed to delete player', req.params.name, error);
res.status(500).json({ error: String(error) });
}
});
console.log('Player routes registered (MariaDB)');
module.exports = router;

View File

@@ -1,10 +1,10 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { db } = require('../sqlite-db');
const { rulesHelpers, logToFile } = require('../mariadb');
const router = express.Router();
console.log('Rules routes registered (sqlite-backed)');
console.log('Rules routes registered (MariaDB)');
// Helpers to clean up OCR/extracted text
function cleanText(s) {
@@ -32,62 +32,95 @@ function cleanTitle(t) {
return s;
}
// We will query sqlite `rules` table on demand; helper to fetch all rules
function getAllRules() {
// Fetch all rules from MariaDB
async function getAllRules() {
try {
const rows = db.prepare('SELECT id, rule_id, title, content, page, source, source_abbr, category FROM rules ORDER BY id').all();
return rows.map(r => ({ id: r.id, rule_id: r.rule_id, title: cleanTitle(r.title), content: cleanText(r.content), page: r.page, source: r.source, sourceAbbr: r.source_abbr, category: r.category }));
const rows = await rulesHelpers.getAll();
return rows.map(r => ({
id: r.id,
rule_id: r.rule_id,
title: cleanTitle(r.title),
content: cleanText(r.content),
page: r.page_num,
source: r.source,
sourceAbbr: r.source_abbr,
category: r.category
}));
} catch (e) {
console.error('Failed to read rules from sqlite:', e);
console.error('Failed to read rules from MariaDB:', e);
logToFile('Error getting all rules:', e);
return [];
}
}
function getRuleById(ruleId) {
async function getRuleById(ruleId) {
try {
const row = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE rule_id = ?').get(ruleId);
if (!row) return null;
return { id: row.id, title: cleanTitle(row.title), content: cleanText(row.content), page: row.page, source: row.source, sourceAbbr: row.sourceAbbr, category: row.category };
const rows = await rulesHelpers.getAll();
const row = rows.find(r => r.rule_id === ruleId || r.id === ruleId);
if (!row) return null;
return {
id: row.rule_id || row.id,
title: cleanTitle(row.title),
content: cleanText(row.content),
page: row.page_num,
source: row.source,
sourceAbbr: row.source_abbr,
category: row.category
};
} catch (e) {
console.error('Failed to read rule by id:', e);
logToFile('Error getting rule by id:', ruleId, e);
return null;
}
}
// Get all rule categories
router.get('/categories', (req, res) => {
router.get('/categories', async (req, res) => {
try {
const rows = getAllRules();
const categories = [...new Set(rows.map(r => r.category).filter(Boolean))];
const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) }));
res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]);
const rows = await getAllRules();
const categories = [...new Set(rows.map(r => r.category).filter(Boolean))];
const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) }));
res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]);
} catch (error) {
console.error('Categories error:', error);
res.status(500).json({ error: 'Failed to get categories' });
}
});
// Search rules (sqlite-backed)
router.get('/search', (req, res) => {
// Search rules (MariaDB)
router.get('/search', async (req, res) => {
try {
const { q: query, category, limit = 20 } = req.query;
if (!query || !query.trim()) return res.json([]);
const limitInt = Math.max(1, parseInt(limit) || 20);
const term = `%${query}%`;
let rows;
if (category && category !== 'all') {
rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) AND category = ? ORDER BY id LIMIT ?').all(term, term, category, limitInt);
if (!rows || rows.length === 0) {
rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) ORDER BY id LIMIT ?').all(term, term, limitInt);
}
} else {
rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) ORDER BY id LIMIT ?').all(term, term, limitInt);
const allRules = await getAllRules();
const term = query.toLowerCase();
let filtered = allRules.filter(rule => {
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
const categoryMatch = !category || category === 'all' || rule.category === category;
return (titleMatch || contentMatch) && categoryMatch;
});
// If category filtering yielded no results, try without category filter
if (filtered.length === 0 && category && category !== 'all') {
filtered = allRules.filter(rule => {
const titleMatch = rule.title && rule.title.toLowerCase().includes(term);
const contentMatch = rule.content && rule.content.toLowerCase().includes(term);
return titleMatch || contentMatch;
});
}
const results = (rows || []).map(r => ({ ...r, content: r.content && r.content.length > 300 ? r.content.substring(0,300) + '...' : r.content }));
// Clean text fields before returning
const cleaned = (results || []).map(r => ({ ...r, title: cleanTitle(r.title), content: r.content ? cleanText(r.content) : r.content }));
res.json(cleaned);
// Limit results and truncate content
const results = filtered.slice(0, limitInt).map(r => ({
...r,
content: r.content && r.content.length > 300 ? r.content.substring(0, 300) + '...' : r.content
}));
res.json(results);
} catch (error) {
console.error('Search error:', error);
res.status(500).json({ error: 'Search failed' });
@@ -95,10 +128,10 @@ router.get('/search', (req, res) => {
});
// Get a specific rule by ID
router.get('/rule/:id', (req, res) => {
router.get('/rule/:id', async (req, res) => {
try {
const { id } = req.params;
const rule = getRuleById(id);
const rule = await getRuleById(id);
if (!rule) return res.status(404).json({ error: 'Rule not found' });
res.json(rule);
} catch (error) {
@@ -108,20 +141,26 @@ router.get('/rule/:id', (req, res) => {
});
// Get random rules for discovery
router.get('/random', (req, res) => {
router.get('/random', async (req, res) => {
try {
const { count = 5, category } = req.query;
const max = Math.max(1, parseInt(count) || 5);
let rows;
const allRules = await getAllRules();
let filtered = allRules;
if (category && category !== 'all') {
rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE category = ? ORDER BY RANDOM() LIMIT ?').all(category, max);
} else {
rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules ORDER BY RANDOM() LIMIT ?').all(max);
filtered = allRules.filter(rule => rule.category === category);
}
const randomRules = (rows || []).map(r => ({ ...r, content: r.content && r.content.length > 200 ? r.content.substring(0,200) + '...' : r.content }));
// Clean before responding
const cleaned = (randomRules || []).map(r => ({ ...r, title: cleanTitle(r.title), content: r.content ? cleanText(r.content) : r.content }));
res.json(cleaned);
// Shuffle and pick random rules
const shuffled = filtered.sort(() => 0.5 - Math.random());
const randomRules = shuffled.slice(0, max).map(r => ({
...r,
content: r.content && r.content.length > 200 ? r.content.substring(0, 200) + '...' : r.content
}));
res.json(randomRules);
} catch (error) {
console.error('Random rules error:', error);
res.status(500).json({ error: 'Failed to get random rules' });
@@ -129,11 +168,26 @@ router.get('/random', (req, res) => {
});
// Get rules statistics
router.get('/stats', (req, res) => {
router.get('/stats', async (req, res) => {
try {
const totalRules = db.prepare('SELECT COUNT(*) as c FROM rules').get().c;
const categories = db.prepare('SELECT category, COUNT(*) as c FROM rules GROUP BY category').all();
const sources = db.prepare('SELECT source, COUNT(*) as c FROM rules GROUP BY source').all();
const allRules = await getAllRules();
const totalRules = allRules.length;
const categoryCount = {};
const sourceCount = {};
allRules.forEach(rule => {
if (rule.category) {
categoryCount[rule.category] = (categoryCount[rule.category] || 0) + 1;
}
if (rule.source) {
sourceCount[rule.source] = (sourceCount[rule.source] || 0) + 1;
}
});
const categories = Object.entries(categoryCount).map(([category, c]) => ({ category, c }));
const sources = Object.entries(sourceCount).map(([source, c]) => ({ source, c }));
res.json({ totalRules, categories, sources, searchTerms: 0 });
} catch (error) {
console.error('Stats error:', error);
@@ -142,12 +196,15 @@ router.get('/stats', (req, res) => {
});
// Reload the rules database (admin only)
router.post('/reload', (req, res) => {
router.post('/reload', async (req, res) => {
try {
const gmSecret = req.headers['x-gm-secret'];
if (gmSecret !== 'bongo') return res.status(403).json({ error: 'Unauthorized' });
const totalRules = db.prepare('SELECT COUNT(*) as c FROM rules').get().c;
res.json({ success: true, totalRules, message: 'Rules are sqlite-backed; no reload necessary' });
const allRules = await getAllRules();
const totalRules = allRules.length;
res.json({ success: true, totalRules, message: 'Rules are MariaDB-backed; already loaded' });
} catch (error) {
console.error('Reload error:', error);
res.status(500).json({ error: 'Failed to reload database' });

View File

@@ -1,11 +1,11 @@
const express = require('express');
const router = express.Router();
const { db, logToFile, stagingHelpers } = require('../sqlite-db');
const { stagingHelpers, rulesHelpers, logToFile } = require('../mariadb');
// List staged sanitized rules
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
try {
const rows = stagingHelpers.list();
const rows = await stagingHelpers.getAll();
res.json(rows);
} catch (e) {
logToFile('staging:list:error', e && e.message);
@@ -14,18 +14,30 @@ router.get('/', (req, res) => {
});
// Approve staged rules: insert into rules table (appends) and clear staging
router.post('/approve', (req, res) => {
router.post('/approve', async (req, res) => {
try {
const rows = stagingHelpers.list();
const insert = db.prepare(`INSERT INTO rules (rule_id,title,content,page,source,source_abbr,category,created_at) VALUES (?,?,?,?,?,?,?,datetime('now'))`);
const insertMany = db.transaction((items) => {
items.forEach((it) => {
insert.run(null, it.title || '', it.content || '', it.page || '', 'sanitized', 'SAN', it.category || null);
});
});
insertMany(rows);
stagingHelpers.clear();
res.json({ success: true, inserted: rows.length });
const rows = await stagingHelpers.getAll();
let inserted = 0;
for (const rule of rows) {
const ruleData = {
title: rule.title || '',
content: rule.content || '',
page_num: rule.page || '',
source: 'sanitized',
source_abbr: 'SAN',
category: rule.category || null,
rulebook: 'staging'
};
const result = await rulesHelpers.create(ruleData);
if (result) {
inserted++;
await stagingHelpers.delete(rule.id);
}
}
res.json({ success: true, inserted });
} catch (e) {
logToFile('staging:approve:error', e && e.stack ? e.stack : e);
res.status(500).json({ error: e.message });
@@ -33,9 +45,12 @@ router.post('/approve', (req, res) => {
});
// Clear staging without approving
router.delete('/', (req, res) => {
router.delete('/', async (req, res) => {
try {
const del = stagingHelpers.clear();
const rows = await stagingHelpers.getAll();
for (const rule of rows) {
await stagingHelpers.delete(rule.id);
}
res.json({ success: true });
} catch (e) {
logToFile('staging:clear:error', e && e.message);

View File

@@ -1,111 +0,0 @@
const express = require('express');
const { sessionHelpers, playerHelpers } = require('../sqlite-db');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// Simple file logger
function logToFile(...args) {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' });
}
const router = express.Router();
// Create a new session (login)
router.post('/login', async (req, res) => {
try {
const { playerName, username, password } = req.body;
const name = playerName || username; // Accept either field name
if (!name) {
logToFile('SESSION: Login missing playerName/username');
return res.status(400).json({ error: 'playerName or username required' });
}
// Check if player exists
const player = playerHelpers.getByName(name);
if (!player) {
logToFile('SESSION: Login player not found', name);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check password if provided
if (password) {
// For now, compare with plain text (you can add bcrypt later)
const playerPw = player.pw || '';
if (playerPw !== password) {
logToFile('SESSION: Login invalid password', name);
return res.status(401).json({ error: 'Invalid credentials' });
}
}
// Create session
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24h
sessionHelpers.create(sessionId, { playerName: name }, expiresAt);
logToFile('SESSION: Login success', name, sessionId);
res.json({ sessionId, expiresAt, playerName: name, success: true });
} catch (error) {
logToFile('SESSION: Login error', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Validate session
router.post('/validate', async (req, res) => {
try {
const { sessionId } = req.body;
if (!sessionId) {
logToFile('SESSION: Validate missing sessionId');
return res.status(400).json({ error: 'sessionId required' });
}
const session = sessionHelpers.get(sessionId);
if (!session) {
logToFile('SESSION: Validate not found', sessionId);
return res.status(401).json({ error: 'Invalid or expired session' });
}
logToFile('SESSION: Validate success', sessionId, session.data.playerName);
res.json({ valid: true, playerName: session.data.playerName });
} catch (error) {
logToFile('SESSION: Validate error', error);
res.status(500).json({ error: 'Validation failed' });
}
});
// Logout (delete session)
router.post('/logout', async (req, res) => {
try {
const { sessionId } = req.body;
if (sessionId) {
sessionHelpers.delete(sessionId);
logToFile('SESSION: Logout success', sessionId);
}
res.json({ success: true });
} catch (error) {
logToFile('SESSION: Logout error', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Clean expired sessions (can be called periodically)
router.post('/cleanup', async (req, res) => {
try {
const deletedCount = sessionHelpers.cleanExpired();
logToFile('SESSION: Cleanup completed', `${deletedCount} sessions removed`);
res.json({ deletedCount });
} catch (error) {
logToFile('SESSION: Cleanup error', error);
res.status(500).json({ error: 'Cleanup failed' });
}
});
module.exports = router;

View File

@@ -1,50 +1,37 @@
const express = require('express');
const Session = require('../sessionModel');
const { logToFile } = require('../mariadb');
const router = express.Router();
const crypto = require('crypto');
// Create a new session (login)
router.post('/login', async (req, res) => {
const { playerName } = req.body;
if (!playerName) return res.status(400).json({ error: 'playerName required' });
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 24h
const session = new Session({ sessionId, playerName, expiresAt });
await session.save();
res.json({ sessionId, expiresAt });
});
// Validate session
const fs = require('fs');
const path = require('path');
function logToFile(...args) {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' });
}
// Simple session validation endpoint
router.post('/validate', async (req, res) => {
const { sessionId } = req.body;
if (!sessionId) {
logToFile('SESSION: Validate missing sessionId');
return res.status(400).json({ error: 'sessionId required' });
try {
const { sessionId } = req.body;
if (!sessionId) {
logToFile('SESSION: Validate missing sessionId');
return res.status(400).json({ error: 'sessionId required' });
}
// Extract player name from session ID (simple format: session_playername_timestamp)
const match = sessionId.match(/^session_([^_]+)_\d+$/);
if (!match) {
logToFile('SESSION: Invalid session format', sessionId);
return res.status(401).json({ error: 'Invalid session format' });
}
const playerName = match[1];
logToFile('SESSION: Session validation successful', playerName);
res.json({
valid: true,
playerName
});
} catch (error) {
console.error('Session validation error:', error);
logToFile('SESSION: Failed to validate session', error);
res.status(500).json({ error: String(error) });
}
const session = await Session.findOne({ sessionId });
if (!session) {
logToFile('SESSION: Validate not found', sessionId);
return res.status(401).json({ error: 'Invalid or expired session' });
}
if (session.expiresAt < new Date()) {
logToFile('SESSION: Validate expired', sessionId);
return res.status(401).json({ error: 'Invalid or expired session' });
}
logToFile('SESSION: Validate success', sessionId, session.playerName);
res.json({ valid: true, playerName: session.playerName });
});
// Logout (delete session)
router.post('/logout', async (req, res) => {
const { sessionId } = req.body;
await Session.deleteOne({ sessionId });
res.json({ success: true });
});
console.log('Session routes registered (simple validation)');
module.exports = router;

View File

@@ -2,6 +2,15 @@ const express = require('express');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const { playerHelpers } = require('../mariadb'); // Use MariaDB instead of SQLite
console.log('ShopRoutes: Loading with purchase endpoint (MariaDB)');
// Simple file logger
function logToFile(...args) {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' });
}
// Minimal shop index - attempt to serve a generated armoury/shop JSON if present
router.get('/', (req, res) => {
@@ -18,4 +27,125 @@ router.get('/', (req, res) => {
}
});
// Test route to verify routes are working
router.get('/test', (req, res) => {
res.json({ message: 'Shop routes are working' });
});
// Purchase item endpoint
router.post('/purchase', async (req, res) => {
console.log('ShopRoutes: Purchase endpoint hit!');
try {
const { playerId, itemId, quantity = 1 } = req.body;
const sessionId = req.headers['x-session-id'];
if (!sessionId) {
return res.status(401).json({ error: 'Session required' });
}
if (!playerId || !itemId) {
return res.status(400).json({ error: 'Player ID and Item ID required' });
}
// Get the player
const players = await playerHelpers.getAll();
const player = players.find(p => p.id === playerId || p.name === playerId);
if (!player) {
return res.status(404).json({ error: 'Player not found' });
}
// Get shop data to find the item
const shopPath = path.join(__dirname, '..', '..', 'public', 'deathwatch-armoury.json');
if (!fs.existsSync(shopPath)) {
return res.status(500).json({ error: 'Shop data not available' });
}
const shopData = JSON.parse(fs.readFileSync(shopPath, 'utf8'));
let foundItem = null;
// Search for the item in all categories
for (const category in shopData.items) {
if (shopData.items[category]) {
foundItem = shopData.items[category].find(item =>
(item.id === itemId) || (`${category}-${item.name}` === itemId)
);
if (foundItem) break;
}
}
if (!foundItem) {
return res.status(404).json({ error: 'Item not found in shop' });
}
// Check if item has cost > 0 (purchasable)
const itemCost = foundItem.req || 0;
if (itemCost <= 0) {
return res.status(400).json({ error: 'This item is not purchasable' });
}
// Check player RP
const playerRp = player.tabInfo?.rp || 0;
const totalCost = itemCost * quantity;
if (playerRp < totalCost) {
return res.status(400).json({
error: `Insufficient Requisition Points. Need ${totalCost} RP but only have ${playerRp} RP.`
});
}
// Add item to player gear and deduct RP
const updatedTabInfo = { ...player.tabInfo };
updatedTabInfo.rp = playerRp - totalCost;
// Initialize gear if it doesn't exist
if (!updatedTabInfo.gear) {
updatedTabInfo.gear = [];
}
// Create gear item in the format expected by character sheet
const gearItem = {
name: foundItem.name,
qty: quantity
};
// Check if item already exists in gear
const existingGearIndex = updatedTabInfo.gear.findIndex(gearItem =>
gearItem.name === foundItem.name
);
if (existingGearIndex >= 0) {
// Update existing item quantity
updatedTabInfo.gear[existingGearIndex].qty += quantity;
} else {
// Add new item to gear
updatedTabInfo.gear.push(gearItem);
}
// Update player data
const success = await playerHelpers.update(player.name, {
...player,
tabInfo: updatedTabInfo
});
if (!success) {
return res.status(500).json({ error: 'Failed to update player data' });
}
logToFile(`Purchase: ${player.name} bought ${quantity}x ${foundItem.name} for ${totalCost} RP`);
res.json({
success: true,
message: `Successfully purchased ${quantity}x ${foundItem.name} for ${totalCost} RP`,
newRp: updatedTabInfo.rp,
item: gearItem
});
} catch (error) {
console.error('Purchase error:', error);
logToFile('API: Purchase failed', error);
res.status(500).json({ error: 'Purchase failed' });
}
});
module.exports = router;

View File

@@ -1,19 +1,24 @@
const express = require('express')
const router = express.Router()
const { db } = require('../sqlite-db')
const { weaponsHelpers, logToFile } = require('../mariadb')
// Return weapons in a normalized shape
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
try {
const rows = db.prepare('SELECT id,name,category,stats,source FROM weapons ORDER BY name').all()
const rows = await weaponsHelpers.getAll()
const parsed = rows.map(r => {
let stats = {}
try { stats = JSON.parse(r.stats || '{}') } catch (e) {}
try {
stats = typeof r.stats === 'string' ? JSON.parse(r.stats) : r.stats || {}
} catch (e) {
logToFile('Error parsing weapon stats for', r.name, e)
}
return { id: r.id, name: r.name, category: r.category, stats: stats, source: r.source }
})
res.json(parsed)
} catch (e) {
console.error('Failed to load weapons from sqlite:', e)
console.error('Failed to load weapons from MariaDB:', e)
logToFile('Error getting weapons:', e)
res.status(500).json({ error: 'Failed to get weapons' })
}
})

View File

@@ -1,64 +0,0 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const playerRoutes = require('./routes/playerRoutes-sqlite');
const sessionRoutes = require('./routes/sessionRoutes-sqlite');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(express.json());
app.use(cors());
app.use(express.static('public')); // Serve files from public directory
// Initialize SQLite database
const { db } = require('./sqlite-db');
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Closing SQLite database...');
db.close();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('Closing SQLite database...');
db.close();
process.exit(0);
});
// Root route for friendly message
app.get('/', (req, res) => {
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
});
// Shop endpoint
app.get('/api/shop', (req, res) => {
try {
console.log('Shop endpoint hit');
const filepath = path.join(__dirname, '../public/deathwatch-armoury.json');
console.log('Looking for shop data at:', filepath);
console.log('File exists:', fs.existsSync(filepath));
const shopData = JSON.parse(fs.readFileSync(filepath, 'utf8'));
console.log('Shop data loaded, keys:', Object.keys(shopData));
res.json(shopData);
} catch (error) {
console.error('Shop error:', error);
res.status(500).json({ error: String(error) });
}
});
// Use routes
app.use('/api/players', playerRoutes);
app.use('/api/sessions', sessionRoutes);
// Start Server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
console.log('Using SQLite database');
});
module.exports = app;

View File

@@ -3,8 +3,9 @@ const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const playerRoutes = require('./routes/playerRoutes-sqlite');
const sessionRoutes = require('./routes/sessionRoutes-sqlite');
// MariaDB routes
const playerRoutes = require('./routes/playerRoutes');
const sessionRoutes = require('./routes/sessionRoutes');
const shopRoutes = require('./routes/shopRoutes');
const rulesRoutes = require('./routes/rulesRoutes');
const bestiaryRoutes = require('./routes/bestiaryRoutes');
@@ -14,12 +15,14 @@ const rulesStagingRoutes = require('./routes/rulesStagingRoutes');
const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit');
// Initialize MariaDB
require('./mariadb');
console.log('Routes loaded:', {
playerRoutes: typeof playerRoutes,
sessionRoutes: typeof sessionRoutes,
shopRoutes: typeof shopRoutes,
rulesRoutes: typeof rulesRoutes,
bestiaryRoutes: typeof bestiaryRoutes
bestiaryRoutes: typeof bestiaryRoutes,
weaponsRoutes: typeof weaponsRoutes
});
const app = express();
@@ -31,6 +34,7 @@ app.use(cors());
// API Routes (before static files)
console.log('Registering API routes...');
// Player routes now working with MariaDB
try {
console.log('Registering /api/players');
app.use('/api/players', playerRoutes);
@@ -62,7 +66,25 @@ try {
} catch (e) {
console.error('Error mounting /api/rules:', e && e.stack ? e.stack : e);
throw e;
}
}
try {
console.log('Registering /api/weapons');
app.use('/api/weapons', weaponsRoutes);
console.log('Weapons routes registered');
} catch (e) {
console.error('Error mounting /api/weapons:', e && e.stack ? e.stack : e);
throw e;
}
try {
console.log('Registering /api/bestiary');
app.use('/api/bestiary', bestiaryRoutes);
console.log('Bestiary routes registered');
} catch (e) {
console.error('Error mounting /api/bestiary:', e && e.stack ? e.stack : e);
throw e;
}
try {
console.log('Registering /api/rules/staging');
@@ -70,9 +92,7 @@ try {
console.log('Rules staging routes registered');
} catch (e) {
console.error('Error mounting /api/rules/staging:', e && e.stack ? e.stack : e);
}
// Expose gamemaster kit files and a simple listing API for GM-only resources
} // Expose gamemaster kit files and a simple listing API for GM-only resources
try {
console.log('Registering /api/gmkit and /gmkit static');
app.get('/api/gmkit/list', (req, res) => {
@@ -157,19 +177,14 @@ app.use('/avatars', express.static(avatarsDir));
// res.sendFile(path.join(buildDir, 'index.html'));
// });
// Initialize SQLite database
const { db } = require('./sqlite-db');
// Graceful shutdown
// Graceful shutdown - MariaDB connections are handled by the pool
process.on('SIGINT', () => {
console.log('Closing SQLite database...');
db.close();
console.log('Shutting down server...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('Closing SQLite database...');
db.close();
console.log('Shutting down server...');
process.exit(0);
});
@@ -188,34 +203,16 @@ if (fs.existsSync(indexHtml)) {
} else {
// Root route for friendly message when no build is present
app.get('/', (req, res) => {
res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.');
res.send('Deathwatch Roller API is running with MariaDB. Use /api/shop for shop data.');
});
}
// Use routes (instrument mounts to debug invalid route patterns)
console.log('Mounting route: /api/shop');
app.use('/api/shop', shopRoutes);
console.log('Mounted /api/shop');
console.log('Mounting route: /api/players');
app.use('/api/players', playerRoutes);
console.log('Mounted /api/players');
console.log('Mounting route: /api/sessions');
app.use('/api/sessions', sessionRoutes);
console.log('Mounted /api/sessions');
console.log('Mounting route: /api/rules');
app.use('/api/rules', rulesRoutes);
console.log('Mounted /api/rules');
console.log('Mounting route: /api/bestiary');
app.use('/api/bestiary', bestiaryRoutes);
console.log('Mounted /api/bestiary');
console.log('Mounting route: /api/weapons');
app.use('/api/weapons', weaponsRoutes);
console.log('Mounted /api/weapons');
// Routes have already been registered above - no need to duplicate
// Start Server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
console.log('Using SQLite database');
console.log('Using MariaDB database');
});
module.exports = app;

View File

View File

@@ -1,272 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Simple DB logger to backend.log
const backendLogPath = path.join(__dirname, 'backend.log');
function logToFile(...args) {
try {
const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n';
fs.appendFileSync(backendLogPath, msg, { encoding: 'utf8' });
} catch (err) {
console.error('Failed to write backend log', err);
}
}
// Create database directory if it doesn't exist
const dbDir = path.join(__dirname, 'sqlite');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const dbPath = path.join(dbDir, 'deathwatch.db');
const db = new Database(dbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
// Create tables
const createTables = () => {
// Players table
db.exec(`
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
roller_info TEXT DEFAULT '{}',
shop_info TEXT DEFAULT '{}',
tab_info TEXT DEFAULT '{}',
pw TEXT DEFAULT '',
pw_hash TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Sessions table (if you need it)
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
data TEXT DEFAULT '{}',
expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_players_name ON players(name);
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS rules_staging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
category TEXT,
page TEXT,
original_json TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rules_staging_category ON rules_staging(category);
`);
console.log('SQLite tables created successfully');
};
// Initialize database
createTables();
// Prepared statements for common operations
const statements = {
// Player operations
getAllPlayers: db.prepare('SELECT * FROM players ORDER BY name'),
getPlayerByName: db.prepare('SELECT * FROM players WHERE name = ?'),
insertPlayer: db.prepare(`
INSERT INTO players (name, roller_info, shop_info, tab_info, pw, pw_hash)
VALUES (?, ?, ?, ?, ?, ?)
`),
updatePlayer: db.prepare(`
UPDATE players
SET roller_info = ?, shop_info = ?, tab_info = ?, pw = ?, pw_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
`),
deletePlayer: db.prepare('DELETE FROM players WHERE name = ?'),
// Session operations
getSession: db.prepare('SELECT * FROM sessions WHERE session_id = ? AND expires_at > datetime(\'now\')'),
insertSession: db.prepare(`
INSERT INTO sessions (session_id, data, expires_at)
VALUES (?, ?, ?)
`),
updateSession: db.prepare(`
UPDATE sessions
SET data = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?
`),
deleteSession: db.prepare('DELETE FROM sessions WHERE session_id = ?'),
cleanExpiredSessions: db.prepare('DELETE FROM sessions WHERE expires_at <= datetime(\'now\')')
};
// Helper functions
const playerHelpers = {
getAll: () => {
logToFile('DB: getAllPlayers - start');
const rows = statements.getAllPlayers.all();
const result = rows.map(row => ({
name: row.name,
rollerInfo: JSON.parse(row.roller_info || '{}'),
shopInfo: JSON.parse(row.shop_info || '{}'),
tabInfo: JSON.parse(row.tab_info || '{}'),
pw: row.pw,
pwHash: row.pw_hash,
_id: row.id,
createdAt: row.created_at,
updatedAt: row.updated_at
}));
logToFile('DB: getAllPlayers - resultCount', result.length);
return result;
},
getByName: (name) => {
logToFile('DB: getPlayerByName - start', name);
const row = statements.getPlayerByName.get(name);
if (!row) {
logToFile('DB: getPlayerByName - not found', name);
return null;
}
const result = {
name: row.name,
rollerInfo: JSON.parse(row.roller_info || '{}'),
shopInfo: JSON.parse(row.shop_info || '{}'),
tabInfo: JSON.parse(row.tab_info || '{}'),
pw: row.pw,
pwHash: row.pw_hash,
_id: row.id,
createdAt: row.created_at,
updatedAt: row.updated_at
};
logToFile('DB: getPlayerByName - found', name);
return result;
},
create: (playerData) => {
logToFile('DB: createPlayer - start', playerData.name);
const result = statements.insertPlayer.run(
playerData.name,
JSON.stringify(playerData.rollerInfo || {}),
JSON.stringify(playerData.shopInfo || {}),
JSON.stringify(playerData.tabInfo || {}),
playerData.pw || '',
playerData.pwHash || ''
);
const out = { ...playerData, _id: result.lastInsertRowid };
logToFile('DB: createPlayer - done', playerData.name, 'rowid', result.lastInsertRowid);
return out;
},
update: (name, playerData) => {
logToFile('DB: updatePlayer - start', name);
// Ensure we don't have nested tabInfo
const cleanTabInfo = playerData.tabInfo || {};
if (cleanTabInfo.tabInfo) {
logToFile('DB: updatePlayer - fixing nested tabInfo structure');
Object.assign(cleanTabInfo, cleanTabInfo.tabInfo);
delete cleanTabInfo.tabInfo;
}
const result = statements.updatePlayer.run(
JSON.stringify(playerData.rollerInfo || {}),
JSON.stringify(playerData.shopInfo || {}),
JSON.stringify(cleanTabInfo),
playerData.pw || '',
playerData.pwHash || '',
name
);
logToFile('DB: updatePlayer - changes', result.changes, name);
return result.changes > 0;
},
delete: (name) => {
logToFile('DB: deletePlayer - start', name);
const result = statements.deletePlayer.run(name);
logToFile('DB: deletePlayer - changes', result.changes, name);
return result.changes > 0;
}
};
const sessionHelpers = {
get: (sessionId) => {
logToFile('DB: getSession - start', sessionId);
const row = statements.getSession.get(sessionId);
if (!row) {
logToFile('DB: getSession - not found', sessionId);
return null;
}
const out = {
sessionId: row.session_id,
data: JSON.parse(row.data || '{}'),
expiresAt: row.expires_at
};
logToFile('DB: getSession - ok', sessionId);
return out;
},
create: (sessionId, data, expiresAt) => {
logToFile('DB: createSession - start', sessionId, 'expiresAt', expiresAt);
const res = statements.insertSession.run(sessionId, JSON.stringify(data), expiresAt);
logToFile('DB: createSession - done', sessionId);
},
update: (sessionId, data, expiresAt) => {
logToFile('DB: updateSession - start', sessionId);
const result = statements.updateSession.run(JSON.stringify(data), expiresAt, sessionId);
logToFile('DB: updateSession - changes', result.changes, sessionId);
return result.changes > 0;
},
delete: (sessionId) => {
logToFile('DB: deleteSession - start', sessionId);
const result = statements.deleteSession.run(sessionId);
logToFile('DB: deleteSession - changes', result.changes, sessionId);
return result.changes > 0;
},
cleanExpired: () => {
logToFile('DB: cleanExpiredSessions - start');
const result = statements.cleanExpiredSessions.run();
logToFile('DB: cleanExpiredSessions - removed', result.changes);
return result.changes;
}
};
// Staging helpers for sanitized rules
const stagingStatements = {
insertStaging: db.prepare('INSERT INTO rules_staging (title, content, category, page, original_json) VALUES (?,?,?,?,?)'),
listStaging: db.prepare('SELECT id, title, content, category, page, original_json, created_at FROM rules_staging ORDER BY id'),
deleteStagingAll: db.prepare('DELETE FROM rules_staging'),
getStaging: db.prepare('SELECT id, title, content, category, page, original_json, created_at FROM rules_staging WHERE id = ?')
};
const stagingHelpers = {
insert: (obj) => {
const res = stagingStatements.insertStaging.run(obj.title || '', obj.content || '', obj.category || '', obj.page || '', JSON.stringify(obj.original || {}));
return res.lastInsertRowid;
},
list: () => stagingStatements.listStaging.all().map(r => ({ ...r, original: JSON.parse(r.original_json || '{}') })),
clear: () => stagingStatements.deleteStagingAll.run(),
get: (id) => {
const row = stagingStatements.getStaging.get(id);
if (!row) return null;
return { ...row, original: JSON.parse(row.original_json || '{}') };
}
};
module.exports = {
db,
statements,
playerHelpers,
sessionHelpers,
stagingHelpers,
close: () => db.close(),
logToFile
};

Binary file not shown.