Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cee86e5
Initial plan
Copilot Jul 21, 2025
7802f60
Implement badge system with database layer and service
Copilot Jul 21, 2025
e2061f0
Update badge system with public/private visibility and user permissions
Copilot Jul 26, 2025
cebb565
Merge branch 'master' into copilot/fix-341
Pierre-Demessence Jul 26, 2025
c5ebfe6
Fix previous merge
Pierre-Demessence Jul 26, 2025
c6dada1
Fix bug
Pierre-Demessence Jul 26, 2025
1996dfa
Merge remote-tracking branch 'origin/master' into copilot/fix-341
Copilot Sep 10, 2026
ff9ae56
Merge master and resolve badge database conflicts
Copilot Sep 10, 2026
10792f7
Fix follow-up review findings after merge
Copilot Sep 10, 2026
3a2af44
Add grouped badge leaderboards
Copilot Sep 10, 2026
a7dc24a
Address grouped leaderboard review feedback
Copilot Sep 10, 2026
1825f71
Polish grouped badge leaderboard flow
Copilot Sep 10, 2026
c823bc3
Merge remote-tracking branch 'origin/master' into copilot/fix-341
Copilot Sep 12, 2026
71f0dd6
Move badge admin commands under admin slash group
Copilot Sep 12, 2026
9128a7e
Stop notifying users on badge awards
Copilot Sep 12, 2026
ea10d8d
Fix admin slash module follow-up issues
Copilot Sep 12, 2026
390bbcf
Fix birthday admin follow-up warnings
Copilot Sep 12, 2026
f72d10b
Enhance UserBadge class with default values and EnsureBadgeDetails me…
Pierre-Demessence Sep 13, 2026
660c2b0
Update BadgeSlashModule to use ephemeral responses for user interactions
Pierre-Demessence Sep 13, 2026
3bea3d1
Sort badges by title and ID in BadgeSlashModule; implement pagination…
Pierre-Demessence Sep 13, 2026
48de288
Refactor SQL queries in IBadgeRepo to alias badge properties in GetUs…
Pierre-Demessence Sep 13, 2026
3601b21
Remove page number from footer text in badge list embed
Pierre-Demessence Sep 13, 2026
f78a92e
feat(admin-badge): add ID autocomplete and optional edit fields
Pierre-Demessence Sep 13, 2026
70844be
fix(badge-list): limit badge display to prevent embed overflow and im…
Pierre-Demessence Sep 13, 2026
067cf05
fix(badge-list): only show awardedBy for admin
Pierre-Demessence Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions DiscordBot.Tests/Extensions/BadgeRepositoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using DiscordBot.Extensions;

namespace DiscordBot.Tests.Extensions;

public class BadgeRepositoryTests
{
[Fact]
public void EnsureBadgeDetails_UsesFlattenedBadgeColumns_WhenNavigationPropertyIsMissing()
{
var userBadge = new UserBadge
{
UserID = "123",
BadgeId = 42,
AwardedAt = new DateTime(2024, 01, 01, 12, 0, 0, DateTimeKind.Utc),
AwardedBy = "456",
BadgeTitle = "Test Badge",
BadgeDescription = "A very nice badge.",
BadgeGroupKey = "udcjam",
BadgeIsPublic = true
};

var badge = userBadge.EnsureBadgeDetails();

Assert.NotNull(badge);
Assert.Equal("Test Badge", badge.Title);
Assert.Equal("A very nice badge.", badge.Description);
Assert.Equal("udcjam", badge.GroupKey);
Assert.True(badge.IsPublic);
Assert.Same(badge, userBadge.Badge);
}
}
210 changes: 210 additions & 0 deletions DiscordBot/Extensions/BadgeRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using Insight.Database;

namespace DiscordBot.Extensions;

public class Badge
{
public int Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
Comment on lines +8 to +9
public string? GroupKey { get; set; }
public bool IsPublic { get; set; } = true;
public DateTime CreatedAt { get; set; }
}

public class UserBadge
{
public int Id { get; set; }
public string UserID { get; set; } = string.Empty;
public int BadgeId { get; set; }
public DateTime AwardedAt { get; set; }
public string AwardedBy { get; set; } = string.Empty;

// Flattened fields returned by joined badge queries.
public string? BadgeTitle { get; set; }
public string? BadgeDescription { get; set; }
public string? BadgeGroupKey { get; set; }
public bool BadgeIsPublic { get; set; } = true;
public DateTime? BadgeCreatedAt { get; set; }

// Navigation property used by code that expects a fully hydrated badge.
public Badge Badge { get; set; } = new();

public Badge EnsureBadgeDetails()
{
if (Badge != null && !string.IsNullOrWhiteSpace(Badge.Title) && !string.IsNullOrWhiteSpace(Badge.Description))
return Badge;

Badge = new Badge
{
Id = BadgeId,
Title = BadgeTitle ?? string.Empty,
Description = BadgeDescription ?? string.Empty,
GroupKey = BadgeGroupKey,
IsPublic = BadgeIsPublic,
CreatedAt = BadgeCreatedAt ?? AwardedAt
};

return Badge;
}
}

