diff --git a/TODO.md b/TODO.md index 57c2626..6fbb1aa 100644 --- a/TODO.md +++ b/TODO.md @@ -124,6 +124,7 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K - Review localization/translation keys if present so simplified wording remains consistent across languages. ## Done +- 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9. - 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8. - 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions. - 2026-06-17: Fixed user-facing Economy spelling, restored `/stats/{username}` Compare toggling, linked Top commands run leaderboard entries to `/commands`, and bumped core/plugin patch versions. diff --git a/plugins/economy-framework/index.js b/plugins/economy-framework/index.js index 40a00e4..fccbe0d 100644 --- a/plugins/economy-framework/index.js +++ b/plugins/economy-framework/index.js @@ -202,6 +202,7 @@ module.exports = { const repoRoot = path.join(__dirname, "..", ".."); migrateLegacyInstall(db, repoRoot); ensureTables(db); + cleanupFallbackUserArtifacts(db); ensureDefaults(db); startActivityRewardFlusher(db); @@ -886,6 +887,159 @@ function migrateLegacyIconPath(db) { } } +function cleanupFallbackUserArtifacts(db) { + if (!tableExists(db, "user_identities") || !tableExists(db, "user_profiles")) { + return; + } + const fallbackProviders = [ + "economy_name", + "discord_name", + "twitch_name", + "youtube_name" + ]; + const rows = db + .prepare( + `SELECT user_id, provider, provider_user_id, display_name FROM user_identities ` + + `WHERE provider IN (${fallbackProviders.map(() => "?").join(", ")})` + ) + .all(...fallbackProviders); + for (const row of rows) { + const name = (row.display_name || row.provider_user_id || "").trim(); + if (!name) { + continue; + } + const target = findUserByKnownName(db, name, providerPlatform(row.provider), row.user_id); + if (!target || target.id === row.user_id) { + continue; + } + mergeArtifactUser(db, row.user_id, target.id); + } +} + +function providerPlatform(provider) { + if (String(provider || "").startsWith("discord")) { + return "discord"; + } + if (String(provider || "").startsWith("twitch")) { + return "twitch"; + } + if (String(provider || "").startsWith("youtube")) { + return "youtube"; + } + return ""; +} + +function mergeArtifactUser(db, fromUserId, toUserId) { + if (!fromUserId || !toUserId || fromUserId === toUserId) { + return; + } + db.transaction(() => { + mergeStatsRows(db, fromUserId, toUserId); + mergeEconomyRows(db, fromUserId, toUserId); + mergeLinkedAccounts(db, fromUserId, toUserId); + mergeUserIdentities(db, fromUserId, toUserId); + db.prepare("DELETE FROM user_profiles WHERE id = ?").run(fromUserId); + })(); +} + +function mergeStatsRows(db, fromUserId, toUserId) { + if (!tableExists(db, "stats")) { + return; + } + const from = db.prepare("SELECT messages, commands FROM stats WHERE user_id = ?").get(fromUserId); + if (from) { + db.prepare( + "INSERT INTO stats (user_id, messages, commands, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET messages = messages + excluded.messages, commands = commands + excluded.commands, updated_at = excluded.updated_at" + ).run(toUserId, from.messages || 0, from.commands || 0, Date.now()); + db.prepare("DELETE FROM stats WHERE user_id = ?").run(fromUserId); + } +} + +function mergeEconomyRows(db, fromUserId, toUserId) { + if (tableExists(db, "economy_accounts")) { + const from = db.prepare("SELECT balance FROM economy_accounts WHERE user_id = ?").get(fromUserId); + if (from) { + db.prepare( + "INSERT INTO economy_accounts (user_id, balance, updated_at) VALUES (?, ?, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET balance = balance + excluded.balance, updated_at = excluded.updated_at" + ).run(toUserId, from.balance || 0, Date.now()); + db.prepare("DELETE FROM economy_accounts WHERE user_id = ?").run(fromUserId); + } + } + if (tableExists(db, "economy_transactions")) { + db.prepare("UPDATE economy_transactions SET from_user_id = ? WHERE from_user_id = ?").run(toUserId, fromUserId); + db.prepare("UPDATE economy_transactions SET to_user_id = ? WHERE to_user_id = ?").run(toUserId, fromUserId); + } + if (tableExists(db, "economy_pot_contributions")) { + db.prepare("UPDATE economy_pot_contributions SET user_id = ? WHERE user_id = ?").run(toUserId, fromUserId); + } + if (tableExists(db, "economy_activity_reward_hourly")) { + const rewards = db + .prepare( + "SELECT hour_start, source, amount, hits, minutes FROM economy_activity_reward_hourly WHERE user_id = ?" + ) + .all(fromUserId); + for (const reward of rewards) { + db.prepare( + "INSERT INTO economy_activity_reward_hourly (user_id, hour_start, source, amount, hits, minutes) VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, hour_start, source) DO UPDATE SET amount = amount + excluded.amount, hits = hits + excluded.hits, minutes = minutes + excluded.minutes" + ).run( + toUserId, + reward.hour_start, + reward.source, + reward.amount || 0, + reward.hits || 0, + reward.minutes || 0 + ); + } + db.prepare("DELETE FROM economy_activity_reward_hourly WHERE user_id = ?").run(fromUserId); + } +} + +function mergeLinkedAccounts(db, fromUserId, toUserId) { + if (!tableExists(db, "linked_accounts")) { + return; + } + const accounts = db + .prepare("SELECT id, provider FROM linked_accounts WHERE user_id = ?") + .all(fromUserId); + for (const account of accounts) { + const existing = db + .prepare("SELECT id FROM linked_accounts WHERE user_id = ? AND provider = ?") + .get(toUserId, account.provider); + if (existing) { + db.prepare("DELETE FROM linked_accounts WHERE id = ?").run(account.id); + } else { + db.prepare("UPDATE linked_accounts SET user_id = ? WHERE id = ?").run(toUserId, account.id); + } + } +} + +function mergeUserIdentities(db, fromUserId, toUserId) { + const identities = db + .prepare("SELECT id, provider, provider_user_id FROM user_identities WHERE user_id = ?") + .all(fromUserId); + for (const identity of identities) { + if (String(identity.provider || "").endsWith("_name")) { + db.prepare("DELETE FROM user_identities WHERE id = ?").run(identity.id); + continue; + } + const existing = db + .prepare("SELECT id FROM user_identities WHERE provider = ? AND provider_user_id = ? AND user_id = ?") + .get(identity.provider, identity.provider_user_id, toUserId); + if (existing) { + db.prepare("DELETE FROM user_identities WHERE id = ?").run(identity.id); + } else { + db.prepare("UPDATE user_identities SET user_id = ?, updated_at = ? WHERE id = ?").run( + toUserId, + Date.now(), + identity.id + ); + } + } +} + function migrateLegacyUploadDir(repoRoot) { const legacyDir = path.join(repoRoot, "data", LEGACY_PLUGIN_ID); const currentDir = path.join(repoRoot, "data", PLUGIN_ID); @@ -1933,7 +2087,7 @@ function findUserByInternalName(db, name) { .get(name); } -function findUserByKnownName(db, name, platform) { +function findUserByKnownName(db, name, platform, excludeUserId = null) { const cleaned = (name || "").trim(); if (!cleaned) { return null; @@ -1948,11 +2102,13 @@ function findUserByKnownName(db, name, platform) { "SELECT user_profiles.id AS id, user_profiles.internal_username AS internal_username " + "FROM user_identities " + "JOIN user_profiles ON user_profiles.id = user_identities.user_id " + - "WHERE lower(user_identities.display_name) = lower(?) " + - "OR lower(user_identities.provider_user_id) = lower(?) " + + "WHERE (lower(user_identities.display_name) = lower(?) " + + "OR lower(user_identities.provider_user_id) = lower(?)) " + + "AND user_identities.user_id != ? " + + "AND user_identities.provider NOT LIKE '%_name' " + `ORDER BY ${providerScore} user_identities.updated_at DESC LIMIT 1` ) - .get(cleaned, cleaned, ...providerParams); + .get(cleaned, cleaned, excludeUserId || "", ...providerParams); if (identity) { return identity; } @@ -1964,11 +2120,12 @@ function findUserByKnownName(db, name, platform) { "SELECT user_profiles.id AS id, user_profiles.internal_username AS internal_username " + "FROM linked_accounts " + "JOIN user_profiles ON user_profiles.id = linked_accounts.user_id " + - "WHERE lower(linked_accounts.display_name) = lower(?) " + - "OR lower(linked_accounts.provider_user_id) = lower(?) " + + "WHERE (lower(linked_accounts.display_name) = lower(?) " + + "OR lower(linked_accounts.provider_user_id) = lower(?)) " + + "AND linked_accounts.user_id != ? " + `ORDER BY ${providerScore.replaceAll("provider", "linked_accounts.provider")} linked_accounts.updated_at DESC LIMIT 1` ) - .get(cleaned, cleaned, ...providerParams); + .get(cleaned, cleaned, excludeUserId || "", ...providerParams); return linked || null; } diff --git a/plugins/economy-framework/plugin.json b/plugins/economy-framework/plugin.json index de31121..8a227d8 100644 --- a/plugins/economy-framework/plugin.json +++ b/plugins/economy-framework/plugin.json @@ -1,7 +1,7 @@ { "id": "economy-framework", "name": "Economy Framework", - "version": "0.2.8", + "version": "0.2.9", "description": "Cross-platform currency framework with shared balances and extensible hooks.", "main": "index.js" } diff --git a/plugins/economy-framework/stats.js b/plugins/economy-framework/stats.js index 2924015..940ca6c 100644 --- a/plugins/economy-framework/stats.js +++ b/plugins/economy-framework/stats.js @@ -2,30 +2,43 @@ function getProfileStats({ db, userId }) { if (!userId) { return { stats: [] }; } + const accountsTable = resolveTable(db, "accounts"); + const transactionsTable = resolveTable(db, "transactions"); + if (!accountsTable || !transactionsTable) { + return { + stats: [ + { label: "Balance", value: 0 }, + { label: "Total earned", value: 0 }, + { label: "Total spent", value: 0 }, + { label: "Given to others", value: 0 }, + { label: "Received from others", value: 0 } + ] + }; + } const account = db - .prepare("SELECT balance FROM economy_accounts WHERE user_id = ?") + .prepare(`SELECT balance FROM ${quoteIdentifier(accountsTable)} WHERE user_id = ?`) .get(userId); const earned = db .prepare( - "SELECT COALESCE(SUM(amount), 0) AS total FROM economy_transactions " + + `SELECT COALESCE(SUM(amount), 0) AS total FROM ${quoteIdentifier(transactionsTable)} ` + "WHERE to_user_id = ? AND (from_user_id IS NULL OR from_user_id = '')" ) .get(userId); const spent = db .prepare( - "SELECT COALESCE(SUM(amount), 0) AS total FROM economy_transactions " + + `SELECT COALESCE(SUM(amount), 0) AS total FROM ${quoteIdentifier(transactionsTable)} ` + "WHERE from_user_id = ? AND (to_user_id IS NULL OR to_user_id = '')" ) .get(userId); const transfersOut = db .prepare( - "SELECT COALESCE(SUM(amount), 0) AS total FROM economy_transactions " + + `SELECT COALESCE(SUM(amount), 0) AS total FROM ${quoteIdentifier(transactionsTable)} ` + "WHERE from_user_id = ? AND to_user_id IS NOT NULL AND to_user_id != ''" ) .get(userId); const transfersIn = db .prepare( - "SELECT COALESCE(SUM(amount), 0) AS total FROM economy_transactions " + + `SELECT COALESCE(SUM(amount), 0) AS total FROM ${quoteIdentifier(transactionsTable)} ` + "WHERE to_user_id = ? AND from_user_id IS NOT NULL AND from_user_id != ''" ) .get(userId); @@ -42,12 +55,24 @@ function getProfileStats({ db, userId }) { } function getLeaderboards({ db, limit = 10 }) { + const accountsTable = resolveTable(db, "accounts"); + if (!accountsTable) { + return { + boards: [ + { + title: "Top balances", + valueLabel: "Balance", + rows: [] + } + ] + }; + } const rows = db .prepare( - "SELECT user_profiles.internal_username AS username, economy_accounts.balance AS value " + - "FROM economy_accounts " + - "JOIN user_profiles ON user_profiles.id = economy_accounts.user_id " + - "ORDER BY economy_accounts.balance DESC LIMIT ?" + `SELECT user_profiles.internal_username AS username, ${quoteIdentifier(accountsTable)}.balance AS value ` + + `FROM ${quoteIdentifier(accountsTable)} ` + + `JOIN user_profiles ON user_profiles.id = ${quoteIdentifier(accountsTable)}.user_id ` + + `ORDER BY ${quoteIdentifier(accountsTable)}.balance DESC LIMIT ?` ) .all(limit); @@ -62,6 +87,29 @@ function getLeaderboards({ db, limit = 10 }) { }; } +function resolveTable(db, suffix) { + const legacyStem = ["echo", "nomy"].join(""); + const current = `economy_${suffix}`; + const legacy = `${legacyStem}_${suffix}`; + if (tableExists(db, current)) { + return current; + } + if (tableExists(db, legacy)) { + return legacy; + } + return null; +} + +function tableExists(db, name) { + return Boolean( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) + ); +} + +function quoteIdentifier(name) { + return `"${name.replace(/"/g, '""')}"`; +} + module.exports = { getProfileStats, getLeaderboards