clean up economy fallback user artifacts

This commit is contained in:
Franz Rolfsvaag 2026-06-17 22:45:32 +02:00
parent a48b946754
commit 606c9452e7
4 changed files with 223 additions and 17 deletions

View File

@ -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. - Review localization/translation keys if present so simplified wording remains consistent across languages.
## Done ## 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: 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: 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. - 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.

View File

@ -202,6 +202,7 @@ module.exports = {
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
migrateLegacyInstall(db, repoRoot); migrateLegacyInstall(db, repoRoot);
ensureTables(db); ensureTables(db);
cleanupFallbackUserArtifacts(db);
ensureDefaults(db); ensureDefaults(db);
startActivityRewardFlusher(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) { function migrateLegacyUploadDir(repoRoot) {
const legacyDir = path.join(repoRoot, "data", LEGACY_PLUGIN_ID); const legacyDir = path.join(repoRoot, "data", LEGACY_PLUGIN_ID);
const currentDir = path.join(repoRoot, "data", PLUGIN_ID); const currentDir = path.join(repoRoot, "data", PLUGIN_ID);
@ -1933,7 +2087,7 @@ function findUserByInternalName(db, name) {
.get(name); .get(name);
} }
function findUserByKnownName(db, name, platform) { function findUserByKnownName(db, name, platform, excludeUserId = null) {
const cleaned = (name || "").trim(); const cleaned = (name || "").trim();
if (!cleaned) { if (!cleaned) {
return null; return null;
@ -1948,11 +2102,13 @@ function findUserByKnownName(db, name, platform) {
"SELECT user_profiles.id AS id, user_profiles.internal_username AS internal_username " + "SELECT user_profiles.id AS id, user_profiles.internal_username AS internal_username " +
"FROM user_identities " + "FROM user_identities " +
"JOIN user_profiles ON user_profiles.id = user_identities.user_id " + "JOIN user_profiles ON user_profiles.id = user_identities.user_id " +
"WHERE lower(user_identities.display_name) = lower(?) " + "WHERE (lower(user_identities.display_name) = lower(?) " +
"OR lower(user_identities.provider_user_id) = 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` `ORDER BY ${providerScore} user_identities.updated_at DESC LIMIT 1`
) )
.get(cleaned, cleaned, ...providerParams); .get(cleaned, cleaned, excludeUserId || "", ...providerParams);
if (identity) { if (identity) {
return 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 " + "SELECT user_profiles.id AS id, user_profiles.internal_username AS internal_username " +
"FROM linked_accounts " + "FROM linked_accounts " +
"JOIN user_profiles ON user_profiles.id = linked_accounts.user_id " + "JOIN user_profiles ON user_profiles.id = linked_accounts.user_id " +
"WHERE lower(linked_accounts.display_name) = lower(?) " + "WHERE (lower(linked_accounts.display_name) = lower(?) " +
"OR lower(linked_accounts.provider_user_id) = 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` `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; return linked || null;
} }

View File

@ -1,7 +1,7 @@
{ {
"id": "economy-framework", "id": "economy-framework",
"name": "Economy Framework", "name": "Economy Framework",
"version": "0.2.8", "version": "0.2.9",
"description": "Cross-platform currency framework with shared balances and extensible hooks.", "description": "Cross-platform currency framework with shared balances and extensible hooks.",
"main": "index.js" "main": "index.js"
} }

View File

@ -2,30 +2,43 @@ function getProfileStats({ db, userId }) {
if (!userId) { if (!userId) {
return { stats: [] }; 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 const account = db
.prepare("SELECT balance FROM economy_accounts WHERE user_id = ?") .prepare(`SELECT balance FROM ${quoteIdentifier(accountsTable)} WHERE user_id = ?`)
.get(userId); .get(userId);
const earned = db const earned = db
.prepare( .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 = '')" "WHERE to_user_id = ? AND (from_user_id IS NULL OR from_user_id = '')"
) )
.get(userId); .get(userId);
const spent = db const spent = db
.prepare( .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 = '')" "WHERE from_user_id = ? AND (to_user_id IS NULL OR to_user_id = '')"
) )
.get(userId); .get(userId);
const transfersOut = db const transfersOut = db
.prepare( .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 != ''" "WHERE from_user_id = ? AND to_user_id IS NOT NULL AND to_user_id != ''"
) )
.get(userId); .get(userId);
const transfersIn = db const transfersIn = db
.prepare( .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 != ''" "WHERE to_user_id = ? AND from_user_id IS NOT NULL AND from_user_id != ''"
) )
.get(userId); .get(userId);
@ -42,12 +55,24 @@ function getProfileStats({ db, userId }) {
} }
function getLeaderboards({ db, limit = 10 }) { function getLeaderboards({ db, limit = 10 }) {
const accountsTable = resolveTable(db, "accounts");
if (!accountsTable) {
return {
boards: [
{
title: "Top balances",
valueLabel: "Balance",
rows: []
}
]
};
}
const rows = db const rows = db
.prepare( .prepare(
"SELECT user_profiles.internal_username AS username, economy_accounts.balance AS value " + `SELECT user_profiles.internal_username AS username, ${quoteIdentifier(accountsTable)}.balance AS value ` +
"FROM economy_accounts " + `FROM ${quoteIdentifier(accountsTable)} ` +
"JOIN user_profiles ON user_profiles.id = economy_accounts.user_id " + `JOIN user_profiles ON user_profiles.id = ${quoteIdentifier(accountsTable)}.user_id ` +
"ORDER BY economy_accounts.balance DESC LIMIT ?" `ORDER BY ${quoteIdentifier(accountsTable)}.balance DESC LIMIT ?`
) )
.all(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 = { module.exports = {
getProfileStats, getProfileStats,
getLeaderboards getLeaderboards