diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index dc5c4509..8d7e9c26 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -6,12 +6,12 @@ enable - + + - diff --git a/DiscordBot/Domain/Casino/CasinoUser.cs b/DiscordBot/Domain/Casino/CasinoUser.cs index d4aa664e..354321d1 100644 --- a/DiscordBot/Domain/Casino/CasinoUser.cs +++ b/DiscordBot/Domain/Casino/CasinoUser.cs @@ -6,7 +6,7 @@ public class CasinoUser { public int Id { get; set; } public required string UserID { get; set; } - public ulong Tokens { get; set; } + public long Tokens { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public DateTime LastDailyReward { get; set; } @@ -16,8 +16,17 @@ public class TokenTransaction { public int Id { get; set; } public required string UserID { get; set; } - public long Amount { get; set; } // Can be negative for spending - public TransactionType Type { get; set; } // Enum for transaction types + public string? TargetUserID { get; set; } + public long Amount { get; set; } + public string TransactionType { get; set; } = ""; + + // Computed from TransactionType string β€” not mapped to DB + [JsonIgnore] + public TransactionKind Kind + { + get => Enum.TryParse(TransactionType, true, out var result) ? result : TransactionKind.Admin; + set => TransactionType = value.ToString(); + } private Dictionary? _details; @@ -28,17 +37,26 @@ public Dictionary? Details set => _details = value; } - // This property will be mapped to the database JSON column - public string? DetailsJson + // Maps to DB column "description" (text). Stores Details dict as JSON, deserializes with fallback for plain text (from MySQL migration) + public string? Description { get => Details != null && Details.Any() ? JsonConvert.SerializeObject(Details) : null; - set => Details = !string.IsNullOrEmpty(value) ? JsonConvert.DeserializeObject>(value) : new Dictionary(); + set + { + if (string.IsNullOrEmpty(value)) + { + _details = new Dictionary(); + return; + } + try { _details = JsonConvert.DeserializeObject>(value); } + catch (JsonException) { _details = new Dictionary { ["text"] = value }; } + } } public DateTime CreatedAt { get; set; } } -public enum TransactionType +public enum TransactionKind { TokenInitialisation, DailyReward, @@ -96,8 +114,9 @@ public static class CasinoProps // TokenTransaction properties public const string TransactionId = nameof(TokenTransaction.Id); public const string TransactionUserID = nameof(TokenTransaction.UserID); + public const string TargetUserID = nameof(TokenTransaction.TargetUserID); public const string Amount = nameof(TokenTransaction.Amount); - public const string TransactionType = nameof(TokenTransaction.Type); - public const string Details = nameof(TokenTransaction.DetailsJson); + public const string TransactionType = nameof(TokenTransaction.TransactionType); + public const string Details = nameof(TokenTransaction.Description); public const string TransactionCreatedAt = nameof(TokenTransaction.CreatedAt); } \ No newline at end of file diff --git a/DiscordBot/Domain/Casino/Game.cs b/DiscordBot/Domain/Casino/Game.cs index c50fafe0..85478fad 100644 --- a/DiscordBot/Domain/Casino/Game.cs +++ b/DiscordBot/Domain/Casino/Game.cs @@ -56,7 +56,7 @@ public interface ICasinoGame public IReadOnlyList<(GamePlayer player, long payout)> EndGame(); public abstract GamePlayerResult GetPlayerGameResult(GamePlayer player); - public abstract long CalculatePayout(GamePlayer player, ulong totalPot); + public abstract long CalculatePayout(GamePlayer player, long totalPot); public void Reset(); @@ -149,8 +149,8 @@ public void StartGame(IEnumerable players) // Default implementation does nothing, override in specific games if needed protected virtual void FinalizeGame(List players) { } public abstract GamePlayerResult GetPlayerGameResult(GamePlayer player); - protected ulong GetTotalPot => (ulong)Players.Sum(p => (long)p.Bet); - public abstract long CalculatePayout(GamePlayer player, ulong totalPot); + protected long GetTotalPot => Players.Sum(p => p.Bet); + public abstract long CalculatePayout(GamePlayer player, long totalPot); /// /// Determines if the game should enter the FINISHED state.
diff --git a/DiscordBot/Domain/Casino/GamePlayer.cs b/DiscordBot/Domain/Casino/GamePlayer.cs index fab05a10..3ecb33e6 100644 --- a/DiscordBot/Domain/Casino/GamePlayer.cs +++ b/DiscordBot/Domain/Casino/GamePlayer.cs @@ -16,7 +16,7 @@ public class GamePlayer /// /// The bet amount placed by the player /// - public required ulong Bet { get; set; } + public required long Bet { get; set; } /// /// The final result of the player in the game (won, lost, tie) /// diff --git a/DiscordBot/Domain/Casino/GameSession.cs b/DiscordBot/Domain/Casino/GameSession.cs index 3294d156..6fb29dc9 100644 --- a/DiscordBot/Domain/Casino/GameSession.cs +++ b/DiscordBot/Domain/Casino/GameSession.cs @@ -10,12 +10,12 @@ public interface IGameSession public Type ActionType { get; } public DiscordGamePlayer? GetPlayer(ulong userId); - public bool AddPlayer(ulong userId, ulong bet); + public bool AddPlayer(ulong userId, long bet); public bool AddPlayerAI(); public void RemovePlayer(ulong userId); public void RemovePlayerAI(); public void SetPlayerReady(ulong userId, bool isReady); - public void SetPlayerBet(ulong userId, ulong bet); + public void SetPlayerBet(ulong userId, long bet); public void DoPlayerAction(ulong userId, Enum action); public bool HasNextDealerAction(); @@ -75,7 +75,7 @@ public GameSession(TGame game, int maxSeats) ///
public bool CanStart => Game.State == GameState.NotStarted && PlayerCount >= Game.MinPlayers && AllPlayersReady; - public ulong GetTotalPot => (ulong)Players.Sum(p => (long)p.Bet); + public long GetTotalPot => Players.Sum(p => p.Bet); public bool ShouldFinish() => Game.ShouldFinish(); @@ -94,7 +94,7 @@ public void Reset() public DiscordGamePlayer? GetPlayer(ulong userId) => Players.FirstOrDefault(p => p.UserId == userId); - public bool AddPlayer(ulong userId, ulong bet) + public bool AddPlayer(ulong userId, long bet) { if (!CanJoin) return false; if (Players.Any(p => p.UserId == userId)) return false; // Player already in game @@ -156,7 +156,7 @@ public void SetPlayerReady(ulong userId, bool ready = true) if (ready && CanStart) Game.StartGame(Players); } - public void SetPlayerBet(ulong userId, ulong bet) + public void SetPlayerBet(ulong userId, long bet) { if (Game.State != GameState.NotStarted) return; // Cannot change bet after the game has started var player = GetPlayer(userId); diff --git a/DiscordBot/Domain/Casino/Games/Cards/Blackjack/Blackjack.cs b/DiscordBot/Domain/Casino/Games/Cards/Blackjack/Blackjack.cs index 06f4e70a..15d2682b 100644 --- a/DiscordBot/Domain/Casino/Games/Cards/Blackjack/Blackjack.cs +++ b/DiscordBot/Domain/Casino/Games/Cards/Blackjack/Blackjack.cs @@ -105,12 +105,12 @@ public override GamePlayerResult GetPlayerGameResult(GamePlayer player) return GamePlayerResult.NoResult; } - public override long CalculatePayout(GamePlayer player, ulong _totalPot) + public override long CalculatePayout(GamePlayer player, long _totalPot) { return player.Result switch { - GamePlayerResult.Won => (long)player.Bet, - GamePlayerResult.Lost => -(long)player.Bet, + GamePlayerResult.Won => player.Bet, + GamePlayerResult.Lost => -player.Bet, GamePlayerResult.Tie => 0, _ => 0 }; diff --git a/DiscordBot/Domain/Casino/Games/Cards/Poker/Poker.cs b/DiscordBot/Domain/Casino/Games/Cards/Poker/Poker.cs index 3f28f866..3a4a71e3 100644 --- a/DiscordBot/Domain/Casino/Games/Cards/Poker/Poker.cs +++ b/DiscordBot/Domain/Casino/Games/Cards/Poker/Poker.cs @@ -230,11 +230,11 @@ public override GamePlayerResult GetPlayerGameResult(GamePlayer player) return winners.Any(w => w.player == player) ? GamePlayerResult.Won : GamePlayerResult.Lost; } - public override long CalculatePayout(GamePlayer player, ulong totalPot) + public override long CalculatePayout(GamePlayer player, long totalPot) { var result = GetPlayerGameResult(player); if (result == GamePlayerResult.Lost) - return -(long)player.Bet; + return -player.Bet; // Calculate winner's share var allHands = Players.Where(p => GameData[p].FinalHand != null) @@ -244,12 +244,11 @@ public override long CalculatePayout(GamePlayer player, ulong totalPot) if (winner.player != null) { - // Winner gets their share of the total pot minus their original bet - var winnings = (long)(totalPot * (ulong)winner.share); - return winnings - (long)player.Bet; + var winnings = (long)(totalPot * winner.share); + return winnings - player.Bet; } - return -(long)player.Bet; + return -player.Bet; } public override bool ShouldFinish() => State == GameState.InProgress && Players.All(p => GameData[p].HasDiscarded); diff --git a/DiscordBot/Domain/Casino/Games/RockPaperScissors/RockPaperScissors.cs b/DiscordBot/Domain/Casino/Games/RockPaperScissors/RockPaperScissors.cs index b45dbd34..3dd73647 100644 --- a/DiscordBot/Domain/Casino/Games/RockPaperScissors/RockPaperScissors.cs +++ b/DiscordBot/Domain/Casino/Games/RockPaperScissors/RockPaperScissors.cs @@ -74,13 +74,13 @@ public override GamePlayerResult GetPlayerGameResult(GamePlayer player) return playerWins ? GamePlayerResult.Won : GamePlayerResult.Lost; } - public override long CalculatePayout(GamePlayer player, ulong totalPot) + public override long CalculatePayout(GamePlayer player, long totalPot) { return player.Result switch { - GamePlayerResult.Won => (long)totalPot - (long)player.Bet, // Winner gets the total pot minus their bet - GamePlayerResult.Lost => -(long)player.Bet, // Loser loses their bet - GamePlayerResult.Tie => 0, // Tie gets bet back (no loss, no gain) + GamePlayerResult.Won => totalPot - player.Bet, + GamePlayerResult.Lost => -player.Bet, + GamePlayerResult.Tie => 0, _ => 0 }; } diff --git a/DiscordBot/Domain/ProfileData.cs b/DiscordBot/Domain/ProfileData.cs index 82ef6162..2942a5fc 100644 --- a/DiscordBot/Domain/ProfileData.cs +++ b/DiscordBot/Domain/ProfileData.cs @@ -7,15 +7,15 @@ public class ProfileData public ulong UserId { get; set; } public string Nickname { get; set; } public string Username { get; set; } - public uint XpTotal { get; set; } - public uint XpRank { get; set; } - public uint KarmaRank { get; set; } - public uint Karma { get; set; } - public uint Level { get; set; } + public long XpTotal { get; set; } + public long XpRank { get; set; } + public long KarmaRank { get; set; } + public int Karma { get; set; } + public int Level { get; set; } public double XpLow { get; set; } public double XpHigh { get; set; } - public uint XpShown { get; set; } - public uint MaxXpShown { get; set; } + public int XpShown { get; set; } + public int MaxXpShown { get; set; } public float XpPercentage { get; set; } public Color MainRoleColor { get; set; } public MagickImage Picture { get; set; } diff --git a/DiscordBot/Extensions/CasinoRepository.cs b/DiscordBot/Extensions/CasinoRepository.cs index e63c145c..6216431e 100644 --- a/DiscordBot/Extensions/CasinoRepository.cs +++ b/DiscordBot/Extensions/CasinoRepository.cs @@ -8,8 +8,8 @@ public interface ICasinoRepo // Casino User Operations [Sql($@" INSERT INTO {CasinoProps.CasinoTableName} ({CasinoProps.UserID}, {CasinoProps.Tokens}, {CasinoProps.CreatedAt}, {CasinoProps.UpdatedAt}, {CasinoProps.LastDailyReward}) - VALUES (@{CasinoProps.UserID}, @{CasinoProps.Tokens}, @{CasinoProps.CreatedAt}, @{CasinoProps.UpdatedAt}, @{CasinoProps.LastDailyReward}); - SELECT * FROM {CasinoProps.CasinoTableName} WHERE {CasinoProps.UserID} = @{CasinoProps.UserID}")] + VALUES (@{CasinoProps.UserID}, @{CasinoProps.Tokens}, @{CasinoProps.CreatedAt}, @{CasinoProps.UpdatedAt}, @{CasinoProps.LastDailyReward}) + RETURNING *")] Task InsertCasinoUser(CasinoUser user); [Sql($"SELECT * FROM {CasinoProps.CasinoTableName} WHERE {CasinoProps.UserID} = @userId")] @@ -19,10 +19,10 @@ public interface ICasinoRepo Task> GetTopTokenHolders(int limit); [Sql($"UPDATE {CasinoProps.CasinoTableName} SET {CasinoProps.Tokens} = @tokens, {CasinoProps.UpdatedAt} = @updatedAt WHERE {CasinoProps.UserID} = @userId")] - Task UpdateTokens(string userId, ulong tokens, DateTime updatedAt); + Task UpdateTokens(string userId, long tokens, DateTime updatedAt); [Sql($"UPDATE {CasinoProps.CasinoTableName} SET {CasinoProps.Tokens} = @tokens, {CasinoProps.UpdatedAt} = @updatedAt, {CasinoProps.LastDailyReward} = @lastDailyReward WHERE {CasinoProps.UserID} = @userId")] - Task UpdateTokensAndDailyReward(string userId, ulong tokens, DateTime updatedAt, DateTime lastDailyReward); + Task UpdateTokensAndDailyReward(string userId, long tokens, DateTime updatedAt, DateTime lastDailyReward); [Sql($"DELETE FROM {CasinoProps.CasinoTableName} WHERE {CasinoProps.UserID} = @userId")] Task DeleteCasinoUser(string userId); @@ -32,9 +32,9 @@ public interface ICasinoRepo // Token Transaction Operations [Sql($@" - INSERT INTO {CasinoProps.TransactionTableName} ({CasinoProps.TransactionUserID}, {CasinoProps.Amount}, {CasinoProps.TransactionType}, {CasinoProps.Details}, {CasinoProps.TransactionCreatedAt}) - VALUES (@{CasinoProps.TransactionUserID}, @{CasinoProps.Amount}, @{CasinoProps.TransactionType}, @{CasinoProps.Details}, @{CasinoProps.TransactionCreatedAt}); - SELECT * FROM {CasinoProps.TransactionTableName} WHERE {CasinoProps.TransactionId} = LAST_INSERT_ID()")] + INSERT INTO {CasinoProps.TransactionTableName} ({CasinoProps.TransactionUserID}, {CasinoProps.TargetUserID}, {CasinoProps.Amount}, {CasinoProps.TransactionType}, {CasinoProps.Details}, {CasinoProps.TransactionCreatedAt}) + VALUES (@{CasinoProps.TransactionUserID}, @{CasinoProps.TargetUserID}, @{CasinoProps.Amount}, @{CasinoProps.TransactionType}, @{CasinoProps.Details}, @{CasinoProps.TransactionCreatedAt}) + RETURNING *")] Task InsertTransaction(TokenTransaction tokenTransaction); [Sql($"SELECT * FROM {CasinoProps.TransactionTableName} WHERE {CasinoProps.TransactionUserID} = @userId ORDER BY {CasinoProps.TransactionCreatedAt} DESC LIMIT @limit")] @@ -47,7 +47,7 @@ public interface ICasinoRepo Task ClearAllTransactions(); [Sql($"SELECT * FROM {CasinoProps.TransactionTableName} WHERE {CasinoProps.TransactionType} = @transactionType ORDER BY {CasinoProps.TransactionCreatedAt} DESC")] - Task> GetTransactionsOfType(TransactionType transactionType); + Task> GetTransactionsOfType(string transactionType); // Test connection [Sql($"SELECT COUNT(*) FROM {CasinoProps.CasinoTableName}")] diff --git a/DiscordBot/Extensions/DBConnectionExtension.cs b/DiscordBot/Extensions/DBConnectionExtension.cs index bba946b9..9294ebd0 100644 --- a/DiscordBot/Extensions/DBConnectionExtension.cs +++ b/DiscordBot/Extensions/DBConnectionExtension.cs @@ -1,5 +1,6 @@ using System.Data.Common; using Insight.Database; +using Npgsql; namespace DiscordBot.Extensions; @@ -7,9 +8,8 @@ public static class DBConnectionExtension { public static async Task ColumnExists(this DbConnection connection, string tableName, string columnName) { - // Execute the query `SHOW COLUMNS FROM `{tableName}` LIKE '{columnName}'` and check if any rows are returned - var query = $"SHOW COLUMNS FROM `{tableName}` LIKE '{columnName}'"; - var response = await connection.QuerySqlAsync(query); + const string query = "SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(@tableName) AND LOWER(column_name) = LOWER(@columnName)"; + var response = await connection.QuerySqlAsync(query, new { tableName, columnName }); return response.Count > 0; } } \ No newline at end of file diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index b5c62837..ce932b21 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -6,13 +6,13 @@ public class ServerUser { // ReSharper disable once InconsistentNaming public string UserID { get; set; } - public uint Karma { get; set; } - public uint KarmaWeekly { get; set; } - public uint KarmaMonthly { get; set; } - public uint KarmaYearly { get; set; } - public uint KarmaGiven { get; set; } - public ulong Exp { get; set; } - public uint Level { get; set; } + public int Karma { get; set; } + public int KarmaWeekly { get; set; } + public int KarmaMonthly { get; set; } + public int KarmaYearly { get; set; } + public int KarmaGiven { get; set; } + public long Exp { get; set; } + public int Level { get; set; } // DefaultCity - Optional Location for Weather, BDay, Temp, Time, etc. (Added - Jan 2024) public string DefaultCity { get; set; } = string.Empty; } @@ -23,7 +23,7 @@ public class ServerUser public static class UserProps { public const string TableName = "users"; - + public const string UserID = nameof(ServerUser.UserID); public const string Karma = nameof(ServerUser.Karma); public const string KarmaWeekly = nameof(ServerUser.KarmaWeekly); @@ -38,8 +38,8 @@ public static class UserProps public interface IServerUserRepo { [Sql($@" - INSERT INTO {UserProps.TableName} ({UserProps.UserID}) VALUES (@{UserProps.UserID}); - SELECT * FROM {UserProps.TableName} WHERE {UserProps.UserID} = @{UserProps.UserID}")] + INSERT INTO {UserProps.TableName} ({UserProps.UserID}) VALUES (@{UserProps.UserID}) + RETURNING *")] Task InsertUser(ServerUser user); [Sql($"DELETE FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task RemoveUser(string userId); @@ -48,54 +48,54 @@ public interface IServerUserRepo Task GetUser(string userId); #region Ranks - - [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.Level}, {UserProps.Exp} FROM {UserProps.TableName} ORDER BY {UserProps.Level} DESC, RAND() LIMIT @n")] + + [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.Level}, {UserProps.Exp} FROM {UserProps.TableName} ORDER BY {UserProps.Level} DESC, RANDOM() LIMIT @n")] Task> GetTopLevel(int n); - [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.KarmaGiven} FROM {UserProps.TableName} ORDER BY {UserProps.Karma} DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.KarmaGiven} FROM {UserProps.TableName} ORDER BY {UserProps.Karma} DESC, RANDOM() LIMIT @n")] Task> GetTopKarma(int n); - [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaWeekly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaWeekly} DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaWeekly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaWeekly} DESC, RANDOM() LIMIT @n")] Task> GetTopKarmaWeekly(int n); - [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaMonthly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaMonthly} DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaMonthly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaMonthly} DESC, RANDOM() LIMIT @n")] Task> GetTopKarmaMonthly(int n); - [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaYearly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaYearly} DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaYearly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaYearly} DESC, RANDOM() LIMIT @n")] Task> GetTopKarmaYearly(int n); [Sql($"SELECT COUNT({UserProps.UserID})+1 FROM {UserProps.TableName} WHERE {UserProps.Level} > @level")] - Task GetLevelRank(string userId, uint level); + Task GetLevelRank(string userId, int level); [Sql($"SELECT COUNT({UserProps.UserID})+1 FROM {UserProps.TableName} WHERE {UserProps.Karma} > @karma")] - Task GetKarmaRank(string userId, uint karma); - + Task GetKarmaRank(string userId, int karma); + #endregion // Ranks #region Update Values - + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Karma} = @karma WHERE {UserProps.UserID} = @userId")] - Task UpdateKarma(string userId, uint karma); + Task UpdateKarma(string userId, int karma); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Karma} = {UserProps.Karma} + 1, {UserProps.KarmaWeekly} = {UserProps.KarmaWeekly} + 1, {UserProps.KarmaMonthly} = {UserProps.KarmaMonthly} + 1, {UserProps.KarmaYearly} = {UserProps.KarmaYearly} + 1 WHERE {UserProps.UserID} = @userId")] Task IncrementKarma(string userId); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.KarmaGiven} = @karmaGiven WHERE {UserProps.UserID} = @userId")] - Task UpdateKarmaGiven(string userId, uint karmaGiven); + Task UpdateKarmaGiven(string userId, int karmaGiven); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Exp} = @xp WHERE {UserProps.UserID} = @userId")] - Task UpdateXp(string userId, ulong xp); + Task UpdateXp(string userId, long xp); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Level} = @level WHERE {UserProps.UserID} = @userId")] - Task UpdateLevel(string userId, uint level); + Task UpdateLevel(string userId, int level); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.DefaultCity} = @city WHERE {UserProps.UserID} = @userId")] Task UpdateDefaultCity(string userId, string city); - + #endregion // Update Values #region Get Single Values - + [Sql($"SELECT {UserProps.Karma} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] - Task GetKarma(string userId); + Task GetKarma(string userId); [Sql($"SELECT {UserProps.KarmaGiven} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] - Task GetKarmaGiven(string userId); + Task GetKarmaGiven(string userId); [Sql($"SELECT {UserProps.Exp} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] - Task GetXp(string userId); + Task GetXp(string userId); [Sql($"SELECT {UserProps.Level} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] - Task GetLevel(string userId); + Task GetLevel(string userId); [Sql($"SELECT {UserProps.DefaultCity} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetDefaultCity(string userId); - + #endregion // Get Single Values /// Returns a count of {Props.TableName} in the Table, otherwise it fails. diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs index 6ccf6784..c0d6926d 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs @@ -300,7 +300,7 @@ public async Task RemoveAIPlayer(string id) #region Betting Actions [ComponentInteraction("bet_add:*:*", true)] - public async Task BetAdd(string id, ulong amount) + public async Task BetAdd(string id, long amount) { await DeferAsync(); @@ -320,7 +320,7 @@ public async Task BetAdd(string id, ulong amount) } [ComponentInteraction("bet_set:*:*", true)] - public async Task BetSet(string id, ulong amount) + public async Task BetSet(string id, long amount) { if (!Context.Interaction.HasResponded) await DeferAsync(); diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.cs index d9ad21db..4d30b28e 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.cs @@ -79,7 +79,7 @@ public async Task CheckTokens() [SlashCommand("gift", "Gift tokens to another user")] public async Task GiftTokens( [Summary("user", "User to gift tokens to")] SocketGuildUser targetUser, - [Summary("amount", "Amount of tokens to gift")] uint amount) + [Summary("amount", "Amount of tokens to gift")] int amount) { if (!await CheckChannelPermissions()) return; @@ -380,14 +380,14 @@ public async Task NavigateHistory(string userId, string pageStr, string requestT private (string emoji, string title, string description) FormatTransactionDisplay(TokenTransaction transaction, bool showUserInfo = false) { - var (emoji, title, description) = transaction.Type switch + var (emoji, title, description) = transaction.Kind switch { - TransactionType.TokenInitialisation => ("🎯", "Account Created", ""), - TransactionType.DailyReward => ("πŸ“…", "Daily Reward", ""), - TransactionType.Gift => GetGiftDisplay(transaction), - TransactionType.Game => GetGameDisplay(transaction), - TransactionType.Admin => GetAdminDisplay(transaction), - _ => ("❓", transaction.Type.ToString(), "") + TransactionKind.TokenInitialisation => ("🎯", "Account Created", ""), + TransactionKind.DailyReward => ("πŸ“…", "Daily Reward", ""), + TransactionKind.Gift => GetGiftDisplay(transaction), + TransactionKind.Game => GetGameDisplay(transaction), + TransactionKind.Admin => GetAdminDisplay(transaction), + _ => ("❓", transaction.TransactionType, "") }; // If showing user info (for all-users view), prepend user name to title @@ -462,7 +462,7 @@ private string CapitalizeFirst(string input) [RequireUserPermission(GuildPermission.Administrator)] public async Task SetTokens( [Summary("user", "User to set tokens for")] SocketGuildUser targetUser, - [Summary("amount", "New token amount")] uint amount) + [Summary("amount", "New token amount")] int amount) { if (!await CheckChannelPermissions()) return; @@ -484,13 +484,13 @@ public async Task SetTokens( [RequireUserPermission(GuildPermission.Administrator)] public async Task AddTokens( [Summary("user", "User to add tokens to")] SocketGuildUser targetUser, - [Summary("amount", "Amount of tokens to add")] uint amount) + [Summary("amount", "Amount of tokens to add")] int amount) { if (!await CheckChannelPermissions()) return; await Context.Interaction.DeferAsync(ephemeral: true); - await CasinoService.UpdateUserTokens(targetUser.Id.ToString(), (long)amount, TransactionType.Admin, new Dictionary + await CasinoService.UpdateUserTokens(targetUser.Id.ToString(), amount, TransactionKind.Admin, new Dictionary { ["admin"] = Context.User.Id.ToString(), ["action"] = "add" diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs index 48de2952..69c17141 100644 --- a/DiscordBot/Modules/ModerationModule.cs +++ b/DiscordBot/Modules/ModerationModule.cs @@ -7,7 +7,6 @@ using Pathoschild.NaturalTimeParser.Parser; using DiscordBot.Attributes; using DiscordBot.Utils; -using Org.BouncyCastle.Asn1.Cms; namespace DiscordBot.Modules; @@ -22,13 +21,13 @@ public class ModerationModule : ModuleBase public BotSettings Settings { get; set; } public UserService UserService { get; set; } public ModerationService ModerationService { get; set; } - + #endregion - + private async Task IsModerationEnabled() { if (Settings.ModeratorCommandsEnabled) return true; - if (await Context.Guild.GetChannelAsync(Settings.BotAnnouncementChannel.Id)is IMessageChannel botAnnouncementChannel) + if (await Context.Guild.GetChannelAsync(Settings.BotAnnouncementChannel.Id) is IMessageChannel botAnnouncementChannel) { var sentMessage = await botAnnouncementChannel.SendMessageAsync($"{Context.User.Mention} some moderation commands are disabled, try using Wick."); await Context.Message.DeleteAsync(); @@ -114,7 +113,7 @@ await LoggingService.LogChannelAndFile( $"You have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + "This is not appealable and any tentative to avoid it will result in your permanent ban.")) { - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id)is ISocketMessageChannel botCommandChannel) + if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is ISocketMessageChannel botCommandChannel) await botCommandChannel.SendMessageAsync( $"I could not DM you {user.Mention}!\nYou have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + "This is not appealable and any tentative to avoid it will result in your permanent ban."); @@ -442,7 +441,7 @@ public async Task FullSync() } #region General Utility Commands - + [Command("WelcomeMessageCount")] [Summary("Returns a count of pending welcome messages.")] [RequireModerator, HideFromHelp] @@ -462,7 +461,7 @@ public async Task WelcomeMessageCount() } await Context.Message.DeleteAsync(); } - + // Command to show the tags available for a specific channel, so the command needs to be run in a channel with tags or specific a channel id to check [Command("ChannelTags")] [Summary("Returns a list of tags for the current channel.")] @@ -499,7 +498,7 @@ public async Task ChannelTags(ulong channelId) } #endregion - + #region CommandList [RequireModerator] [Summary("Does what you see now.")] diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index ced5b0c4..d8bc3b65 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -251,7 +251,7 @@ public async Task UserCompleted(string message) return; } - const uint xpGain = 5000; + const int xpGain = 5000; var userXp = await DatabaseService.Query.GetXp(userId.ToString()); await DatabaseService.Query.UpdateXp(userId.ToString(), userXp + xpGain); await Context.Message.DeleteAsync(); @@ -468,7 +468,7 @@ public async Task TopKarmaYearly() await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); } - private async Task GenerateRankEmbedFromList(List<(ulong userID, uint value)> data, string labelName) + private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) { var embedBuilder = new EmbedBuilder { diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 03989537..f0f22093 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -75,6 +75,7 @@ private async Task MainAsync() _recruitService = _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); return Task.CompletedTask; }; @@ -110,6 +111,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .BuildServiceProvider(); private static void DeserializeSettings() diff --git a/DiscordBot/Services/Casino/CasinoService.cs b/DiscordBot/Services/Casino/CasinoService.cs index 2550cef6..653f72a9 100644 --- a/DiscordBot/Services/Casino/CasinoService.cs +++ b/DiscordBot/Services/Casino/CasinoService.cs @@ -39,7 +39,7 @@ public async Task GetOrCreateCasinoUser(string userId) }; var createdUser = await _databaseService.CasinoQuery.InsertCasinoUser(newUser); - await RecordTransaction(userId, (long)_settings.CasinoStartingTokens, TransactionType.TokenInitialisation); + await RecordTransaction(userId, _settings.CasinoStartingTokens, TransactionKind.TokenInitialisation); await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.CasinoStartingTokens} starting tokens"); return createdUser; } @@ -51,7 +51,7 @@ public async Task GetOrCreateCasinoUser(string userId) } } - public async Task TransferTokens(string fromUserId, string toUserId, ulong amount) + public async Task TransferTokens(string fromUserId, string toUserId, long amount) { var fromUser = await GetOrCreateCasinoUser(fromUserId); var toUser = await GetOrCreateCasinoUser(toUserId); @@ -64,11 +64,11 @@ public async Task TransferTokens(string fromUserId, string toUserId, ulong await _databaseService.CasinoQuery.UpdateTokens(toUserId, toUser.Tokens + amount, DateTime.UtcNow); // Record transactions - await RecordTransaction(fromUserId, -(long)amount, TransactionType.Gift, new Dictionary + await RecordTransaction(fromUserId, -amount, TransactionKind.Gift, new Dictionary { ["to"] = toUserId, }); - await RecordTransaction(toUserId, (long)amount, TransactionType.Gift, new Dictionary + await RecordTransaction(toUserId, amount, TransactionKind.Gift, new Dictionary { ["from"] = fromUserId }); @@ -76,16 +76,16 @@ public async Task TransferTokens(string fromUserId, string toUserId, ulong return true; } - public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionType transactionType, Dictionary? details = null) + public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionKind transactionType, Dictionary? details = null) { try { var user = await GetOrCreateCasinoUser(userId); - var newBalance = (long)user.Tokens + deltaTokens; + var newBalance = user.Tokens + deltaTokens; // Prevent negative balance if (newBalance < 0) newBalance = 0; - await _databaseService.CasinoQuery.UpdateTokens(userId, (ulong)newBalance, DateTime.UtcNow); + await _databaseService.CasinoQuery.UpdateTokens(userId, newBalance, DateTime.UtcNow); await RecordTransaction(userId, deltaTokens, transactionType, details); } catch (Exception ex) @@ -96,11 +96,11 @@ public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionT } } - public async Task SetUserTokens(string userId, ulong amount, string adminUserId) + public async Task SetUserTokens(string userId, long amount, string adminUserId) { await _databaseService.CasinoQuery.UpdateTokens(userId, amount, DateTime.UtcNow); - await RecordTransaction(userId, (long)amount, TransactionType.Admin, new Dictionary + await RecordTransaction(userId, amount, TransactionKind.Admin, new Dictionary { ["admin"] = adminUserId, ["action"] = "set" @@ -126,13 +126,13 @@ public async Task> GetAllRecentTransactions(int limit = 1 return transactions.ToList(); } - private async Task RecordTransaction(string userId, long amount, TransactionType type, Dictionary? details = null) + private async Task RecordTransaction(string userId, long amount, TransactionKind type, Dictionary? details = null) { var transaction = new TokenTransaction { UserID = userId, Amount = amount, - Type = type, + Kind = type, CreatedAt = DateTime.UtcNow, Details = details }; @@ -148,7 +148,7 @@ public async Task> GetGameStatistics(IUser user) { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(TransactionType.Game); + var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Group transactions by game type var gameGroups = gameTransactions @@ -206,7 +206,7 @@ public async Task GetGameLeaderboard(string? gameName = n { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(TransactionType.Game); + var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Filter by game if specified var filteredTransactions = gameTransactions @@ -315,7 +315,7 @@ public bool IsChannelAllowed(ulong channelId) #region Daily Rewards - public async Task<(bool success, ulong tokensAwarded, ulong newBalance, DateTime nextRewardTime)> TryClaimDailyReward(string userId) + public async Task<(bool success, long tokensAwarded, long newBalance, DateTime nextRewardTime)> TryClaimDailyReward(string userId) { try { @@ -332,7 +332,7 @@ public bool IsChannelAllowed(ulong channelId) var tokensAwarded = _settings.CasinoDailyRewardTokens; var newBalance = user.Tokens + tokensAwarded; await _databaseService.CasinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); - await RecordTransaction(userId, (long)tokensAwarded, TransactionType.DailyReward); + await RecordTransaction(userId, tokensAwarded, TransactionKind.DailyReward); await _loggingService.LogChannelAndFile($"{ServiceName}: User {userId} claimed daily reward of {tokensAwarded} tokens"); return (true, tokensAwarded, newBalance, now.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds)); diff --git a/DiscordBot/Services/Casino/GameService.cs b/DiscordBot/Services/Casino/GameService.cs index a794ef0c..68dc4fa8 100644 --- a/DiscordBot/Services/Casino/GameService.cs +++ b/DiscordBot/Services/Casino/GameService.cs @@ -73,7 +73,7 @@ public async Task JoinGame(IDiscordGameSession session, ulong userId) session.AddPlayer(userId, 1); } - public async Task SetBet(IDiscordGameSession session, ulong userId, ulong bet) + public async Task SetBet(IDiscordGameSession session, ulong userId, long bet) { var user = await _casinoService.GetOrCreateCasinoUser(userId.ToString()); if (bet > user.Tokens) throw new InvalidOperationException("You do not have enough tokens."); @@ -87,7 +87,7 @@ public async Task EndGame(IDiscordGameSession session) foreach (var (player, payout) in payouts) { if (player.IsAI) continue; // Skip AI players - await _casinoService.UpdateUserTokens(player.UserId.ToString(), payout, TransactionType.Game, new Dictionary + await _casinoService.UpdateUserTokens(player.UserId.ToString(), payout, TransactionKind.Game, new Dictionary { { "game", session.GameName }, }); diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 82df7e62..5fe2a940 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -3,7 +3,8 @@ using DiscordBot.Domain; using DiscordBot.Settings; using Insight.Database; -using MySql.Data.MySqlClient; +using Insight.Database.Providers.PostgreSQL; +using Npgsql; namespace DiscordBot.Services; @@ -18,7 +19,7 @@ private ICasinoRepo CreateCasinoQuery() { try { - var c = new MySqlConnection(ConnectionString); + var c = new NpgsqlConnection(ConnectionString); return c.As(); } catch (Exception e) @@ -32,7 +33,7 @@ private IServerUserRepo CreateQuery() { try { - var c = new MySqlConnection(ConnectionString); + var c = new NpgsqlConnection(ConnectionString); return c.As(); } catch (Exception e) @@ -47,13 +48,15 @@ private IServerUserRepo CreateQuery() public DatabaseService(ILoggingService logging, BotSettings settings) { + PostgreSQLInsightDbProvider.RegisterProvider(); + ConnectionString = settings.DbConnectionString; _logging = logging; DbConnection c = null; try { - c = new MySqlConnection(ConnectionString); + c = new NpgsqlConnection(ConnectionString); } catch (Exception e) { @@ -72,11 +75,10 @@ await _logging.LogAction( $"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); - // Not sure on best practice for if column is missing, full blown migrations seem overkill var defaultCityExists = await c.ColumnExists(UserProps.TableName, UserProps.DefaultCity); if (!defaultCityExists) { - c.ExecuteSql($"ALTER TABLE `{UserProps.TableName}` ADD `{UserProps.DefaultCity}` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `{UserProps.Level}`"); + c.ExecuteSql($"ALTER TABLE {UserProps.TableName} ADD COLUMN {UserProps.DefaultCity} varchar(64) DEFAULT NULL"); await _logging.LogAction($"DatabaseService: Added missing column '{UserProps.DefaultCity}' to table '{UserProps.TableName}'.", ExtendedLogSeverity.Positive); } @@ -88,23 +90,17 @@ await _logging.LogAction($"DatabaseService: Table '{UserProps.TableName}' does n try { c.ExecuteSql( - $"CREATE TABLE `{UserProps.TableName}` (`ID` int(11) UNSIGNED NOT NULL," + - $"`{UserProps.UserID}` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, " + - $"`{UserProps.Karma}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.KarmaWeekly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.KarmaMonthly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.KarmaYearly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.KarmaGiven}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.Exp}` bigint(11) UNSIGNED NOT NULL DEFAULT 0, " + - $"`{UserProps.Level}` int(11) UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); - c.ExecuteSql( - $"ALTER TABLE `{UserProps.TableName}` ADD PRIMARY KEY (`ID`,`{UserProps.UserID}`), ADD UNIQUE KEY `{UserProps.UserID}` (`{UserProps.UserID}`)"); - c.ExecuteSql( - $"ALTER TABLE `{UserProps.TableName}` MODIFY `ID` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1"); - - // "DefaultCity" Nullable - Weather, BDay, Temp, Time, etc. Optional for users to set their own city (Added - Jan 2024) - c.ExecuteSql( - $"ALTER TABLE `{UserProps.TableName}` ADD `{UserProps.DefaultCity}` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `{UserProps.Level}`"); + $"CREATE TABLE {UserProps.TableName} (" + + $"id SERIAL PRIMARY KEY, " + + $"{UserProps.UserID} varchar(32) NOT NULL UNIQUE, " + + $"{UserProps.Karma} integer NOT NULL DEFAULT 0, " + + $"{UserProps.KarmaWeekly} integer NOT NULL DEFAULT 0, " + + $"{UserProps.KarmaMonthly} integer NOT NULL DEFAULT 0, " + + $"{UserProps.KarmaYearly} integer NOT NULL DEFAULT 0, " + + $"{UserProps.KarmaGiven} integer NOT NULL DEFAULT 0, " + + $"{UserProps.Exp} bigint NOT NULL DEFAULT 0, " + + $"{UserProps.Level} integer NOT NULL DEFAULT 0, " + + $"{UserProps.DefaultCity} varchar(64) DEFAULT NULL)"); } catch (Exception e) { @@ -133,31 +129,28 @@ await _logging.LogAction( ExtendedLogSeverity.LowWarning); try { - // Create casino_users table c.ExecuteSql( - $"CREATE TABLE `{CasinoProps.CasinoTableName}` (" + - $"`{CasinoProps.Id}` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, " + - $"`{CasinoProps.UserID}` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, " + - $"`{CasinoProps.Tokens}` bigint(20) UNSIGNED NOT NULL DEFAULT 1000, " + - $"`{CasinoProps.CreatedAt}` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - $"`{CasinoProps.UpdatedAt}` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + - $"`{CasinoProps.LastDailyReward}` timestamp NOT NULL DEFAULT '1970-01-01 00:00:01', " + - $"PRIMARY KEY (`{CasinoProps.Id}`), " + - $"UNIQUE KEY `{CasinoProps.UserID}` (`{CasinoProps.UserID}`) " + - $") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $"CREATE TABLE {CasinoProps.CasinoTableName} (" + + $"{CasinoProps.Id} SERIAL PRIMARY KEY, " + + $"{CasinoProps.UserID} varchar(32) NOT NULL UNIQUE, " + + $"{CasinoProps.Tokens} bigint NOT NULL DEFAULT 1000, " + + $"{CasinoProps.CreatedAt} timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + $"{CasinoProps.UpdatedAt} timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + $"{CasinoProps.LastDailyReward} timestamptz NOT NULL DEFAULT '1970-01-01 00:00:01+00')"); + + c.ExecuteSql( + $"CREATE TABLE {CasinoProps.TransactionTableName} (" + + $"{CasinoProps.TransactionId} SERIAL PRIMARY KEY, " + + $"{CasinoProps.TransactionUserID} varchar(32) NOT NULL, " + + $"{CasinoProps.TargetUserID} varchar(32) DEFAULT NULL, " + + $"{CasinoProps.Amount} bigint NOT NULL, " + + $"{CasinoProps.TransactionType} varchar(50) NOT NULL, " + + $"{CasinoProps.Details} text DEFAULT NULL, " + + $"{CasinoProps.TransactionCreatedAt} timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP)"); - // Create token_transactions table c.ExecuteSql( - $"CREATE TABLE `{CasinoProps.TransactionTableName}` (" + - $"`{CasinoProps.TransactionId}` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, " + - $"`{CasinoProps.TransactionUserID}` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, " + - $"`{CasinoProps.Amount}` bigint(20) NOT NULL, " + - $"`{CasinoProps.TransactionType}` int(11) NOT NULL, " + - $"`{CasinoProps.Details}` json DEFAULT NULL, " + // JSON column for transaction details - $"`{CasinoProps.TransactionCreatedAt}` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - $"PRIMARY KEY (`{CasinoProps.TransactionId}`), " + - $"KEY `idx_user_created` (`{CasinoProps.TransactionUserID}`, `{CasinoProps.TransactionCreatedAt}`) " + - $") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $"CREATE INDEX idx_user_created ON {CasinoProps.TransactionTableName} " + + $"({CasinoProps.TransactionUserID}, {CasinoProps.TransactionCreatedAt})"); } catch (Exception e) { @@ -171,24 +164,6 @@ await _logging.LogAction($"DatabaseService: Casino tables generated without erro ExtendedLogSeverity.Positive); c.Close(); } - - // Generate and add events if they don't exist - try - { - c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetWeeklyLeaderboards` ON SCHEDULE EVERY 1 WEEK STARTS '2021-08-02 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaWeekly} = 0"); - c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetMonthlyLeaderboards` ON SCHEDULE EVERY 1 MONTH STARTS '2021-08-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaMonthly} = 0"); - c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetYearlyLeaderboards` ON SCHEDULE EVERY 1 YEAR STARTS '2022-01-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaYearly} = 0"); - c.Close(); - } - catch (Exception e) - { - await _logging.LogAction($"SQL Exception: Failed to generate leaderboard events.\nMessage: {e}", - ExtendedLogSeverity.Warning); - } - }); } diff --git a/DiscordBot/Services/KarmaResetService.cs b/DiscordBot/Services/KarmaResetService.cs new file mode 100644 index 00000000..69f8d3ea --- /dev/null +++ b/DiscordBot/Services/KarmaResetService.cs @@ -0,0 +1,136 @@ +using DiscordBot.Settings; +using Insight.Database; +using Npgsql; + +namespace DiscordBot.Services; + +/// +/// Replaces MySQL EVENT scheduler β€” resets weekly/monthly/yearly karma columns on schedule. +/// Tracks last-reset timestamps so missed resets are caught up on startup. +/// +public class KarmaResetService +{ + private const string MetaTable = "karma_reset_meta"; + + private readonly ILoggingService _logging; + private readonly string _connectionString; + + public KarmaResetService(ILoggingService logging, BotSettings settings) + { + _logging = logging; + _connectionString = settings.DbConnectionString; + + Task.Run(RunLoop); + } + + private async Task RunLoop() + { + // Wait for DatabaseService to finish table creation + await Task.Delay(TimeSpan.FromSeconds(10)); + + try + { + await EnsureMetaTable(); + await CatchUpMissedResets(); + } + catch (Exception e) + { + await _logging.LogChannelAndFile($"KarmaResetService: Failed during startup: {e.Message}", ExtendedLogSeverity.Warning); + } + + while (true) + { + try + { + await Task.Delay(TimeSpan.FromHours(1)); + + var now = DateTime.UtcNow; + + if (now.DayOfWeek == DayOfWeek.Monday) + await TryReset("weekly", UserProps.KarmaWeekly); + + if (now.Day == 1) + { + await TryReset("monthly", UserProps.KarmaMonthly); + + if (now.Month == 1) + await TryReset("yearly", UserProps.KarmaYearly); + } + } + catch (Exception e) + { + await _logging.LogChannelAndFile($"KarmaResetService: Error during reset check: {e.Message}", ExtendedLogSeverity.Warning); + } + } + } + + private async Task EnsureMetaTable() + { + await using var c = new NpgsqlConnection(_connectionString); + await c.OpenAsync(); + await c.ExecuteSqlAsync( + $"CREATE TABLE IF NOT EXISTS {MetaTable} (" + + $"period varchar(16) PRIMARY KEY, " + + $"last_reset timestamptz NOT NULL DEFAULT '1970-01-01 00:00:00+00')"); + await c.ExecuteSqlAsync($"INSERT INTO {MetaTable} (period) VALUES ('weekly') ON CONFLICT DO NOTHING"); + await c.ExecuteSqlAsync($"INSERT INTO {MetaTable} (period) VALUES ('monthly') ON CONFLICT DO NOTHING"); + await c.ExecuteSqlAsync($"INSERT INTO {MetaTable} (period) VALUES ('yearly') ON CONFLICT DO NOTHING"); + } + + private async Task CatchUpMissedResets() + { + var now = DateTime.UtcNow; + + var weeklyLast = await GetLastReset("weekly"); + if (WeekNumber(now) != WeekNumber(weeklyLast) || now.Year != weeklyLast.Year) + await ResetColumn("weekly", UserProps.KarmaWeekly); + + var monthlyLast = await GetLastReset("monthly"); + if (now.Month != monthlyLast.Month || now.Year != monthlyLast.Year) + await ResetColumn("monthly", UserProps.KarmaMonthly); + + var yearlyLast = await GetLastReset("yearly"); + if (now.Year != yearlyLast.Year) + await ResetColumn("yearly", UserProps.KarmaYearly); + } + + private async Task TryReset(string period, string column) + { + var lastReset = await GetLastReset(period); + var now = DateTime.UtcNow; + + var shouldReset = period switch + { + "weekly" => WeekNumber(now) != WeekNumber(lastReset) || now.Year != lastReset.Year, + "monthly" => now.Month != lastReset.Month || now.Year != lastReset.Year, + "yearly" => now.Year != lastReset.Year, + _ => false + }; + + if (shouldReset) + await ResetColumn(period, column); + } + + private async Task ResetColumn(string period, string column) + { + await using var c = new NpgsqlConnection(_connectionString); + await c.OpenAsync(); + await c.ExecuteSqlAsync($"UPDATE {UserProps.TableName} SET {column} = 0"); + await c.ExecuteSqlAsync($"UPDATE {MetaTable} SET last_reset = NOW() WHERE period = @period", new { period }); + await _logging.LogChannelAndFile($"KarmaResetService: Reset {period} karma ({column}).", ExtendedLogSeverity.Positive); + } + + private async Task GetLastReset(string period) + { + await using var c = new NpgsqlConnection(_connectionString); + await c.OpenAsync(); + var results = await c.QuerySqlAsync($"SELECT last_reset FROM {MetaTable} WHERE period = @period", new { period }); + if (results.Count > 0 && results[0] is IDictionary row && row.TryGetValue("last_reset", out var val) && val is DateTime dt) + return dt; + return DateTime.MinValue; + } + + private static int WeekNumber(DateTime date) => + System.Globalization.CultureInfo.InvariantCulture.Calendar + .GetWeekOfYear(date, System.Globalization.CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); +} \ No newline at end of file diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index f865581a..fbfc169b 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -263,7 +263,7 @@ public async Task UpdateXp(SocketMessage messageParam) var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (uint)xpGain); + await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, xpGain); @@ -300,9 +300,9 @@ private async Task LevelUp(SocketMessage messageParam, ulong userId) //TODO Add level up card } - private double GetXpLow(uint level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); + private double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); - private double GetXpHigh(uint level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); + private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); private SkinData GetSkinData() => JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), @@ -333,8 +333,8 @@ public async Task GenerateProfileCard(IUser user) var xpLow = GetXpLow(level); var xpHigh = GetXpHigh(level); - var xpShown = (uint)(xpTotal - xpLow); - var maxXpShown = (uint)(xpHigh - xpLow); + var xpShown = (int)(xpTotal - xpLow); + var maxXpShown = (int)(xpHigh - xpLow); var percentage = (float)xpShown / maxXpShown; @@ -355,8 +355,8 @@ public async Task GenerateProfileCard(IUser user) var profile = new ProfileData { Karma = karma, - KarmaRank = (uint)karmaRank, - Level = (uint)level, + KarmaRank = karmaRank, + Level = level, MainRoleColor = mainRole.Color, MaxXpShown = maxXpShown, Nickname = ((IGuildUser)user).Nickname, @@ -365,9 +365,9 @@ public async Task GenerateProfileCard(IUser user) XpHigh = xpHigh, XpLow = xpLow, XpPercentage = percentage, - XpRank = (uint)xpRank, + XpRank = xpRank, XpShown = xpShown, - XpTotal = (uint)xpTotal + XpTotal = xpTotal }; var background = new MagickImage($"{_settings.AssetsRootPath}/skins/{skin.Background}"); diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index c751ccff..9b6be99e 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -139,12 +139,12 @@ public class BotSettings #region Casino Settings public bool CasinoEnabled { get; set; } = true; - public ulong CasinoStartingTokens { get; set; } = 1000; + public long CasinoStartingTokens { get; set; } = 1000; public List CasinoAllowedChannels { get; set; } = new List(); public int CasinoGameTimeoutMinutes { get; set; } = 5; // Daily Reward Settings - public ulong CasinoDailyRewardTokens { get; set; } = 100; + public long CasinoDailyRewardTokens { get; set; } = 100; public int CasinoDailyRewardIntervalSeconds { get; set; } = 86400; // 24 hours = 86400 seconds #endregion // Casino Settings diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 44960198..db00b571 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -3,9 +3,8 @@ /* Auth info */ "token": "Y O U R _ B O T _ T O K E N", "invite": "InviteLink", // Currently Unused - /* 'SSL MODE' and 'Allow User Variables' are only required when running on a local machine with XAMPP. This can often be removed. */ /* DB Info*/ - "DbConnectionString": "server=localhost;port=3306;database=test;user id=USER;Password=USERPASSWORD;SSL Mode=None;Allow User Variables=True", + "DbConnectionString": "Host=localhost;Port=5432;Database=udcbot;Username=udcbot;Password=USERPASSWORD", /*Server Info*/ "serverRootPath": "./SERVER", "assetsRootPath": "./Assets", diff --git a/README.md b/README.md index e688088c..1d46464b 100644 --- a/README.md +++ b/README.md @@ -283,16 +283,17 @@ docker-compose up -d **Manual Database Setup (Alternative to Docker):** -If you prefer not to use Docker, you'll need to set up a MySQL database manually: +If you prefer not to use Docker, you'll need to set up a PostgreSQL database manually: -1. **Install MySQL server:** - - **Windows/macOS:** [XAMPP](https://www.apachefriends.org/download.html) (includes MySQL + phpMyAdmin) - - **Linux:** `sudo apt install mysql-server` or equivalent +1. **Install PostgreSQL:** + - **Windows:** [PostgreSQL Installer](https://www.postgresql.org/download/windows/) + - **macOS:** `brew install postgresql@16` + - **Linux:** `sudo apt install postgresql` or equivalent 2. **Create database and user:** - Create a new database for the bot - Create a user with full permissions to that database - - Update the `DbConnectionString` in `Settings.json` with your database details + - Update the `DbConnectionString` in `Settings.json` with your connection details (e.g. `Host=localhost;Port=5432;Database=udcbot;Username=udcbot;Password=YOUR_PASSWORD`) 3. **Initialize database schema:** - The bot will attempt to create necessary tables on first run @@ -308,7 +309,7 @@ sudo apt install ttf-mscorefonts-installer **Connection String Format:** ```json -"DbConnectionString": "Server=localhost;Database=your_db_name;Uid=your_username;Pwd=your_password;" +"DbConnectionString": "Host=localhost;Port=5432;Database=your_db_name;Username=your_username;Password=your_password" ``` ## Notes diff --git a/docker-compose.yml b/docker-compose.yml index 0c4aba68..f392ab0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,27 @@ version: "3.3" services: db: - image: mysql + image: postgres:16 volumes: - - db_data:/var/lib/mysql + - db_data:/var/lib/postgresql/data restart: always ports: - - 127.0.0.1:3306:3306 - #- 3306:3306 + - 127.0.0.1:5432:5432 environment: - MYSQL_ROOT_PASSWORD: 123456789 - MYSQL_DATABASE: udcbot - MYSQL_USER: udcbot - MYSQL_PASSWORD: 123456789 + POSTGRES_DB: udcbot + POSTGRES_USER: udcbot + POSTGRES_PASSWORD: 123456789 - phpmyadmin: - image: phpmyadmin + adminer: + image: adminer:4 depends_on: - db restart: always ports: - 8080:80 environment: - PMA_HOST: db + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: dracula bot: build: . diff --git a/docs/INDEX.md b/docs/INDEX.md index 401aef29..75543243 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -40,4 +40,6 @@ post_date: "2026-04-03" | Document | Description | |----------|-------------| +| [MySQL β†’ PostgreSQL Data Migration](plans/data-migration-mysql-to-postgresql.md) | Operational guide β€” pgloader, cutover phases, kubectl commands | +| [MySQL β†’ PostgreSQL Code Changes](plans/done/mysql-to-postgresql-changes.md) | Architecture reference β€” all code, schema, and infra changes in PR #375 | | [plans/done/](plans/done/) | Completed feature plans | diff --git a/docs/plans/data-migration-mysql-to-postgresql.md b/docs/plans/data-migration-mysql-to-postgresql.md new file mode 100644 index 00000000..040a0a1f --- /dev/null +++ b/docs/plans/data-migration-mysql-to-postgresql.md @@ -0,0 +1,262 @@ +# MySQL β†’ PostgreSQL Data Migration Plan + +## Overview + +Migrate production data from MySQL (`udc-bot-prod`) to PostgreSQL. The schema has already been migrated (DDL in `DatabaseService.cs`). This plan covers **data migration and the production cutover strategy**. + +## Cutover Strategy + +The PR (`feature/postgre`) replaces MySQL with PostgreSQL in k8s manifests. This means we **lose MySQL** once the new manifests are deployed. The migration must follow this sequence: + +### Phase 0: Test on Dev (current step) + +**Goal:** Validate the pgloader migration from prod MySQL β†’ dev PostgreSQL before touching prod. + +1. **Scale down dev bot** β€” `kubectl scale deployment/udc-bot -n udc-bot-dev --replicas=0` +2. **Deploy the pgloader Job** β€” `kubectl apply -f k8s/dev/pgloader-migration.yaml` + - Creates a temporary ExternalSecret for MySQL prod credentials in dev namespace + - Runs pgloader in `data only` mode (tables already exist from bot startup) + - Truncates existing dev data, imports prod data, resets sequences +3. **Check Job logs** β€” `kubectl logs job/mysql-to-postgresql-migration -n udc-bot-dev` +4. **Verify data** β€” connect via Adminer or `kubectl exec` and check row counts +5. **Scale dev bot back up** β€” `kubectl scale deployment/udc-bot -n udc-bot-dev --replicas=1` +6. **Test bot commands** β€” `!profile`, `thanks @someone`, casino commands +7. **Clean up** β€” delete the Job and temporary MySQL secret: + ```bash + kubectl delete job mysql-to-postgresql-migration -n udc-bot-dev + kubectl delete externalsecret mysql-prod-credentials -n udc-bot-dev + kubectl delete secret mysql-prod-credentials -n udc-bot-dev + ``` + +**File:** `k8s/dev/pgloader-migration.yaml` (ExternalSecret + ConfigMap + Job) + +### Phase 1: Prepare (before merging PR) + +1. **Deploy PostgreSQL alongside MySQL in prod** β€” add `postgresql.yaml` to `k8s/prod/` while keeping `mysql.yaml` intact. Both databases run simultaneously. +2. **Scale down the bot** β€” `kubectl scale deployment/udc-bot -n udc-bot-prod --replicas=0` β€” freeze writes to MySQL. +3. **Backup MySQL** β€” `mysqldump` as safety net. + +### Phase 2: Migrate (both databases running) + +4. **Run pgloader Job** β€” streams data from MySQL β†’ PostgreSQL within the cluster. +5. **Verify data** β€” row counts, spot-checks, sequence values. + +### Phase 3: Switch (controlled cutover) + +6. **Deploy the new bot** (from `feature/postgre`) pointing to PostgreSQL. +7. **Verify bot commands** β€” `!profile`, karma, casino. +8. **Keep MySQL running** for 24-48h as rollback safety net. + +### Phase 4: Cleanup + +9. **Remove MySQL** from prod k8s manifests (`mysql.yaml`, ExternalSecrets). +10. **Remove MySQL 1Password items** if no longer needed. +11. **Move this plan** to `docs/plans/done/`. + +## Data Inventory + +| Table | Rows | Key Columns | Notes | +|-------|------|-------------|-------| +| `users` | **54,302** | `ID` (auto-inc), `UserID` (varchar PK) | Karma, XP, Level data | +| `casino_users` | **22** | `Id` (auto-inc), `UserID` (varchar unique) | Token balances | +| `token_transactions` | **1,441** | `Id` (auto-inc), `UserID`, `Amount`, `Type` | Transaction history | + +### Schema Differences + +| MySQL Type | PostgreSQL Type | Affected Columns | +|-----------|----------------|-----------------| +| `int unsigned` | `integer` | Karma*, KarmaGiven, Level, Id | +| `bigint unsigned` | `bigint` | Exp, Tokens | +| `timestamp` (MySQL default) | `timestamp` | CreatedAt, UpdatedAt, LastDailyReward | +| `varchar(32)` | `varchar(32)` | UserID (no change) | + +## Migration Strategy + +**Recommended: `pgloader`** β€” purpose-built MySQL-to-PostgreSQL ETL tool. + +### Why pgloader + +- Handles type mapping automatically (unsigned β†’ signed) +- Streams data (no intermediate files for 54K rows) +- Single command, declarative config +- Handles timestamp conversion, encoding, and NULL differences + +### Alternative: CSV export/import + +Simpler but manual. Good fallback if pgloader isn't available in the cluster. + +--- + +## Execution Plan + +### Prerequisites + +- [ ] PostgreSQL deployed in prod **alongside** MySQL (temporary dual-database state) +- [ ] Bot scaled to 0 replicas (no writes during migration) +- [ ] MySQL backup taken +- [ ] Cross-namespace DNS verified: `mysql.udc-bot-prod.svc.cluster.local` reachable from migration pod + +### Option A: pgloader (Recommended) + +#### Step 1: Create pgloader Job + +Deploy a one-shot Kubernetes Job that runs pgloader with a config targeting MySQLβ†’PostgreSQL. + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: mysql-to-postgresql-migration + namespace: udc-bot-dev +spec: + template: + spec: + containers: + - name: pgloader + image: ghcr.io/dimitri/pgloader:latest + command: + - pgloader + - /config/migration.load + volumeMounts: + - name: config + mountPath: /config + volumes: + - name: config + configMap: + name: pgloader-config + restartPolicy: Never + backoffLimit: 1 +``` + +#### Step 2: pgloader Configuration + +> **Note:** The actual working config is in `k8s/dev/pgloader-migration.yaml` and `k8s/prod/pgloader-migration.yaml`. See [MySQL β†’ PostgreSQL Code Changes](done/mysql-to-postgresql-changes.md) for all the pgloader quirks discovered during testing. + +``` +LOAD DATABASE + FROM mysql://root:${MYSQL_PASSWORD}@mysql..svc.cluster.local:3306/udcbot + INTO postgresql://udcbot:${POSTGRES_PASSWORD}@postgresql..svc.cluster.local:5432/udcbot + +WITH data only, reset sequences, + workers = 2, concurrency = 1 + +SET maintenance_work_mem to '128MB' + +INCLUDING ONLY TABLE NAMES MATCHING ~/users|casino_users|token_transactions/ + +ALTER SCHEMA 'udcbot' RENAME TO 'public' + +BEFORE LOAD DO + $$ ALTER TABLE users ADD COLUMN IF NOT EXISTS birthday timestamp; $$, + $$ DROP TABLE IF EXISTS token_transactions CASCADE; $$, + $$ CREATE TABLE token_transactions (Id SERIAL PRIMARY KEY, UserID varchar(32) NOT NULL, TargetUserID varchar(32) DEFAULT NULL, Amount bigint NOT NULL, TransactionType varchar(50) NOT NULL, Description text DEFAULT NULL, CreatedAt timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP); $$, + $$ CREATE INDEX IF NOT EXISTS idx_user_created ON token_transactions (UserID, CreatedAt); $$, + $$ TRUNCATE users, casino_users CASCADE; $$ + +AFTER LOAD DO + $$ ALTER TABLE users DROP COLUMN IF EXISTS birthday; $$ +; +``` + +**Key points:** +- `data only` mode β€” tables must already exist (bot creates them on startup) +- `ALTER SCHEMA 'udcbot' RENAME TO 'public'` β€” MySQL DB name maps to a PG schema; this redirects to `public` +- Passwords are embedded in URLs β€” pgloader does **not** respect `PGPASSWORD`/`MYSQL_PWD` env vars +- `token_transactions` is DROP+CREATE'd because the bot's DDL schema may differ from MySQL's column names +- `birthday` temp column: added before load and dropped after β€” handles MySQL sources that may have this column (idempotent, safe for sources without it) + +#### Step 3: Verify + +```sql +SELECT COUNT(*) FROM users; -- Expect: 54,302 +SELECT COUNT(*) FROM casino_users; -- Expect: 22 +SELECT COUNT(*) FROM token_transactions; -- Expect: 1,441 +``` + +### Option B: CSV Export/Import (Fallback) + +#### Step 1: Export from MySQL + +```bash +# Run inside MySQL pod +mysqldump -u root -p udcbot users casino_users token_transactions \ + --compatible=postgresql --no-create-info --complete-insert \ + --skip-quote-names > /tmp/data.sql +``` + +Or per-table CSV: + +```bash +mysql -u root -p udcbot -e "SELECT * FROM users" --batch > /tmp/users.tsv +mysql -u root -p udcbot -e "SELECT * FROM casino_users" --batch > /tmp/casino_users.tsv +mysql -u root -p udcbot -e "SELECT * FROM token_transactions" --batch > /tmp/token_transactions.tsv +``` + +#### Step 2: Transfer files + +```bash +kubectl cp udc-bot-prod/mysql-pod:/tmp/users.tsv ./users.tsv +kubectl cp udc-bot-prod/mysql-pod:/tmp/casino_users.tsv ./casino_users.tsv +kubectl cp udc-bot-prod/mysql-pod:/tmp/token_transactions.tsv ./token_transactions.tsv +``` + +#### Step 3: Import to PostgreSQL + +```bash +kubectl cp ./users.tsv udc-bot-dev/postgresql-pod:/tmp/users.tsv +kubectl exec -it deployment/postgresql -n udc-bot-dev -- psql -U udcbot -d udcbot -c "\COPY users FROM '/tmp/users.tsv' WITH (FORMAT text, HEADER true)" +``` + +Repeat for each table. + +#### Step 4: Fix sequences + +After import, auto-increment sequences will be out of sync: + +```sql +SELECT setval('users_id_seq', (SELECT MAX(id) FROM users)); +SELECT setval('casino_users_id_seq', (SELECT MAX(id) FROM casino_users)); +SELECT setval('token_transactions_id_seq', (SELECT MAX(id) FROM token_transactions)); +``` + +--- + +## Post-Migration Checklist + +- [ ] Verify row counts match source +- [ ] Spot-check specific users (karma, level, tokens) +- [ ] Test `!profile` with a known user +- [ ] Test `thanks @someone` and verify karma increment +- [ ] Test casino commands +- [ ] Verify auto-increment sequences are correct + +## Risk Mitigation + +- **MySQL stays running** for 24-48h after cutover β€” instant rollback by redeploying the old bot image +- **MySQL backup** before migration: `mysqldump -u root -p udcbot > backup.sql` +- **PostgreSQL is idempotent**: Tables are created by the bot on startup; migration can be re-run with `TRUNCATE` first +- **Bot is down during migration** (scaled to 0) β€” no data inconsistency possible +- **Rollback plan**: Scale down new bot β†’ scale up old bot β†’ MySQL is still there with original data + +## K8s Manifest Changes for Cutover + +To run both databases temporarily, you need to **add** `postgresql.yaml` + PostgreSQL ExternalSecret to `k8s/prod/` **before** removing `mysql.yaml`. The PR should be split or the manifests applied in steps: + +1. First ArgoCD sync: Add PostgreSQL manifests (MySQL stays) +2. Run migration Job +3. Second ArgoCD sync: Deploy new bot image + remove MySQL manifests + +## Timeline + +1. Deploy PostgreSQL in prod alongside MySQL (5 min) +2. Scale bot to 0 (1 min) +3. Backup MySQL (5 min) +4. Deploy pgloader Job (5 min) +5. Wait for completion (< 1 min for 54K rows) +6. Verify data (10 min) +7. Deploy new bot pointing to PostgreSQL (5 min) +8. Test bot commands (10 min) +9. Monitor for 24-48h +10. Remove MySQL (5 min) + +**Active work: ~45 minutes** | **Total with monitoring: 24-48h** diff --git a/docs/plans/done/mysql-to-postgresql-changes.md b/docs/plans/done/mysql-to-postgresql-changes.md new file mode 100644 index 00000000..86c35b53 --- /dev/null +++ b/docs/plans/done/mysql-to-postgresql-changes.md @@ -0,0 +1,268 @@ +# MySQL β†’ PostgreSQL Code & Schema Changes + +> Reference document for PR #375 (`feature/postgre`). Describes **what** was changed in the codebase and **why**. +> +> For the operational data migration procedure, see [data-migration-mysql-to-postgresql.md](../data-migration-mysql-to-postgresql.md). + +## Table of Contents + +- [1. Database Schema Differences](#1-database-schema-differences) +- [2. Application Code Changes](#2-application-code-changes) +- [3. Infrastructure Changes](#3-infrastructure-changes) +- [4. pgloader Gotchas](#4-pgloader-gotchas) +- [5. Files Summary](#5-files-summary) + +--- + +## 1. Database Schema Differences + +### 1.1. Type Mapping + +PostgreSQL does not support unsigned integer types. All `uint`/`ulong` C# properties mapped to DB columns were changed to `int`/`long`. + +| MySQL Type | PostgreSQL Type | C# Type | Affected Columns | +|-----------|----------------|---------|-----------------| +| `int unsigned` | `integer` | `int` | All `Id`, Karma*, KarmaGiven, Level | +| `bigint unsigned` | `bigint` | `long` | Exp, Tokens, Amount | +| `datetime` | `timestamptz` | `DateTime` | CreatedAt, UpdatedAt, LastDailyReward | +| `auto_increment` | `SERIAL` | β€” | All primary keys | +| `varchar(N)` | `varchar(N)` | `string` | No change | +| `text` | `text` | `string` | No change | + +### 1.2. Identifier Casing + +PostgreSQL lowercases all unquoted identifiers. The DDL in `DatabaseService.cs` uses unquoted names (e.g., `UserID` becomes `userid` in the PG catalog). Insight.Database maps C# PascalCase properties to PG lowercase columns automatically via case-insensitive matching. + +**Never use double-quoted identifiers** in PostgreSQL DDL β€” it forces case-sensitivity and breaks Insight.Database's automatic mapping. + +### 1.3. `token_transactions` Table + +The PostgreSQL schema matches the MySQL schema to allow direct data migration via pgloader (which maps columns by name, case-insensitively). + +| Column | MySQL | PostgreSQL | +|--------|-------|-----------| +| Id | `int unsigned auto_increment` | `SERIAL` | +| UserID | `varchar(32) NOT NULL` | `varchar(32) NOT NULL` | +| TargetUserID | `varchar(32) NULL` | `varchar(32) DEFAULT NULL` | +| Amount | `bigint NOT NULL` | `bigint NOT NULL` | +| TransactionType | `varchar(50) NOT NULL` | `varchar(50) NOT NULL` | +| Description | `text NULL` | `text DEFAULT NULL` | +| CreatedAt | `datetime NOT NULL` | `timestamptz NOT NULL` | + +**Design note:** `TransactionType` is stored as a human-readable string (e.g., `"Game"`, `"Gift"`) rather than an integer enum. `Description` stores JSON-serialized metadata as plain text rather than using PostgreSQL's `jsonb` type. Both choices preserve compatibility with the existing MySQL data. + +### 1.4. `karma_reset_meta` β€” New Table (PG only) + +MySQL has a built-in EVENT scheduler that resets weekly/monthly/yearly karma columns. **PostgreSQL has no EVENT scheduler.** + +The standard PG alternative is the `pg_cron` extension, but it requires superuser access and isn't always available in managed/containerized deployments. + +**Solution:** A new C# background service (`KarmaResetService`) polls hourly and tracks last-reset timestamps in a `karma_reset_meta` table. This table is created automatically on startup β€” it has no MySQL counterpart and is not part of the data migration. + +--- + +## 2. Application Code Changes + +### 2.1. NuGet Packages + +| Package | Old | New | +|---------|-----|-----| +| `MySql.Data` | (removed) | β€” | +| `Insight.Database` | 8.0.5 | 8.0.6 | +| `Insight.Database.Providers.PostgreSQL` | (new) | 8.0.6 | + +### 2.2. Database Layer (`DatabaseService.cs`) + +- `MySqlConnection` β†’ `NpgsqlConnection` +- Registered `PostgreSQLInsightDbProvider` on startup +- Full DDL rewrite to PostgreSQL syntax (`SERIAL`, `timestamptz`, no double-quotes, no unsigned types) + +### 2.3. `uint` β†’ `int` / `ulong` β†’ `long` + +Npgsql throws `DbType.UInt32 isn't supported by PostgreSQL or Npgsql` for unsigned C# types. All 17+ files with DB-mapped unsigned properties were changed: + +- `Domain/ProfileData.cs` β€” karma/level fields +- `Domain/Casino/CasinoUser.cs` β€” Id +- `Domain/Casino/Game.cs`, `GamePlayer.cs`, `GameSession.cs` +- `Domain/Casino/Games/Cards/Blackjack/Blackjack.cs` +- `Domain/Casino/Games/Cards/Poker/Poker.cs` +- `Domain/Casino/Games/RockPaperScissors/RockPaperScissors.cs` +- `Extensions/CasinoRepository.cs`, `UserDBRepository.cs` +- `Modules/Casino/CasinoSlashModule.cs`, `CasinoSlashModule.Games.cs` +- `Modules/UserModule.cs` +- `Services/Casino/CasinoService.cs`, `GameService.cs` +- `Services/UserService.cs` +- `Settings/Deserialized/Settings.cs` + +### 2.4. SQL Syntax Differences + +| MySQL | PostgreSQL | Files | +|-------|-----------|-------| +| `RAND()` | `RANDOM()` | `UserDBRepository.cs` | +| `INSERT...SELECT LAST_INSERT_ID()` | `INSERT...RETURNING *` | `UserDBRepository.cs`, `CasinoRepository.cs` | +| `SHOW COLUMNS FROM...` | `information_schema.columns` query | `DBConnectionExtension.cs` | +| `::jsonb` cast | Removed (column is now `text`) | `CasinoRepository.cs` | + +### 2.5. `TransactionKind` Enum and `TransactionType` Column + +The C# enum is called `TransactionKind` (not `TransactionType`) to avoid a naming collision with the `TransactionType` string property on `TokenTransaction` that maps to the DB `transactiontype` varchar(50) column. + +**How the mapping works:** + +1. DB column `transactiontype` (varchar) maps to `TokenTransaction.TransactionType` (string property) +2. `TokenTransaction.Kind` is a computed property: + - Getter: parses `TransactionType` string to `TransactionKind` enum (case-insensitive, defaults to `Admin`) + - Setter: converts enum to string via `.ToString()` and assigns to `TransactionType` +3. `CasinoProps.TransactionType` constant = `"TransactionType"` (the column name) +4. `GetTransactionsOfType()` takes a `string` parameter, called with `nameof(TransactionKind.Game)` + +### 2.6. `Description` Column and `Details` Dictionary + +The DB column `description` (text) stores JSON-serialized metadata as plain text. In C#, `TokenTransaction.Description` is the DB-mapped property, while `TokenTransaction.Details` (Dictionary) is the in-memory representation. + +The `Description` setter handles both formats: +- JSON strings (normal bot data): deserialized to `Dictionary` +- Plain text (migrated MySQL data): caught via `JsonException`, stored as `{ "text": "" }` + +### 2.7. `TargetUserID` Column + +Added to match MySQL schema. Currently **unused** by application code β€” the target user for gift transfers is stored in `Details["from"]`/`Details["to"]`. The column exists for migration compatibility and potential future use. + +### 2.8. `KarmaResetService` (new) + +**File:** `Services/KarmaResetService.cs` β€” registered in `Program.cs` as singleton. + +Replaces MySQL EVENT scheduler: +- Background loop checks hourly +- Resets `KarmaWeekly` on Mondays, `KarmaMonthly` on 1st, `KarmaYearly` on Jan 1st +- Persists timestamps in `karma_reset_meta` table (auto-created) +- Catches up missed resets on startup (e.g., bot was down) + +### 2.9. Other Changes + +- **`DBConnectionExtension.cs`**: Changed `using MySql.Data.MySqlClient` β†’ `using Npgsql`. `ColumnExists` now queries `information_schema.columns` with parameterized SQL and `LOWER()` for case-insensitive matching. +- **`ModerationModule.cs`**: Removed orphaned `BouncyCastle` import. + +--- + +## 3. Infrastructure Changes + +### 3.1. Docker Compose + +| Service | Before | After | +|---------|--------|-------| +| Database | `mysql:8.0` | `postgres:16` | +| Admin UI | phpMyAdmin | `adminer:4` (Dracula theme) | +| Port | 3306 | 5432 | + +Environment variables: `MYSQL_*` β†’ `POSTGRES_*`. Volume: `mysql_data` β†’ `postgres_data`. + +### 3.2. Kubernetes Manifests (dev + prod) + +| File | Change | +|------|--------| +| `postgresql.yaml` | **New.** StatefulSet + Service + PVC. | +| `postgresql-backup.yaml` | **New.** CronJob using `pg_dump` + S3 upload. | +| `mysql-backup.yaml` | **Deleted.** | +| `phpmyadmin.yaml` β†’ `adminer.yaml` | **Renamed and rewritten.** Adminer uses ~13 Mi RAM vs pgAdmin's ~194 Mi. | +| `pgadmin.yaml` | **Deleted.** | +| `pgloader-migration.yaml` | **New.** ConfigMap + Job for data migration. | +| `external-secrets.yaml` | Added `postgresql-credentials`. MySQL secrets kept temporarily. | +| `bot-config.yaml` | Connection string changed to PostgreSQL format. | +| `bot.yaml` | Init container waits for port 5432 instead of 3306. | + +### 3.3. ExternalSecrets (1Password) + +**New permanent items:** + +| 1Password Item | K8s Secret | Purpose | +|----------------|-----------|---------| +| `PostgreSQL Server - Dev` | `postgresql-credentials` | PG password (dev) | +| `PostgreSQL Server - Prod` | `postgresql-credentials` | PG password (prod) | + +**Temporary items (remove after migration):** + +| 1Password Item | K8s Secret | Purpose | +|----------------|-----------|---------| +| `MySQL Server - Root User - Dev/Prod` | `mysql-credentials` | For pgloader | +| `MySQL Server - UDC User - Dev/Prod` | `mysql-user-credentials` | For pgloader | + +### 3.4. Resource Comparison + +Dev PostgreSQL stack uses ~47% less RAM than prod MySQL stack: + +| Component | MySQL Stack (prod) | PostgreSQL Stack (dev) | +|-----------|-------------------|----------------------| +| Database | ~614 Mi | ~303 Mi | +| Admin UI | ~230 Mi (phpMyAdmin) | ~13 Mi (Adminer) | +| **Total** | **~844 Mi** | **~400 Mi (est.)** | + +--- + +## 4. pgloader Gotchas + +Lessons learned from 5 iterations of pgloader testing: + +| Issue | What Happened | Solution | +|-------|--------------|---------| +| `CAST` not supported in `data only` mode | pgloader threw syntax error on `CAST type int unsigned to integer` | Remove CAST β€” pgloader handles type coercion automatically in data-only mode | +| Env vars ignored | `PGPASSWORD` and `MYSQL_PWD` had no effect | Embed passwords directly in connection URLs (pgloader uses its own connection libraries) | +| Schema mismatch | pgloader couldn't find schema "udcbot" in PostgreSQL | Add `ALTER SCHEMA 'udcbot' RENAME TO 'public'` β€” MySQL DB name maps to a PG schema | +| Column name mismatch | `token_transactions` had different column names in MySQL vs PG | Changed PG schema to match MySQL column names (see section 1.3) | + +--- + +## 5. Files Summary + +### New Files + +| File | Purpose | +|------|---------| +| `Services/KarmaResetService.cs` | Replaces MySQL EVENT scheduler | +| `k8s/*/postgresql.yaml` | PostgreSQL StatefulSet + Service + PVC | +| `k8s/*/postgresql-backup.yaml` | CronJob for PG backups to S3 | +| `k8s/*/adminer.yaml` | Adminer web UI | +| `k8s/*/pgloader-migration.yaml` | Data migration Job | +| `docs/plans/data-migration-mysql-to-postgresql.md` | Operational migration guide | +| `docs/plans/done/mysql-to-postgresql-changes.md` | This document | + +### Modified Files (17+ C# + infra) + +| File | Change | +|------|--------| +| `DiscordBot.csproj` | NuGet changes | +| `Domain/Casino/CasinoUser.cs` | `uint`β†’`int`, enum rename, schema property renames | +| `Domain/ProfileData.cs` | `uint`β†’`int` | +| `Domain/Casino/Game*.cs` (4 files) | `uint`β†’`int` | +| `Extensions/CasinoRepository.cs` | SQL syntax, parameter types | +| `Extensions/DBConnectionExtension.cs` | MySQLβ†’Npgsql, `information_schema` | +| `Extensions/UserDBRepository.cs` | `RAND()`β†’`RANDOM()`, `RETURNING *` | +| `Modules/Casino/CasinoSlashModule*.cs` | Enum rename | +| `Modules/ModerationModule.cs` | Removed orphaned import | +| `Modules/UserModule.cs` | `uint`β†’`int` | +| `Services/DatabaseService.cs` | Complete DDL rewrite | +| `Services/Casino/CasinoService.cs` | Enum rename | +| `Services/Casino/GameService.cs` | Enum rename | +| `Services/UserService.cs` | `uint`β†’`int`, path changes | +| `Settings/Deserialized/Settings.cs` | `uint`β†’`int` | +| `Program.cs` | Registered `KarmaResetService` | +| `docker-compose.yml` | MySQLβ†’PostgreSQL, phpMyAdminβ†’Adminer | +| `README.md` | Updated setup instructions | +| `k8s/*/external-secrets.yaml` | Added PG secrets | +| `k8s/*/bot-config.yaml` | PG connection string | +| `k8s/*/bot.yaml` | Init container port 5432 | + +### Deleted Files + +| File | Reason | +|------|--------| +| `k8s/*/mysql-backup.yaml` | Replaced by `postgresql-backup.yaml` | +| `k8s/*/pgadmin.yaml` | Replaced by Adminer | + +### Temporary Files (remove after migration) + +| File | Remove When | +|------|------------| +| `k8s/*/mysql.yaml` | After prod data migration verified (24-48h) | +| `k8s/*/pgloader-migration.yaml` | After prod migration complete | +| MySQL ExternalSecret entries | After MySQL fully decommissioned | diff --git a/k8s/dev/phpmyadmin.yaml b/k8s/dev/adminer.yaml similarity index 58% rename from k8s/dev/phpmyadmin.yaml rename to k8s/dev/adminer.yaml index 9f3e216a..81bb0fcf 100644 --- a/k8s/dev/phpmyadmin.yaml +++ b/k8s/dev/adminer.yaml @@ -2,47 +2,47 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: phpmyadmin + name: adminer namespace: udc-bot-dev labels: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer app.kubernetes.io/part-of: udc-bot environment: dev spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer template: metadata: labels: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer app.kubernetes.io/part-of: udc-bot environment: dev spec: containers: - - name: phpmyadmin - image: phpmyadmin:5.2.3 + - name: adminer + image: adminer:4 ports: - - containerPort: 80 + - containerPort: 8080 protocol: TCP env: - - name: PMA_HOST - value: mysql - - name: PMA_PORT - value: "3306" + - name: ADMINER_DEFAULT_SERVER + value: postgresql + - name: ADMINER_DESIGN + value: dracula resources: requests: - cpu: 25m - memory: 64Mi + cpu: 10m + memory: 32Mi limits: - cpu: 200m - memory: 256Mi + cpu: 100m + memory: 128Mi readinessProbe: httpGet: path: / - port: 80 - initialDelaySeconds: 10 + port: 8080 + initialDelaySeconds: 5 periodSeconds: 10 securityContext: allowPrivilegeEscalation: false @@ -50,47 +50,46 @@ spec: apiVersion: v1 kind: Service metadata: - name: phpmyadmin + name: adminer namespace: udc-bot-dev labels: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer app.kubernetes.io/part-of: udc-bot environment: dev spec: type: ClusterIP ports: - port: 80 - targetPort: 80 + targetPort: 8080 protocol: TCP selector: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: phpmyadmin + name: adminer namespace: udc-bot-dev labels: - app.kubernetes.io/name: phpmyadmin + app.kubernetes.io/name: adminer app.kubernetes.io/part-of: udc-bot environment: dev annotations: cert-manager.io/cluster-issuer: letsencrypt-prod - traefik.ingress.kubernetes.io/router.middlewares: default-ip-allowlist@kubernetescrd spec: ingressClassName: traefik tls: - hosts: - - phpmyadmin.dev.bot.udc.ovh - secretName: phpmyadmin-dev-tls + - adminer.dev.bot.udc.ovh + secretName: adminer-dev-tls rules: - - host: phpmyadmin.dev.bot.udc.ovh + - host: adminer.dev.bot.udc.ovh http: paths: - path: / pathType: Prefix backend: service: - name: phpmyadmin + name: adminer port: number: 80 diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index db1d6292..e702a26a 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -13,7 +13,7 @@ data: Settings.json: | { "token": "${BOT_TOKEN}", - "DbConnectionString": "server=mysql;port=3306;database=udcbot;user id=udcbot;Password=${DB_PASSWORD};SSL Mode=None;Allow User Variables=True;AllowPublicKeyRetrieval=True", + "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", "invite": "InviteLink", // Currently Unused /*Server Info*/ "serverRootPath": "./SERVER", diff --git a/k8s/dev/bot.yaml b/k8s/dev/bot.yaml index 1c3e7fc8..5754624f 100644 --- a/k8s/dev/bot.yaml +++ b/k8s/dev/bot.yaml @@ -61,7 +61,7 @@ spec: - name: DB_PASSWORD valueFrom: secretKeyRef: - name: mysql-user-credentials + name: postgresql-credentials key: password - name: WEATHER_KEY valueFrom: @@ -111,14 +111,14 @@ spec: capabilities: drop: - ALL - - name: wait-for-mysql + - name: wait-for-postgresql image: busybox:1.37 command: - sh - -c - | - until nc -z mysql 3306; do - echo "Waiting for MySQL..." + until nc -z postgresql 5432; do + echo "Waiting for PostgreSQL..." sleep 2 done securityContext: @@ -128,7 +128,7 @@ spec: - ALL containers: - name: bot - image: ghcr.io/unity-developer-community/udc-bot-dev:latest + image: ghcr.io/unity-developer-community/udc-bot-dev:d6223c0 volumeMounts: - name: app-settings mountPath: /app/Settings diff --git a/k8s/dev/external-secrets.yaml b/k8s/dev/external-secrets.yaml index 97bfd10a..521f5389 100644 --- a/k8s/dev/external-secrets.yaml +++ b/k8s/dev/external-secrets.yaml @@ -1,4 +1,24 @@ --- +# PostgreSQL password β€” from 1Password "PostgreSQL Server - Dev" +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: postgresql-credentials + namespace: udc-bot-dev +spec: + refreshInterval: 1h + secretStoreRef: + name: onepassword + kind: ClusterSecretStore + target: + name: postgresql-credentials + data: + - secretKey: password + remoteRef: + key: "PostgreSQL Server - Dev" + property: password +--- +# TEMPORARY: MySQL credentials needed for migration testing. Remove after migration. # MySQL root password β€” from 1Password "MySQL Server - Root User - Dev" apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -18,6 +38,7 @@ spec: key: "MySQL Server - Root User - Dev" property: password --- +# TEMPORARY: MySQL user credentials needed for migration testing. Remove after migration. # MySQL udcbot user password β€” from 1Password "MySQL Server - UDC User - Dev" apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -56,29 +77,6 @@ spec: key: "Bot Token - Dev" property: identifiant --- -# AWS credentials for MySQL backups to S3 β€” from 1Password "AWS Backup Credentials" -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: mysql-backup-credentials - namespace: udc-bot-dev -spec: - refreshInterval: 1h - secretStoreRef: - name: onepassword - kind: ClusterSecretStore - target: - name: mysql-backup-credentials - data: - - secretKey: AWS_ACCESS_KEY_ID - remoteRef: - key: "AWS Backup Credentials" - property: access-key-id - - secretKey: AWS_SECRET_ACCESS_KEY - remoteRef: - key: "AWS Backup Credentials" - property: secret-access-key ---- # Bot API keys β€” from various 1Password items apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -113,3 +111,26 @@ spec: remoteRef: key: "AirlabAPI" property: api_key +--- +# AWS credentials for S3 backups β€” from 1Password "AWS Backup Credentials" +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: postgresql-backup-credentials + namespace: udc-bot-dev +spec: + refreshInterval: 1h + secretStoreRef: + name: onepassword + kind: ClusterSecretStore + target: + name: postgresql-backup-credentials + data: + - secretKey: AWS_ACCESS_KEY_ID + remoteRef: + key: "AWS Backup Credentials" + property: access-key-id + - secretKey: AWS_SECRET_ACCESS_KEY + remoteRef: + key: "AWS Backup Credentials" + property: secret-access-key diff --git a/k8s/dev/mysql-backup.yaml b/k8s/dev/mysql-backup.yaml deleted file mode 100644 index ad305e02..00000000 --- a/k8s/dev/mysql-backup.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# MySQL backup using databack/mysql-backup β€” handles its own daily schedule internally. -# Dumps go to S3 bucket "udc-bot-mysql-backups" with 90-day retention via S3 lifecycle. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mysql-backup - namespace: udc-bot-dev - labels: - app.kubernetes.io/name: mysql-backup - app.kubernetes.io/part-of: udc-bot - environment: dev -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app.kubernetes.io/name: mysql-backup - template: - metadata: - labels: - app.kubernetes.io/name: mysql-backup - app.kubernetes.io/part-of: udc-bot - environment: dev - spec: - containers: - - name: mysql-backup - image: databack/mysql-backup:1.4.0 - args: ["dump"] - env: - - name: DB_SERVER - value: mysql - - name: DB_PORT - value: "3306" - - name: DB_USER - value: root - - name: DB_PASS - valueFrom: - secretKeyRef: - name: mysql-credentials - key: password - - name: DB_DUMP_TARGET - value: s3://udc-bot-mysql-backups/udc-bot-mysql-dev - - name: DB_DUMP_FREQ - value: "1440" - - name: AWS_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: mysql-backup-credentials - key: AWS_ACCESS_KEY_ID - - name: AWS_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: mysql-backup-credentials - key: AWS_SECRET_ACCESS_KEY - - name: AWS_DEFAULT_REGION - value: eu-west-3 - resources: - requests: - cpu: 25m - memory: 64Mi - limits: - cpu: 100m - memory: 128Mi - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL diff --git a/k8s/dev/mysql.yaml b/k8s/dev/mysql.yaml index 8198f72f..88b0a674 100644 --- a/k8s/dev/mysql.yaml +++ b/k8s/dev/mysql.yaml @@ -1,4 +1,6 @@ --- +# TEMPORARY: Keep MySQL running alongside PostgreSQL for migration testing. +# Remove this file after migration is verified. apiVersion: v1 kind: PersistentVolumeClaim metadata: diff --git a/k8s/dev/pgloader-migration.yaml b/k8s/dev/pgloader-migration.yaml new file mode 100644 index 00000000..b65092f8 --- /dev/null +++ b/k8s/dev/pgloader-migration.yaml @@ -0,0 +1,107 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: pgloader-config + namespace: udc-bot-dev + labels: + app.kubernetes.io/name: pgloader-migration + app.kubernetes.io/part-of: udc-bot + environment: dev +data: + run-migration.sh: | + #!/bin/sh + set -e + + cat > /tmp/migration.load < /tmp/migration.load <