public class BadgeLeaderboardEntry
{
public string UserId { get; set; }

Check warning on line 54 in DiscordBot/Extensions/BadgeRepository.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Non-nullable property 'UserId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public long BadgeCount { get; set; }
}

/// <summary>
/// Table Properties for Badge. Intended to be used with IBadgeRepo and enforce consistency.
/// </summary>
public static class BadgeProps
{
public const string TableName = "badges";

public const string Id = nameof(Badge.Id);
public const string Title = nameof(Badge.Title);
public const string Description = nameof(Badge.Description);
public const string GroupKey = nameof(Badge.GroupKey);
public const string IsPublic = nameof(Badge.IsPublic);
public const string CreatedAt = nameof(Badge.CreatedAt);
}

/// <summary>
/// Table Properties for UserBadge. Intended to be used with IBadgeRepo and enforce consistency.
/// </summary>
public static class UserBadgeProps
{
public const string TableName = "user_badges";

public const string Id = nameof(UserBadge.Id);
public const string UserID = nameof(UserBadge.UserID);
public const string BadgeId = nameof(UserBadge.BadgeId);
public const string AwardedAt = nameof(UserBadge.AwardedAt);
public const string AwardedBy = nameof(UserBadge.AwardedBy);
}

public interface IBadgeRepo
{
#region Badge Management

[Sql($@"
INSERT INTO {BadgeProps.TableName} ({BadgeProps.Title}, {BadgeProps.Description}, {BadgeProps.GroupKey}, {BadgeProps.IsPublic}, {BadgeProps.CreatedAt})
VALUES (@{BadgeProps.Title}, @{BadgeProps.Description}, @{BadgeProps.GroupKey}, @{BadgeProps.IsPublic}, @{BadgeProps.CreatedAt})
RETURNING *")]
Task<Badge> CreateBadge(Badge badge);

[Sql($"SELECT * FROM {BadgeProps.TableName} ORDER BY {BadgeProps.Title}")]
Task<IList<Badge>> GetAllBadges();

[Sql($"SELECT * FROM {BadgeProps.TableName} WHERE {BadgeProps.IsPublic} = TRUE ORDER BY {BadgeProps.Title}")]
Task<IList<Badge>> GetPublicBadges();

[Sql($"SELECT * FROM {BadgeProps.TableName} WHERE {BadgeProps.Id} = @badgeId")]
Task<Badge> GetBadge(int badgeId);

[Sql($"SELECT * FROM {BadgeProps.TableName} WHERE {BadgeProps.Title} = @title")]
Task<Badge> GetBadgeByTitle(string title);
Comment on lines +103 to +107

[Sql($"UPDATE {BadgeProps.TableName} SET {BadgeProps.Title} = @{BadgeProps.Title}, {BadgeProps.Description} = @{BadgeProps.Description}, {BadgeProps.GroupKey} = @{BadgeProps.GroupKey}, {BadgeProps.IsPublic} = @{BadgeProps.IsPublic} WHERE {BadgeProps.Id} = @{BadgeProps.Id}")]
Task UpdateBadge(Badge badge);

[Sql($"DELETE FROM {BadgeProps.TableName} WHERE {BadgeProps.Id} = @badgeId")]
Task DeleteBadge(int badgeId);

#endregion // Badge Management

#region User Badge Management

[Sql($@"
INSERT INTO {UserBadgeProps.TableName} ({UserBadgeProps.UserID}, {UserBadgeProps.BadgeId}, {UserBadgeProps.AwardedAt}, {UserBadgeProps.AwardedBy})
VALUES (@{UserBadgeProps.UserID}, @{UserBadgeProps.BadgeId}, @{UserBadgeProps.AwardedAt}, @{UserBadgeProps.AwardedBy})
RETURNING *")]
Task<UserBadge> AssignBadgeToUser(UserBadge userBadge);

[Sql($"DELETE FROM {UserBadgeProps.TableName} WHERE {UserBadgeProps.UserID} = @userId AND {UserBadgeProps.BadgeId} = @badgeId")]
Task RemoveBadgeFromUser(string userId, int badgeId);

[Sql($@"
SELECT ub.*,
b.{BadgeProps.Title} AS {nameof(UserBadge.BadgeTitle)},
b.{BadgeProps.Description} AS {nameof(UserBadge.BadgeDescription)},
b.{BadgeProps.GroupKey} AS {nameof(UserBadge.BadgeGroupKey)},
b.{BadgeProps.IsPublic} AS {nameof(UserBadge.BadgeIsPublic)},
b.{BadgeProps.CreatedAt} AS {nameof(UserBadge.BadgeCreatedAt)}
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
WHERE ub.{UserBadgeProps.UserID} = @userId
ORDER BY ub.{UserBadgeProps.AwardedAt} DESC")]
Task<IList<UserBadge>> GetUserBadges(string userId);

[Sql($@"
SELECT ub.*,
b.{BadgeProps.Title} AS {nameof(UserBadge.BadgeTitle)},
b.{BadgeProps.Description} AS {nameof(UserBadge.BadgeDescription)},
b.{BadgeProps.GroupKey} AS {nameof(UserBadge.BadgeGroupKey)},
b.{BadgeProps.IsPublic} AS {nameof(UserBadge.BadgeIsPublic)},
b.{BadgeProps.CreatedAt} AS {nameof(UserBadge.BadgeCreatedAt)}
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
WHERE ub.{UserBadgeProps.UserID} = @userId AND b.{BadgeProps.IsPublic} = TRUE
ORDER BY ub.{UserBadgeProps.AwardedAt} DESC")]
Task<IList<UserBadge>> GetUserPublicBadges(string userId);

[Sql($@"
SELECT ub.{UserBadgeProps.UserID} AS UserId, COUNT(*) AS BadgeCount
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
GROUP BY ub.{UserBadgeProps.UserID}
ORDER BY BadgeCount DESC, ub.{UserBadgeProps.UserID}
LIMIT @limit")]
Task<IList<BadgeLeaderboardEntry>> GetBadgeLeaderboard(int limit);

[Sql($@"
SELECT ub.{UserBadgeProps.UserID} AS UserId, COUNT(*) AS BadgeCount
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
WHERE b.{BadgeProps.IsPublic} = TRUE
GROUP BY ub.{UserBadgeProps.UserID}
ORDER BY BadgeCount DESC, ub.{UserBadgeProps.UserID}
LIMIT @limit")]
Task<IList<BadgeLeaderboardEntry>> GetPublicBadgeLeaderboard(int limit);

[Sql($@"
SELECT ub.{UserBadgeProps.UserID} AS UserId, COUNT(*) AS BadgeCount
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
WHERE b.{BadgeProps.GroupKey} = @groupKey
GROUP BY ub.{UserBadgeProps.UserID}
ORDER BY BadgeCount DESC, ub.{UserBadgeProps.UserID}
LIMIT @limit")]
Task<IList<BadgeLeaderboardEntry>> GetBadgeLeaderboardByGroup(string groupKey, int limit);

[Sql($@"
SELECT ub.{UserBadgeProps.UserID} AS UserId, COUNT(*) AS BadgeCount
FROM {UserBadgeProps.TableName} ub
JOIN {BadgeProps.TableName} b ON ub.{UserBadgeProps.BadgeId} = b.{BadgeProps.Id}
WHERE b.{BadgeProps.IsPublic} = TRUE AND b.{BadgeProps.GroupKey} = @groupKey
GROUP BY ub.{UserBadgeProps.UserID}
ORDER BY BadgeCount DESC, ub.{UserBadgeProps.UserID}
LIMIT @limit")]
Task<IList<BadgeLeaderboardEntry>> GetPublicBadgeLeaderboardByGroup(string groupKey, int limit);

[Sql($@"
SELECT COUNT(*) FROM {UserBadgeProps.TableName}
WHERE {UserBadgeProps.UserID} = @userId AND {UserBadgeProps.BadgeId} = @badgeId")]
Task<long> CheckUserHasBadge(string userId, int badgeId);

[Sql($@"
SELECT ub.{UserBadgeProps.UserID}, ub.{UserBadgeProps.AwardedAt}, ub.{UserBadgeProps.AwardedBy}
FROM {UserBadgeProps.TableName} ub
WHERE ub.{UserBadgeProps.BadgeId} = @badgeId
ORDER BY ub.{UserBadgeProps.AwardedAt} DESC")]
Task<IList<UserBadge>> GetBadgeHolders(int badgeId);

#endregion // User Badge Management

/// <summary>Returns a count of badges in the Table, used for testing connection. </summary>
[Sql($"SELECT COUNT(*) FROM {BadgeProps.TableName}")]
Task<long> TestBadgeConnection();
}
Loading
Loading