diff --git a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
new file mode 100644
index 00000000..5b427a3e
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
@@ -0,0 +1,167 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using SVSim.Database.Entities.Guild;
+using System.Data;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public sealed class GuildChatMessageRepository : IGuildChatMessageRepository
+{
+ private readonly SVSimDbContext _db;
+
+ public GuildChatMessageRepository(SVSimDbContext db) { _db = db; }
+
+ ///
+ /// Allocates the next per-guild monotonic MessageId and inserts the row inside a serializable
+ /// transaction (Postgres). The unique (GuildId, MessageId) index is the backstop — if a
+ /// concurrent insert wins the race we retry once before surfacing the exception.
+ /// InMemory provider does not support transactions; callers are single-threaded in tests.
+ ///
+ public async Task AppendAsync(GuildChatMessage msg, CancellationToken ct = default)
+ {
+ for (int attempt = 0; attempt < 2; attempt++)
+ {
+ IDbContextTransaction? tx = null;
+ try
+ {
+ // BeginTransactionAsync throws NotSupportedException on the InMemory provider.
+ // Catch and fall through — single-threaded tests don't need transaction isolation.
+ try
+ {
+ tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
+ }
+ catch (InvalidOperationException)
+ {
+ // InMemory provider does not support transactions.
+ }
+
+ int nextId = await GetMaxMessageIdAsync(msg.GuildId, ct) + 1;
+ msg.MessageId = nextId;
+
+ _db.GuildChatMessages.Add(msg);
+ await _db.SaveChangesAsync(ct);
+
+ if (tx is not null)
+ await tx.CommitAsync(ct);
+
+ return msg;
+ }
+ catch (DbUpdateException) when (attempt == 0)
+ {
+ // Unique violation on (GuildId, MessageId) — another writer raced us.
+ // Detach the failed entity and retry once.
+ if (tx is not null)
+ {
+ try { await tx.RollbackAsync(ct); } catch { /* ignore rollback failure */ }
+ }
+ _db.Entry(msg).State = EntityState.Detached;
+ msg.MessageId = 0; // will be re-assigned on retry
+ }
+ finally
+ {
+ if (tx is not null)
+ await tx.DisposeAsync();
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Failed to allocate a unique MessageId for GuildId={msg.GuildId} after 2 attempts.");
+ }
+
+ ///
+ /// Window query for message history.
+ /// direction 1 = OLD (walk backwards from start, return ascending),
+ /// direction 2 = NEW (walk forwards from start),
+ /// direction 3 = BOTH (around start, older half + newer half).
+ /// start = 0 means "latest".
+ /// Result is always ordered oldest-to-newest.
+ ///
+ public async Task> GetWindowAsync(
+ int guildId, int start, int direction, int limit, CancellationToken ct = default)
+ {
+ IQueryable baseQ = _db.GuildChatMessages
+ .Where(m => m.GuildId == guildId);
+
+ if (start == 0)
+ {
+ // Latest N messages.
+ return await baseQ
+ .OrderByDescending(m => m.MessageId)
+ .Take(limit)
+ .OrderBy(m => m.MessageId)
+ .ToListAsync(ct);
+ }
+
+ switch (direction)
+ {
+ case 1: // OLD — messages older than (and including) start
+ return await baseQ
+ .Where(m => m.MessageId <= start)
+ .OrderByDescending(m => m.MessageId)
+ .Take(limit)
+ .OrderBy(m => m.MessageId)
+ .ToListAsync(ct);
+
+ case 2: // NEW — messages newer than (and including) start
+ return await baseQ
+ .Where(m => m.MessageId >= start)
+ .OrderBy(m => m.MessageId)
+ .Take(limit)
+ .ToListAsync(ct);
+
+ case 3: // BOTH — half older, half newer around start
+ {
+ int half = limit / 2;
+ var older = await baseQ
+ .Where(m => m.MessageId <= start)
+ .OrderByDescending(m => m.MessageId)
+ .Take(half)
+ .ToListAsync(ct);
+ var newer = await baseQ
+ .Where(m => m.MessageId > start)
+ .OrderBy(m => m.MessageId)
+ .Take(limit - half)
+ .ToListAsync(ct);
+ return older.OrderBy(m => m.MessageId).Concat(newer).ToList();
+ }
+
+ default:
+ return await baseQ
+ .OrderByDescending(m => m.MessageId)
+ .Take(limit)
+ .OrderBy(m => m.MessageId)
+ .ToListAsync(ct);
+ }
+ }
+
+ public async Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default)
+ {
+ var max = await _db.GuildChatMessages
+ .Where(m => m.GuildId == guildId)
+ .MaxAsync(m => (int?)m.MessageId, ct);
+ return max ?? 0;
+ }
+
+ public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default)
+ {
+ var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct);
+ _db.GuildChatMessages.RemoveRange(messages);
+ if (messages.Count > 0)
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default)
+ {
+ var msg = await _db.GuildChatMessages
+ .FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct);
+
+ if (msg is null) return false;
+ if (msg.AuthorViewerId != callerViewerId) return false;
+ if (msg.DeckPayload is null) return false;
+
+ msg.DeckPayload = null;
+ msg.Body = "";
+ await _db.SaveChangesAsync(ct);
+ return true;
+ }
+}
diff --git a/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs b/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs
new file mode 100644
index 00000000..87c92110
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore;
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public sealed class GuildInviteRepository : IGuildInviteRepository
+{
+ private readonly SVSimDbContext _db;
+
+ public GuildInviteRepository(SVSimDbContext db) { _db = db; }
+
+ public Task GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default)
+ => _db.GuildInvites.FirstOrDefaultAsync(
+ i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
+
+ public async Task> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
+ => await _db.GuildInvites
+ .Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
+ .ToListAsync(ct);
+
+ public async Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
+ => await _db.GuildInvites
+ .Where(i => i.GuildId == guildId && i.Status == GuildInviteStatus.Pending)
+ .ToListAsync(ct);
+
+ public Task CountPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
+ => _db.GuildInvites.CountAsync(
+ i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending, ct);
+
+ public async Task AddAsync(GuildInvite invite, CancellationToken ct = default)
+ {
+ _db.GuildInvites.Add(invite);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task UpdateStatusAsync(int guildId, long inviteeViewerId, GuildInviteStatus status,
+ DateTime respondedAt, CancellationToken ct = default)
+ {
+ var invite = await _db.GuildInvites
+ .FirstOrDefaultAsync(i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
+ if (invite is null) return;
+ invite.Status = status;
+ invite.RespondedAt = respondedAt;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task ConsumePendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default)
+ {
+ var pending = await _db.GuildInvites
+ .Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
+ .ToListAsync(ct);
+ foreach (var invite in pending)
+ {
+ invite.Status = GuildInviteStatus.Consumed;
+ invite.RespondedAt = respondedAt;
+ }
+ if (pending.Count > 0)
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default)
+ {
+ var invites = await _db.GuildInvites.Where(i => i.GuildId == guildId).ToListAsync(ct);
+ _db.GuildInvites.RemoveRange(invites);
+ if (invites.Count > 0)
+ await _db.SaveChangesAsync(ct);
+ }
+}
diff --git a/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs b/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs
new file mode 100644
index 00000000..cc9231cb
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore;
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public sealed class GuildJoinRequestRepository : IGuildJoinRequestRepository
+{
+ private readonly SVSimDbContext _db;
+
+ public GuildJoinRequestRepository(SVSimDbContext db) { _db = db; }
+
+ public Task GetAsync(int guildId, long viewerId, CancellationToken ct = default)
+ => _db.GuildJoinRequests.FirstOrDefaultAsync(
+ r => r.GuildId == guildId && r.ViewerId == viewerId, ct);
+
+ public async Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
+ => await _db.GuildJoinRequests
+ .Where(r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending)
+ .ToListAsync(ct);
+
+ public async Task> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default)
+ => await _db.GuildJoinRequests
+ .Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending)
+ .ToListAsync(ct);
+
+ public Task CountPendingForGuildAsync(int guildId, CancellationToken ct = default)
+ => _db.GuildJoinRequests.CountAsync(
+ r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending, ct);
+
+ public async Task AddAsync(GuildJoinRequest r, CancellationToken ct = default)
+ {
+ _db.GuildJoinRequests.Add(r);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task UpdateStatusAsync(int guildId, long viewerId, GuildJoinRequestStatus status,
+ DateTime respondedAt, CancellationToken ct = default)
+ {
+ var req = await _db.GuildJoinRequests
+ .FirstOrDefaultAsync(r => r.GuildId == guildId && r.ViewerId == viewerId, ct);
+ if (req is null) return;
+ req.Status = status;
+ req.RespondedAt = respondedAt;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task CancelPendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default)
+ {
+ var pending = await _db.GuildJoinRequests
+ .Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending)
+ .ToListAsync(ct);
+ foreach (var req in pending)
+ {
+ req.Status = GuildJoinRequestStatus.Canceled;
+ req.RespondedAt = respondedAt;
+ }
+ if (pending.Count > 0)
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default)
+ {
+ var requests = await _db.GuildJoinRequests.Where(r => r.GuildId == guildId).ToListAsync(ct);
+ _db.GuildJoinRequests.RemoveRange(requests);
+ if (requests.Count > 0)
+ await _db.SaveChangesAsync(ct);
+ }
+}
diff --git a/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs b/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs
new file mode 100644
index 00000000..f7e7b48b
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs
@@ -0,0 +1,73 @@
+using Microsoft.EntityFrameworkCore;
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public sealed class GuildMemberRepository : IGuildMemberRepository
+{
+ private readonly SVSimDbContext _db;
+
+ public GuildMemberRepository(SVSimDbContext db) { _db = db; }
+
+ ///
+ /// The unique index on ViewerId guarantees at most one membership row per viewer.
+ ///
+ public Task GetMembershipAsync(long viewerId, CancellationToken ct = default)
+ => _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
+
+ public async Task> ListByGuildAsync(int guildId, CancellationToken ct = default)
+ => await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct);
+
+ public Task CountByGuildAsync(int guildId, CancellationToken ct = default)
+ => _db.GuildMembers.CountAsync(m => m.GuildId == guildId, ct);
+
+ public Task CountByGuildAndRoleAsync(int guildId, GuildRole role, CancellationToken ct = default)
+ => _db.GuildMembers.CountAsync(m => m.GuildId == guildId && m.Role == role, ct);
+
+ public async Task AddAsync(GuildMember m, CancellationToken ct = default)
+ {
+ _db.GuildMembers.Add(m);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task UpdateRoleAsync(int guildId, long viewerId, GuildRole role, CancellationToken ct = default)
+ {
+ var member = await _db.GuildMembers
+ .FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == viewerId, ct);
+ if (member is null) return;
+ member.Role = role;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task RemoveAsync(int guildId, long viewerId, CancellationToken ct = default)
+ {
+ var member = await _db.GuildMembers
+ .FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == viewerId, ct);
+ if (member is null) return;
+ _db.GuildMembers.Remove(member);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default)
+ {
+ var members = await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct);
+ _db.GuildMembers.RemoveRange(members);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task> CountBatchByGuildIdsAsync(
+ IReadOnlyList guildIds, CancellationToken ct = default)
+ {
+ var counts = await _db.GuildMembers
+ .Where(m => guildIds.Contains(m.GuildId))
+ .GroupBy(m => m.GuildId)
+ .Select(g => new { GuildId = g.Key, Count = g.Count() })
+ .ToListAsync(ct);
+
+ // Seed all requested guild ids so callers don't need a null-check for guilds with 0 members.
+ var result = guildIds.ToDictionary(id => id, _ => 0);
+ foreach (var row in counts)
+ result[row.GuildId] = row.Count;
+ return result;
+ }
+}
diff --git a/SVSim.Database/Repositories/Guild/GuildRepository.cs b/SVSim.Database/Repositories/Guild/GuildRepository.cs
new file mode 100644
index 00000000..6f094381
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/GuildRepository.cs
@@ -0,0 +1,72 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public sealed class GuildRepository : IGuildRepository
+{
+ private readonly SVSimDbContext _db;
+
+ public GuildRepository(SVSimDbContext db) { _db = db; }
+
+ public Task GetByIdAsync(int guildId, CancellationToken ct = default)
+ => _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
+
+ public Task GetActiveByIdAsync(int guildId, CancellationToken ct = default)
+ => _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId && g.BreakupAt == null, ct);
+
+ public Task GetWithMembersAsync(int guildId, CancellationToken ct = default)
+ => _db.Guilds
+ .Include(g => g.Members)
+ .AsSplitQuery()
+ .FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
+
+ public Task NameExistsAsync(string name, CancellationToken ct = default)
+ => _db.Guilds.AnyAsync(g => g.Name == name && g.BreakupAt == null, ct);
+
+ public async Task AddAsync(Entities.Guild.Guild g, CancellationToken ct = default)
+ {
+ _db.Guilds.Add(g);
+ await _db.SaveChangesAsync(ct);
+ return g;
+ }
+
+ public async Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default)
+ {
+ var guild = await _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
+ if (guild is null) return;
+ guild.BreakupAt = brokenUpAt;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task> SearchAsync(
+ string name, int activity, int joinCondition, int memberBucket,
+ int maxMemberCap, int resultCap, CancellationToken ct = default)
+ {
+ IQueryable q = _db.Guilds.Where(g => g.BreakupAt == null);
+
+ if (!string.IsNullOrEmpty(name))
+ q = q.Where(g => g.Name.Contains(name));
+
+ if (activity != 0)
+ q = q.Where(g => (int)g.Activity == activity);
+
+ if (joinCondition != 0)
+ q = q.Where(g => (int)g.JoinCondition == joinCondition);
+
+ if (memberBucket != 0)
+ {
+ q = memberBucket switch
+ {
+ 1 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 1
+ && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= 10),
+ 2 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 11
+ && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= 25),
+ 3 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 26
+ && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= maxMemberCap),
+ _ => q,
+ };
+ }
+
+ return await q.Take(resultCap).ToListAsync(ct);
+ }
+}
diff --git a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs
new file mode 100644
index 00000000..4a76f688
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs
@@ -0,0 +1,20 @@
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public interface IGuildChatMessageRepository
+{
+ /// Atomically allocate the next per-guild message_id and insert.
+ Task AppendAsync(GuildChatMessage msg, CancellationToken ct = default);
+
+ /// Window query. direction: 1=OLD (asc-walk older), 2=NEW (newer), 3=BOTH (around).
+ /// `start` may be 0 meaning "latest". Returns ordered oldest-to-newest.
+ Task> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default);
+
+ Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
+ Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
+
+ /// For /guild_chat/delete_deck: clears the deck payload + body without removing the message row,
+ /// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller.
+ Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default);
+}
diff --git a/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs b/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs
new file mode 100644
index 00000000..867bdc01
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs
@@ -0,0 +1,15 @@
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public interface IGuildInviteRepository
+{
+ Task GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default);
+ Task> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default);
+ Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
+ Task CountPendingForInviteeAsync(long viewerId, CancellationToken ct = default);
+ Task AddAsync(GuildInvite invite, CancellationToken ct = default);
+ Task UpdateStatusAsync(int guildId, long inviteeViewerId, GuildInviteStatus status, DateTime respondedAt, CancellationToken ct = default);
+ Task ConsumePendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default);
+ Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
+}
diff --git a/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs b/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs
new file mode 100644
index 00000000..970d00a3
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs
@@ -0,0 +1,15 @@
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public interface IGuildJoinRequestRepository
+{
+ Task GetAsync(int guildId, long viewerId, CancellationToken ct = default);
+ Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
+ Task> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default);
+ Task CountPendingForGuildAsync(int guildId, CancellationToken ct = default);
+ Task AddAsync(GuildJoinRequest r, CancellationToken ct = default);
+ Task UpdateStatusAsync(int guildId, long viewerId, GuildJoinRequestStatus status, DateTime respondedAt, CancellationToken ct = default);
+ Task CancelPendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default);
+ Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
+}
diff --git a/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs b/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs
new file mode 100644
index 00000000..75243808
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs
@@ -0,0 +1,18 @@
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public interface IGuildMemberRepository
+{
+ Task GetMembershipAsync(long viewerId, CancellationToken ct = default);
+ Task> ListByGuildAsync(int guildId, CancellationToken ct = default);
+ Task CountByGuildAsync(int guildId, CancellationToken ct = default);
+ Task CountByGuildAndRoleAsync(int guildId, GuildRole role, CancellationToken ct = default);
+ Task AddAsync(GuildMember m, CancellationToken ct = default);
+ Task UpdateRoleAsync(int guildId, long viewerId, GuildRole role, CancellationToken ct = default);
+ Task RemoveAsync(int guildId, long viewerId, CancellationToken ct = default);
+ Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
+
+ /// Returns a count of members per guild for the given guild ids. Used by Task 9 search.
+ Task> CountBatchByGuildIdsAsync(IReadOnlyList guildIds, CancellationToken ct = default);
+}
diff --git a/SVSim.Database/Repositories/Guild/IGuildRepository.cs b/SVSim.Database/Repositories/Guild/IGuildRepository.cs
new file mode 100644
index 00000000..6ee67938
--- /dev/null
+++ b/SVSim.Database/Repositories/Guild/IGuildRepository.cs
@@ -0,0 +1,27 @@
+using SVSim.Database.Entities.Guild;
+
+namespace SVSim.Database.Repositories.Guild;
+
+public interface IGuildRepository
+{
+ Task GetByIdAsync(int guildId, CancellationToken ct = default);
+
+ /// Returns guild only if not soft-deleted (BreakupAt is null).
+ Task GetActiveByIdAsync(int guildId, CancellationToken ct = default);
+
+ /// Returns guild + populated Members list (split-query).
+ Task GetWithMembersAsync(int guildId, CancellationToken ct = default);
+
+ Task NameExistsAsync(string name, CancellationToken ct = default);
+
+ Task AddAsync(Entities.Guild.Guild g, CancellationToken ct = default);
+
+ /// Soft-delete: sets BreakupAt to UtcNow. Caller is responsible for cascading
+ /// hard-deletes on Members / Invites / JoinRequests / ChatMessages.
+ Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default);
+
+ /// Filtered + bucketed search. Empty name = match-all; activity/joinCondition/bucket = 0 = any.
+ Task> SearchAsync(
+ string name, int activity, int joinCondition, int memberBucket,
+ int maxMemberCap, int resultCap, CancellationToken ct = default);
+}
diff --git a/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs b/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs
new file mode 100644
index 00000000..fd2ee3a1
--- /dev/null
+++ b/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs
@@ -0,0 +1,71 @@
+using NUnit.Framework;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using SVSim.Database;
+using SVSim.Database.Entities.Guild;
+using SVSim.Database.Repositories.Guild;
+using GuildEntity = SVSim.Database.Entities.Guild.Guild;
+
+namespace SVSim.UnitTests.Repositories.Guild;
+
+[TestFixture]
+public class GuildRepositoryTests
+{
+ private SVSimDbContext Db()
+ => new(NullLogger.Instance,
+ new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"guild-repo-{Guid.NewGuid()}")
+ .Options);
+
+ [Test]
+ public async Task Add_and_load_by_id_round_trips()
+ {
+ await using var db = Db();
+ var repo = new GuildRepository(db);
+ var g = new GuildEntity
+ {
+ GuildId = 100_000_007, Name = "Repo", Description = "",
+ LeaderViewerId = 1, EmblemId = 100000000,
+ Activity = GuildActivity.All, JoinCondition = GuildJoinCondition.Free,
+ CreatedAt = DateTime.UtcNow,
+ };
+ await repo.AddAsync(g);
+
+ var loaded = await repo.GetActiveByIdAsync(100_000_007);
+ Assert.That(loaded, Is.Not.Null);
+ Assert.That(loaded!.Name, Is.EqualTo("Repo"));
+ }
+
+ [Test]
+ public async Task SearchAsync_filters_by_activity_and_join_condition()
+ {
+ await using var db = Db();
+ var repo = new GuildRepository(db);
+ await repo.AddAsync(new GuildEntity { GuildId = 1, Name = "Alpha", Activity = GuildActivity.Royal, JoinCondition = GuildJoinCondition.Free, CreatedAt = DateTime.UtcNow });
+ await repo.AddAsync(new GuildEntity { GuildId = 2, Name = "Beta", Activity = GuildActivity.Elf, JoinCondition = GuildJoinCondition.Approval, CreatedAt = DateTime.UtcNow });
+
+ var royalFree = await repo.SearchAsync("", (int)GuildActivity.Royal, (int)GuildJoinCondition.Free, 0, 30, 50);
+ Assert.That(royalFree.Select(x => x.Name), Is.EquivalentTo(new[] { "Alpha" }));
+
+ var any = await repo.SearchAsync("", 0, 0, 0, 30, 50);
+ Assert.That(any, Has.Count.EqualTo(2));
+ }
+
+ [Test]
+ public async Task SearchAsync_bucket_3_includes_through_MaxMemberNum()
+ {
+ await using var db = Db();
+ var repo = new GuildRepository(db);
+ var bigId = 1;
+ await repo.AddAsync(new GuildEntity { GuildId = bigId, Name = "Big", Activity = GuildActivity.All, JoinCondition = GuildJoinCondition.Free, CreatedAt = DateTime.UtcNow });
+ for (int i = 0; i < 27; i++)
+ db.GuildMembers.Add(new GuildMember { GuildId = bigId, ViewerId = 1000 + i, Role = GuildRole.Regular, JoinedAt = DateTime.UtcNow });
+ await db.SaveChangesAsync();
+
+ var large = await repo.SearchAsync("", 0, 0, 3, 30, 50);
+ Assert.That(large.Select(x => x.GuildId), Has.Member(bigId));
+
+ var small = await repo.SearchAsync("", 0, 0, 1, 30, 50);
+ Assert.That(small.Select(x => x.GuildId), Has.No.Member(bigId));
+ }
+}