feat(guild): repositories for Guild aggregate
This commit is contained in:
167
SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
Normal file
167
SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
Normal file
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<GuildChatMessage> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(
|
||||
int guildId, int start, int direction, int limit, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<GuildChatMessage> 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<int> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
68
SVSim.Database/Repositories/Guild/GuildInviteRepository.cs
Normal file
68
SVSim.Database/Repositories/Guild/GuildInviteRepository.cs
Normal file
@@ -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<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default)
|
||||
=> _db.GuildInvites.FirstOrDefaultAsync(
|
||||
i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
|
||||
=> await _db.GuildInvites
|
||||
.Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildInvite>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
|
||||
=> await _db.GuildInvites
|
||||
.Where(i => i.GuildId == guildId && i.Status == GuildInviteStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<int> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<GuildJoinRequest?> GetAsync(int guildId, long viewerId, CancellationToken ct = default)
|
||||
=> _db.GuildJoinRequests.FirstOrDefaultAsync(
|
||||
r => r.GuildId == guildId && r.ViewerId == viewerId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildJoinRequest>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
|
||||
=> await _db.GuildJoinRequests
|
||||
.Where(r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildJoinRequest>> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default)
|
||||
=> await _db.GuildJoinRequests
|
||||
.Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<int> 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);
|
||||
}
|
||||
}
|
||||
73
SVSim.Database/Repositories/Guild/GuildMemberRepository.cs
Normal file
73
SVSim.Database/Repositories/Guild/GuildMemberRepository.cs
Normal file
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique index on ViewerId guarantees at most one membership row per viewer.
|
||||
/// </summary>
|
||||
public Task<GuildMember?> GetMembershipAsync(long viewerId, CancellationToken ct = default)
|
||||
=> _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildMember>> ListByGuildAsync(int guildId, CancellationToken ct = default)
|
||||
=> await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct);
|
||||
|
||||
public Task<int> CountByGuildAsync(int guildId, CancellationToken ct = default)
|
||||
=> _db.GuildMembers.CountAsync(m => m.GuildId == guildId, ct);
|
||||
|
||||
public Task<int> 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<Dictionary<int, int>> CountBatchByGuildIdsAsync(
|
||||
IReadOnlyList<int> 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;
|
||||
}
|
||||
}
|
||||
72
SVSim.Database/Repositories/Guild/GuildRepository.cs
Normal file
72
SVSim.Database/Repositories/Guild/GuildRepository.cs
Normal file
@@ -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<Entities.Guild.Guild?> GetByIdAsync(int guildId, CancellationToken ct = default)
|
||||
=> _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
|
||||
|
||||
public Task<Entities.Guild.Guild?> GetActiveByIdAsync(int guildId, CancellationToken ct = default)
|
||||
=> _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId && g.BreakupAt == null, ct);
|
||||
|
||||
public Task<Entities.Guild.Guild?> GetWithMembersAsync(int guildId, CancellationToken ct = default)
|
||||
=> _db.Guilds
|
||||
.Include(g => g.Members)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
|
||||
|
||||
public Task<bool> NameExistsAsync(string name, CancellationToken ct = default)
|
||||
=> _db.Guilds.AnyAsync(g => g.Name == name && g.BreakupAt == null, ct);
|
||||
|
||||
public async Task<Entities.Guild.Guild> 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<IReadOnlyList<Entities.Guild.Guild>> SearchAsync(
|
||||
string name, int activity, int joinCondition, int memberBucket,
|
||||
int maxMemberCap, int resultCap, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Entities.Guild.Guild> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using SVSim.Database.Entities.Guild;
|
||||
|
||||
namespace SVSim.Database.Repositories.Guild;
|
||||
|
||||
public interface IGuildChatMessageRepository
|
||||
{
|
||||
/// <summary>Atomically allocate the next per-guild message_id and insert.</summary>
|
||||
Task<GuildChatMessage> AppendAsync(GuildChatMessage msg, CancellationToken ct = default);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default);
|
||||
|
||||
Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
|
||||
Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default);
|
||||
}
|
||||
15
SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs
Normal file
15
SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SVSim.Database.Entities.Guild;
|
||||
|
||||
namespace SVSim.Database.Repositories.Guild;
|
||||
|
||||
public interface IGuildInviteRepository
|
||||
{
|
||||
Task<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildInvite>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
|
||||
Task<int> 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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using SVSim.Database.Entities.Guild;
|
||||
|
||||
namespace SVSim.Database.Repositories.Guild;
|
||||
|
||||
public interface IGuildJoinRequestRepository
|
||||
{
|
||||
Task<GuildJoinRequest?> GetAsync(int guildId, long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildJoinRequest>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildJoinRequest>> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<int> 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);
|
||||
}
|
||||
18
SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs
Normal file
18
SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using SVSim.Database.Entities.Guild;
|
||||
|
||||
namespace SVSim.Database.Repositories.Guild;
|
||||
|
||||
public interface IGuildMemberRepository
|
||||
{
|
||||
Task<GuildMember?> GetMembershipAsync(long viewerId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GuildMember>> ListByGuildAsync(int guildId, CancellationToken ct = default);
|
||||
Task<int> CountByGuildAsync(int guildId, CancellationToken ct = default);
|
||||
Task<int> 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);
|
||||
|
||||
/// <summary>Returns a count of members per guild for the given guild ids. Used by Task 9 search.</summary>
|
||||
Task<Dictionary<int, int>> CountBatchByGuildIdsAsync(IReadOnlyList<int> guildIds, CancellationToken ct = default);
|
||||
}
|
||||
27
SVSim.Database/Repositories/Guild/IGuildRepository.cs
Normal file
27
SVSim.Database/Repositories/Guild/IGuildRepository.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using SVSim.Database.Entities.Guild;
|
||||
|
||||
namespace SVSim.Database.Repositories.Guild;
|
||||
|
||||
public interface IGuildRepository
|
||||
{
|
||||
Task<Entities.Guild.Guild?> GetByIdAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns guild only if not soft-deleted (BreakupAt is null).</summary>
|
||||
Task<Entities.Guild.Guild?> GetActiveByIdAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns guild + populated Members list (split-query).</summary>
|
||||
Task<Entities.Guild.Guild?> GetWithMembersAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> NameExistsAsync(string name, CancellationToken ct = default);
|
||||
|
||||
Task<Entities.Guild.Guild> AddAsync(Entities.Guild.Guild g, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Soft-delete: sets BreakupAt to UtcNow. Caller is responsible for cascading
|
||||
/// hard-deletes on Members / Invites / JoinRequests / ChatMessages.</summary>
|
||||
Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Filtered + bucketed search. Empty name = match-all; activity/joinCondition/bucket = 0 = any.</summary>
|
||||
Task<IReadOnlyList<Entities.Guild.Guild>> SearchAsync(
|
||||
string name, int activity, int joinCondition, int memberBucket,
|
||||
int maxMemberCap, int resultCap, CancellationToken ct = default);
|
||||
}
|
||||
71
SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs
Normal file
71
SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs
Normal file
@@ -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<SVSimDbContext>.Instance,
|
||||
new DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user