'use strict'; const sqlite3 = require('sqlite3').verbose(); const { open } = require('sqlite'); const path = require('path'); const fs = require('fs'); class Database { constructor() { this.db = null; } async initialize(dbPath) { // Ensure directory exists const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } this.db = await open({ filename: dbPath, driver: sqlite3.Database }); // Enable foreign key enforcement (required for ON DELETE CASCADE) await this.db.run('PRAGMA foreign_keys = ON'); await this.createTables(); await this.insertDefaultPermissions(); console.log('Database initialized:', dbPath); return this.db; } async createTables() { // Permissions table await this.db.exec(` CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT UNIQUE NOT NULL, role TEXT DEFAULT 'team' NOT NULL CHECK(role IN ('owner', 'team', 'readonly')), joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Chests table await this.db.exec(` CREATE TABLE IF NOT EXISTS chests ( id INTEGER PRIMARY KEY AUTOINCREMENT, pos_x INTEGER NOT NULL, pos_y INTEGER NOT NULL, pos_z INTEGER NOT NULL, chest_type TEXT NOT NULL CHECK(chest_type IN ('single', 'double')), row INTEGER NOT NULL, column INTEGER NOT NULL, category TEXT, last_scan TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(pos_x, pos_y, pos_z) ) `); // Shulkers table await this.db.exec(` CREATE TABLE IF NOT EXISTS shulkers ( id INTEGER PRIMARY KEY AUTOINCREMENT, chest_id INTEGER NOT NULL, slot INTEGER NOT NULL, shulker_type TEXT DEFAULT 'shulker_box', category TEXT, item_focus TEXT, slot_count INTEGER DEFAULT 0, total_items INTEGER DEFAULT 0, last_scan TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE, UNIQUE(chest_id, slot) ) `); // Shulker items table await this.db.exec(` CREATE TABLE IF NOT EXISTS shulker_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, shulker_id INTEGER NOT NULL, item_name TEXT NOT NULL, item_id INTEGER NOT NULL, slot INTEGER NOT NULL, count INTEGER NOT NULL, nbt_data TEXT, FOREIGN KEY (shulker_id) REFERENCES shulkers(id) ON DELETE CASCADE, UNIQUE(shulker_id, slot), CHECK(slot >= 0 AND slot <= 26), CHECK(count > 0 AND count <= 64) ) `); // Trades table await this.db.exec(` CREATE TABLE IF NOT EXISTS trades ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('deposit', 'withdraw')), items TEXT NOT NULL, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Chest loose items table (non-shulker items sitting directly in chests) await this.db.exec(` CREATE TABLE IF NOT EXISTS chest_loose_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, chest_id INTEGER NOT NULL, slot INTEGER NOT NULL, item_name TEXT NOT NULL, item_id INTEGER NOT NULL, count INTEGER NOT NULL, FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE, UNIQUE(chest_id, slot) ) `); // Item index for fast searches await this.db.exec(` CREATE TABLE IF NOT EXISTS item_index ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER UNIQUE NOT NULL, item_name TEXT NOT NULL, total_count INTEGER DEFAULT 0, shulker_ids TEXT, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Maps table (filled map images) await this.db.exec(` CREATE TABLE IF NOT EXISTS maps ( id INTEGER PRIMARY KEY AUTOINCREMENT, map_id INTEGER UNIQUE NOT NULL, image_data TEXT, pixel_data TEXT, captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Invite sites table await this.db.exec(` CREATE TABLE IF NOT EXISTS invite_sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, label TEXT NOT NULL, bot_name TEXT NOT NULL, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Invite permissions table await this.db.exec(` CREATE TABLE IF NOT EXISTS invite_permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, player_name TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES invite_sites(id) ON DELETE CASCADE, UNIQUE(site_id, player_name) ) `); } async insertDefaultPermissions() { const conf = require('../../conf'); const defaultPlayers = conf.storage?.defaultPlayers || []; for (const player of defaultPlayers) { try { await this.db.run( 'INSERT OR IGNORE INTO permissions (player_name, role) VALUES (?, ?)', [player.name, player.role] ); } catch (error) { console.error('Error inserting default player:', player.name, error); } } } // ======================================== // Permissions // ======================================== async addPlayer(name, Role = 'team') { return await this.db.run( 'INSERT INTO permissions (player_name, role) VALUES (?, ?)', [name, Role] ); } async removePlayer(name) { return await this.db.run('DELETE FROM permissions WHERE player_name = ?', [name]); } async getPlayerRole(name) { const row = await this.db.get('SELECT role FROM permissions WHERE player_name = ?', [name]); return row ? row.role : null; } async getAllPlayers() { return await this.db.all('SELECT * FROM permissions ORDER BY role DESC, player_name ASC'); } async checkPermission(name, requiredRole) { const role = await this.getPlayerRole(name); if (!role) return false; const roles = ['readonly', 'team', 'owner']; return roles.indexOf(role) >= roles.indexOf(requiredRole); } // ======================================== // Chests // ======================================== async upsertChest(x, y, z, chestType, row, column, category = null) { return await this.db.run(` INSERT INTO chests (pos_x, pos_y, pos_z, chest_type, row, column, category) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(pos_x, pos_y, pos_z) DO UPDATE SET chest_type = excluded.chest_type, row = excluded.row, column = excluded.column, category = excluded.category, last_scan = CURRENT_TIMESTAMP `, [x, y, z, chestType, row, column, category]); } async getChests() { return await this.db.all('SELECT * FROM chests ORDER BY row, column'); } async getChestById(id) { return await this.db.get('SELECT * FROM chests WHERE id = ?', [id]); } async getChestByPosition(x, y, z) { return await this.db.get( 'SELECT * FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?', [x, y, z] ); } async deleteOrphanChests(knownChestPositions) { // knownChestPositions is array of {x, y, z} if (knownChestPositions.length === 0) return; const placeholders = knownChestPositions.map(() => '(?, ?, ?)').join(', '); const values = knownChestPositions.flatMap(p => [p.x, p.y, p.z]); return await this.db.run(` DELETE FROM chests WHERE (pos_x, pos_y, pos_z) NOT IN (${placeholders}) `, values); } // ======================================== // Shulkers // ======================================== async upsertShulker(chestId, slot, shulkerType, category = null, itemFocus = null) { const result = await this.db.run(` INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus) VALUES (?, ?, ?, ?, ?) ON CONFLICT(chest_id, slot) DO UPDATE SET shulker_type = excluded.shulker_type, category = COALESCE(excluded.category, shulkers.category), item_focus = COALESCE(excluded.item_focus, shulkers.item_focus), last_scan = CURRENT_TIMESTAMP `, [chestId, slot, shulkerType, category, itemFocus]); return result.lastID; } async upsertAndGetShulker(chestId, slot, shulkerType, category = null) { await this.db.run(` INSERT INTO shulkers (chest_id, slot, shulker_type, category) VALUES (?, ?, ?, ?) ON CONFLICT(chest_id, slot) DO UPDATE SET shulker_type = excluded.shulker_type, category = COALESCE(excluded.category, shulkers.category), last_scan = CURRENT_TIMESTAMP `, [chestId, slot, shulkerType, category]); return await this.db.get( 'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?', [chestId, slot] ); } async getShulkersByChest(chestId) { return await this.db.all('SELECT * FROM shulkers WHERE chest_id = ? ORDER BY slot', [chestId]); } async getShulkerById(id) { return await this.db.get('SELECT * FROM shulkers WHERE id = ?', [id]); } async getShulkerByChestSlot(chestId, slot) { return await this.db.get( 'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?', [chestId, slot] ); } async updateShulkerCounts(shulkerId, slotCount, totalItems) { return await this.db.run(` UPDATE shulkers SET slot_count = ?, total_items = ?, last_scan = CURRENT_TIMESTAMP WHERE id = ? `, [slotCount, totalItems, shulkerId]); } async updateShulkerItemFocus(shulkerId, itemFocus) { return await this.db.run( 'UPDATE shulkers SET item_focus = ? WHERE id = ?', [itemFocus, shulkerId] ); } async deleteShulker(id) { return await this.db.run('DELETE FROM shulkers WHERE id = ?', [id]); } // Find a shulker that already stores this item type and has space (<27 slots used, not in-transit) async findShulkerWithSpace(itemName, excludeId = null) { return await this.db.get(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id WHERE s.item_focus = ? AND s.slot_count >= 0 AND s.slot_count < 27 AND (? IS NULL OR s.id != ?) ORDER BY s.slot_count DESC LIMIT 1 `, [itemName, excludeId, excludeId]); } // Find any empty shulker (no item_focus, no items, not in-transit) async findEmptyShulker(excludeId = null) { return await this.db.get(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id WHERE s.item_focus IS NULL AND s.total_items = 0 AND s.slot_count >= 0 AND (? IS NULL OR s.id != ?) ORDER BY s.id ASC LIMIT 1 `, [excludeId, excludeId]); } // Find shulkers containing a specific item (for withdrawal, excludes in-transit and special/named items) async findShulkersWithItem(itemName) { return await this.db.all(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type, SUM(si.count) as available_count FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id INNER JOIN shulker_items si ON si.shulker_id = s.id WHERE si.item_name = ? AND s.slot_count >= 0 AND NOT ( si.nbt_data IS NOT NULL AND si.nbt_data != 'null' AND ( si.nbt_data LIKE '%"displayName"%' OR si.nbt_data LIKE '%"lore"%' OR si.nbt_data LIKE '%"customModelData"%' ) ) GROUP BY s.id ORDER BY available_count ASC `, [itemName]); } // Find item types that have more than one non-full shulker (candidates for consolidation) async findConsolidatableItems() { return await this.db.all(` SELECT s.item_focus, COUNT(*) as shulker_count, SUM(s.slot_count) as total_slots_used, SUM(s.total_items) as total_items FROM shulkers s WHERE s.item_focus IS NOT NULL AND s.slot_count >= 0 AND s.slot_count < 27 GROUP BY s.item_focus HAVING COUNT(*) > 1 ORDER BY total_slots_used ASC `); } // Get all non-full shulkers for a given item, sorted least-full first async getShulkersByItemFocus(itemName) { return await this.db.all(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id WHERE s.item_focus = ? AND s.slot_count >= 0 ORDER BY s.slot_count ASC `, [itemName]); } // Find a chest slot that doesn't have a shulker (for placing newly crafted ones) async findEmptyChestSlot() { const chests = await this.db.all(` SELECT c.*, COUNT(s.id) as shulker_count FROM chests c LEFT JOIN shulkers s ON s.chest_id = c.id GROUP BY c.id HAVING shulker_count < CASE WHEN c.chest_type = 'double' THEN 54 ELSE 27 END ORDER BY c.id ASC LIMIT 1 `); if (!chests || chests.length === 0) return null; const chest = chests[0]; const shulkers = await this.getShulkersByChest(chest.id); const usedSlots = new Set(shulkers.map(s => s.slot)); const maxSlots = chest.chest_type === 'double' ? 54 : 27; for (let i = 0; i < maxSlots; i++) { if (!usedSlots.has(i)) { return { chest_id: chest.id, pos_x: chest.pos_x, pos_y: chest.pos_y, pos_z: chest.pos_z, slot: i, }; } } return null; } // Get total count of a specific item across all shulkers (excludes special/named items) async getItemTotalCount(itemName) { const result = await this.db.get(` SELECT SUM(si.count) as total FROM shulker_items si WHERE si.item_name = ? AND NOT ( si.nbt_data IS NOT NULL AND si.nbt_data != 'null' AND ( si.nbt_data LIKE '%"displayName"%' OR si.nbt_data LIKE '%"lore"%' OR si.nbt_data LIKE '%"customModelData"%' ) ) `, [itemName]); return result?.total || 0; } // ======================================== // Shulker Items // ======================================== async upsertShulkerItem(shulkerId, itemId, itemName, slot, count, nbt = null) { return await this.db.run(` INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(shulker_id, slot) DO UPDATE SET item_id = excluded.item_id, item_name = excluded.item_name, count = excluded.count, nbt_data = excluded.nbt_data `, [shulkerId, itemId, itemName, slot, count, nbt ? JSON.stringify(nbt) : null]); } async batchUpsertShulkerItems(shulkerId, items) { if (!items.length) return; await this.db.run('BEGIN TRANSACTION'); try { const stmt = await this.db.prepare(` INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(shulker_id, slot) DO UPDATE SET item_id = excluded.item_id, item_name = excluded.item_name, count = excluded.count, nbt_data = excluded.nbt_data `); for (const item of items) { await stmt.run(shulkerId, item.id, item.name, item.slot, item.count, item.nbt ? JSON.stringify(item.nbt) : null); } await stmt.finalize(); await this.db.run('COMMIT'); } catch (error) { await this.db.run('ROLLBACK'); throw error; } } async getShulkerItems(shulkerId) { return await this.db.all('SELECT * FROM shulker_items WHERE shulker_id = ? ORDER BY slot', [shulkerId]); } async clearShulkerItems(shulkerId) { return await this.db.run('DELETE FROM shulker_items WHERE shulker_id = ?', [shulkerId]); } async getShulkerItemById(id) { return await this.db.get('SELECT * FROM shulker_items WHERE id = ?', [id]); } /** * Get all "special" items — those with NBT containing displayName, lore, or customModelData. * Returns items with location info (chest position, shulker slot). */ async getSpecialItems() { return await this.db.all(` SELECT si.*, s.slot as shulker_slot, s.chest_id, s.item_focus, c.pos_x, c.pos_y, c.pos_z FROM shulker_items si INNER JOIN shulkers s ON s.id = si.shulker_id INNER JOIN chests c ON c.id = s.chest_id WHERE si.nbt_data IS NOT NULL AND si.nbt_data != 'null' AND ( si.nbt_data LIKE '%"displayName"%' OR si.nbt_data LIKE '%"lore"%' OR si.nbt_data LIKE '%"customModelData"%' ) ORDER BY si.item_name, si.id `); } // ======================================== // Trades // ======================================== async logTrade(playerName, action, items) { return await this.db.run( 'INSERT INTO trades (player_name, action, items) VALUES (?, ?, ?)', [playerName, action, JSON.stringify(items)] ); } async getRecentTrades(limit = 50) { return await this.db.all( 'SELECT * FROM trades ORDER BY timestamp DESC LIMIT ?', [limit] ); } async getTradesByPlayer(playerName, limit = 50) { return await this.db.all( 'SELECT * FROM trades WHERE player_name = ? ORDER BY timestamp DESC LIMIT ?', [playerName, limit] ); } // ======================================== // Item Index // ======================================== async rebuildItemIndex() { // Clear and rebuild from shulker_items await this.db.run('DELETE FROM item_index'); await this.db.run(` INSERT INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated) SELECT si.item_id, si.item_name, SUM(si.count) as total_count, GROUP_CONCAT('{"id":' || si.shulker_id || ',"count":' || si.count || '}') as shulker_ids, CURRENT_TIMESTAMP FROM shulker_items si GROUP BY si.item_name `); const count = await this.db.get('SELECT COUNT(*) as c FROM item_index'); console.log(`Database: Rebuilt item index with ${count?.c || 0} entries`); } async searchItems(query) { // Query shulker_items plus empty shulkers as a virtual item if (!query) { return await this.db.all(` SELECT item_name, SUM(count) as total_count FROM ( SELECT item_name, count FROM shulker_items UNION ALL SELECT shulker_type AS item_name, 1 AS count FROM shulkers WHERE total_items = 0 AND item_focus IS NULL ) GROUP BY item_name ORDER BY total_count DESC `); } return await this.db.all(` SELECT item_name, SUM(count) as total_count FROM ( SELECT item_name, count FROM shulker_items WHERE item_name LIKE ? UNION ALL SELECT shulker_type AS item_name, 1 AS count FROM shulkers WHERE total_items = 0 AND item_focus IS NULL AND shulker_type LIKE ? ) GROUP BY item_name ORDER BY total_count DESC `, [`%${query}%`, `%${query}%`]); } async getItemDetails(itemId) { const item = await this.db.get('SELECT * FROM item_index WHERE item_id = ?', [itemId]); if (!item) return null; // Parse shulker_ids and get chest details const shulkerIds = JSON.parse(`[${item.shulker_ids}]`); const locations = await this.db.all(` SELECT s.id as shulker_id, s.chest_id, c.pos_x, c.pos_y, c.pos_z, js.value as count FROM json_each(?) as js INNER JOIN shulkers s ON s.id = JSON_EXTRACT(js.value, '$.id') INNER JOIN chests c ON c.id = s.chest_id `, [item.shulker_ids]); return { ...item, locations }; } // ======================================== // Map / Aggregation // ======================================== // Get all chests with a summary of their shulker contents (for map view) async getChestsWithSummary() { return await this.db.all(` SELECT c.id, c.pos_x, c.pos_y, c.pos_z, c.chest_type, c.row, c.column, c.category, COUNT(s.id) as shulker_count, COALESCE(SUM(s.total_items), 0) as total_items, GROUP_CONCAT(DISTINCT s.item_focus) as item_focuses, COALESCE((SELECT COUNT(*) FROM chest_loose_items cli WHERE cli.chest_id = c.id), 0) as loose_item_count FROM chests c LEFT JOIN shulkers s ON s.chest_id = c.id GROUP BY c.id ORDER BY c.pos_x, c.pos_z, c.pos_y `); } // Get detailed shulker info with all items async getShulkerWithItems(shulkerId) { const shulker = await this.db.get(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id WHERE s.id = ? `, [shulkerId]); if (!shulker) return null; const items = await this.getShulkerItems(shulkerId); return { ...shulker, items }; } // Get all shulkers for a chest with their items async getChestContents(chestId) { const chest = await this.getChestById(chestId); if (!chest) return null; const shulkers = await this.db.all(` SELECT s.*, GROUP_CONCAT(si.item_name || ':' || si.count) as item_summary FROM shulkers s LEFT JOIN shulker_items si ON si.shulker_id = s.id WHERE s.chest_id = ? GROUP BY s.id ORDER BY s.slot `, [chestId]); return { chest, shulkers }; } // ======================================== // Chest Loose Items // ======================================== async clearLooseItems(chestId) { return await this.db.run('DELETE FROM chest_loose_items WHERE chest_id = ?', [chestId]); } async upsertLooseItem(chestId, slot, itemName, itemId, count) { return await this.db.run(` INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count) VALUES (?, ?, ?, ?, ?) ON CONFLICT(chest_id, slot) DO UPDATE SET item_name = excluded.item_name, item_id = excluded.item_id, count = excluded.count `, [chestId, slot, itemName, itemId, count]); } async batchUpsertLooseItems(chestId, items) { if (!items.length) return; await this.db.run('BEGIN TRANSACTION'); try { const stmt = await this.db.prepare(` INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count) VALUES (?, ?, ?, ?, ?) ON CONFLICT(chest_id, slot) DO UPDATE SET item_name = excluded.item_name, item_id = excluded.item_id, count = excluded.count `); for (const item of items) { await stmt.run(chestId, item.slot, item.name, item.id, item.count); } await stmt.finalize(); await this.db.run('COMMIT'); } catch (error) { await this.db.run('ROLLBACK'); throw error; } } async getChestsWithLooseItems() { return await this.db.all(` SELECT DISTINCT c.* FROM chests c INNER JOIN chest_loose_items cli ON cli.chest_id = c.id ORDER BY c.id `); } async getAllLooseItems() { return await this.db.all(` SELECT cli.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type FROM chest_loose_items cli INNER JOIN chests c ON c.id = cli.chest_id ORDER BY c.id, cli.slot `); } async deleteLooseItem(chestId, slot) { return await this.db.run( 'DELETE FROM chest_loose_items WHERE chest_id = ? AND slot = ?', [chestId, slot] ); } // ======================================== // Maps // ======================================== async upsertMap(mapId, imageData, pixelData) { return await this.db.run(` INSERT INTO maps (map_id, image_data, pixel_data) VALUES (?, ?, ?) ON CONFLICT(map_id) DO UPDATE SET image_data = excluded.image_data, pixel_data = excluded.pixel_data, captured_at = CURRENT_TIMESTAMP `, [mapId, imageData, pixelData]); } async getMap(mapId) { return await this.db.get('SELECT * FROM maps WHERE map_id = ?', [mapId]); } async getAllMaps() { return await this.db.all('SELECT id, map_id, image_data, captured_at FROM maps ORDER BY map_id'); } async deleteMap(mapId) { return await this.db.run('DELETE FROM maps WHERE map_id = ?', [mapId]); } /** * Find filled_map items in shulkers that don't have captured images yet. * Returns items with location info needed to withdraw them. */ async getUncapturedMaps() { return await this.db.all(` SELECT si.*, s.slot as shulker_slot, s.chest_id, s.id as shulker_id, c.pos_x, c.pos_y, c.pos_z FROM shulker_items si INNER JOIN shulkers s ON s.id = si.shulker_id INNER JOIN chests c ON c.id = s.chest_id WHERE si.item_name = 'filled_map' AND s.slot_count >= 0 ORDER BY si.id `); } // ======================================== // Stats // ======================================== async getStats() { const totalItems = await this.db.get('SELECT SUM(total_items) as total FROM shulkers'); const totalShulkers = await this.db.get('SELECT COUNT(*) as total FROM shulkers'); const totalChests = await this.db.get('SELECT COUNT(*) as total FROM chests'); const emptyShulkers = await this.db.get("SELECT COUNT(*) as total FROM shulkers WHERE slot_count = 0"); const recentTrades = await this.db.get('SELECT COUNT(*) as total FROM trades WHERE timestamp > datetime("now", "-1 day")'); const looseItems = await this.db.get('SELECT COALESCE(SUM(count), 0) as total FROM chest_loose_items'); const chestCapacity = await this.db.get(` SELECT COALESCE(SUM(CASE WHEN chest_type = 'double' THEN 54 ELSE 27 END), 0) as total_slots FROM chests `); // Category breakdown const categories = await this.db.all(` SELECT category, COUNT(*) as count FROM shulkers WHERE category IS NOT NULL GROUP BY category `); const categoryMap = {}; for (const cat of categories) { categoryMap[cat.category] = cat.count; } return { totalItems: totalItems?.total || 0, totalShulkers: totalShulkers?.total || 0, totalChests: totalChests?.total || 0, emptyShulkers: emptyShulkers?.total || 0, recentTrades: recentTrades?.total || 0, looseItemCount: looseItems?.total || 0, totalChestSlots: chestCapacity?.total_slots || 0, categories: categoryMap }; } // ======================================== // Invite Sites // ======================================== async getInviteSites() { return await this.db.all(` SELECT s.*, GROUP_CONCAT(p.player_name) as players FROM invite_sites s LEFT JOIN invite_permissions p ON p.site_id = s.id GROUP BY s.id ORDER BY s.name `); } async getInviteSiteByName(name) { return await this.db.get('SELECT * FROM invite_sites WHERE name = ?', [name]); } async getInviteSitePlayers(siteId) { const rows = await this.db.all( 'SELECT player_name FROM invite_permissions WHERE site_id = ? ORDER BY player_name', [siteId] ); return rows.map(r => r.player_name); } async isPlayerAllowedAtSite(siteName, playerName) { const row = await this.db.get(` SELECT 1 FROM invite_sites s INNER JOIN invite_permissions p ON p.site_id = s.id WHERE s.name = ? AND p.player_name = ? `, [siteName, playerName]); return !!row; } async addInviteSite(name, label, botName, description) { return await this.db.run( 'INSERT INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)', [name, label, botName, description || null] ); } async updateInviteSite(id, fields) { const allowed = ['name', 'label', 'bot_name', 'description']; const sets = []; const values = []; for (const key of allowed) { if (fields[key] !== undefined) { sets.push(`${key} = ?`); values.push(fields[key]); } } if (sets.length === 0) return; values.push(id); return await this.db.run( `UPDATE invite_sites SET ${sets.join(', ')} WHERE id = ?`, values ); } async deleteInviteSite(id) { return await this.db.run('DELETE FROM invite_sites WHERE id = ?', [id]); } async addInvitePermission(siteId, playerName) { return await this.db.run( 'INSERT OR IGNORE INTO invite_permissions (site_id, player_name) VALUES (?, ?)', [siteId, playerName] ); } async removeInvitePermission(siteId, playerName) { return await this.db.run( 'DELETE FROM invite_permissions WHERE site_id = ? AND player_name = ?', [siteId, playerName] ); } async seedInviteSites(sites) { for (const site of sites) { try { await this.db.run( 'INSERT OR IGNORE INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)', [site.name, site.label, site.bot, site.description || null] ); const row = await this.getInviteSiteByName(site.name); if (row && site.allowed) { for (const player of site.allowed) { await this.addInvitePermission(row.id, player); } } } catch (error) { console.error('Error seeding invite site:', site.name, error); } } } async close() { if (this.db) { await this.db.close(); this.db = null; } } } module.exports = new Database();