feat(guild): invite state machine

Add surrogate PK (IDENTITY bigint) to GuildInvite for wire invite_id.
Implement InviteAsync, CancelInviteAsync, RejectInviteAsync,
ListOutgoingInvitesAsync, ListInvitedGuildsAsync in GuildService.
Wire 5 endpoints: invite_user_list, invited_guild_list, invite,
cancel_invite, reject_invite in GuildController.
Migration: AddGuildInviteSurrogatePk — drops composite PK, adds Id
IDENTITY column, adds unique index on (GuildId, InviteeViewerId).
6 integration tests in GuildInviteFlowTests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 13:21:57 -04:00
parent c103ab6a87
commit 1b7fa2d525
12 changed files with 5574 additions and 22 deletions

View File

@@ -2,6 +2,12 @@ namespace SVSim.Database.Entities.Guild;
public class GuildInvite
{
/// <summary>
/// Auto-increment surrogate PK. Used as the wire <c>invite_id</c> returned to the client
/// and echoed back in cancel_invite / reject_invite requests.
/// </summary>
public long Id { get; set; }
public int GuildId { get; set; }
public long InviteeViewerId { get; set; }
public long InviterViewerId { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddGuildInviteSurrogatePk : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites");
migrationBuilder.AddColumn<long>(
name: "Id",
table: "GuildInvites",
type: "bigint",
nullable: false,
defaultValue: 0L)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.AddPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_GuildInvites_GuildId_InviteeViewerId",
table: "GuildInvites",
columns: new[] { "GuildId", "InviteeViewerId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites");
migrationBuilder.DropIndex(
name: "IX_GuildInvites_GuildId_InviteeViewerId",
table: "GuildInvites");
migrationBuilder.DropColumn(
name: "Id",
table: "GuildInvites");
migrationBuilder.AddPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites",
columns: new[] { "GuildId", "InviteeViewerId" });
}
}
}

View File

@@ -171,15 +171,21 @@ namespace SVSim.Database.Migrations
modelBuilder.Entity("SVSim.Database.Entities.Guild.GuildInvite", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GuildId")
.HasColumnType("integer");
b.Property<long>("InviteeViewerId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("InviterViewerId")
.HasColumnType("bigint");
@@ -189,7 +195,10 @@ namespace SVSim.Database.Migrations
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("GuildId", "InviteeViewerId");
b.HasKey("Id");
b.HasIndex("GuildId", "InviteeViewerId")
.IsUnique();
b.HasIndex("InviteeViewerId", "Status");

View File

@@ -13,8 +13,12 @@ public sealed class GuildInviteRepository : IGuildInviteRepository
=> _db.GuildInvites.FirstOrDefaultAsync(
i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
public Task<GuildInvite?> GetByIdAsync(long inviteId, CancellationToken ct = default)
=> _db.GuildInvites.FirstOrDefaultAsync(i => i.Id == inviteId, ct);
public async Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
=> await _db.GuildInvites
.Include(i => i.Guild)
.Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
.ToListAsync(ct);

View File

@@ -5,6 +5,7 @@ namespace SVSim.Database.Repositories.Guild;
public interface IGuildInviteRepository
{
Task<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default);
Task<GuildInvite?> GetByIdAsync(long inviteId, 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);

View File

@@ -532,10 +532,12 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<GuildInvite>(e =>
{
e.HasKey(i => new { i.GuildId, i.InviteeViewerId });
e.HasKey(i => i.Id);
e.Property(i => i.Id).ValueGeneratedOnAdd();
e.HasOne(i => i.Guild).WithMany().HasForeignKey(i => i.GuildId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(i => new { i.InviteeViewerId, i.Status }); // "my pending invites"
e.HasIndex(i => new { i.GuildId, i.InviteeViewerId }).IsUnique(); // one pending invite per pair
e.HasIndex(i => new { i.InviteeViewerId, i.Status }); // "my pending invites"
});
modelBuilder.Entity<GuildJoinRequest>(e =>

View File

@@ -190,14 +190,65 @@ public sealed class GuildService : IGuildService
return GuildOpResult.Ok;
}
public Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
{
// Caller must be a guild member with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
public Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
// Target must not already be in any guild.
var targetMembership = await _members.GetMembershipAsync(targetViewerId, ct);
if (targetMembership is not null) return new(GuildOpResultCode.AlreadyInGuild);
public Task<GuildOpResult> RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
// No duplicate pending invite to the same target from the same guild.
var existing = await _invites.GetAsync(callerMembership.GuildId, targetViewerId, ct);
if (existing is { Status: GuildInviteStatus.Pending }) return new(GuildOpResultCode.InviteAlreadyResolved);
var now = DateTime.UtcNow;
await _invites.AddAsync(new GuildInvite
{
GuildId = callerMembership.GuildId,
InviteeViewerId = targetViewerId,
InviterViewerId = callerViewerId,
Status = GuildInviteStatus.Pending,
CreatedAt = now,
}, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Caller must be in their guild with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must belong to the same guild as the invite.
if (invite.GuildId != callerMembership.GuildId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, invite.InviteeViewerId, GuildInviteStatus.Canceled, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Invitee rejects — no guild membership check needed.
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must be the invitee.
if (invite.InviteeViewerId != callerViewerId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, callerViewerId, GuildInviteStatus.Rejected, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
@@ -221,7 +272,61 @@ public sealed class GuildService : IGuildService
=> throw new NotImplementedException();
public Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
=> _invites.ListPendingForInviteeAsync(viewerId, ct);
public async Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(callerViewerId, ct);
if (membership is null) return Array.Empty<GuildOutgoingInviteEntry>();
var pendingInvites = await _invites.ListPendingForGuildAsync(membership.GuildId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildOutgoingInviteEntry>();
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct);
// Load emblem + degree data for invitees.
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => inviteeIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
foreach (var invite in pendingInvites)
{
viewerRows.TryGetValue(invite.InviteeViewerId, out var v);
result.Add(new GuildOutgoingInviteEntry(
InviteId: invite.Id,
InviteeViewerId: invite.InviteeViewerId,
Name: names.GetValueOrDefault(invite.InviteeViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
CreatedAt: invite.CreatedAt));
}
return result;
}
public async Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default)
{
// ListPendingForInviteeAsync already includes the Guild nav prop.
var pendingInvites = await _invites.ListPendingForInviteeAsync(viewerId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildReceivedInviteEntry>();
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
InviteId: invite.Id,
Guild: invite.Guild,
MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0),
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
}
public Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();

View File

@@ -38,3 +38,27 @@ public sealed record GuildSearchEntry(Entities.Guild.Guild Guild, int MemberNum,
public sealed record CreateGuildRequest(string Name, int Activity, int JoinCondition);
public sealed record UpdateGuildRequest(int? Activity, int? JoinCondition, string? Name = null);
/// <summary>
/// Enriched outgoing invite entry returned by <see cref="IGuildService.ListOutgoingInvitesAsync"/>.
/// Combines invite metadata with invitee viewer profile for the <c>/guild/invite_user_list</c> response.
/// </summary>
public sealed record GuildOutgoingInviteEntry(
long InviteId,
long InviteeViewerId,
string Name,
long EmblemId,
string CountryCode,
int Rank,
int DegreeId,
DateTime CreatedAt);
/// <summary>
/// Enriched received invite entry returned by <see cref="IGuildService.ListInvitedGuildsAsync"/>.
/// Combines invite id with guild detail fields for the <c>/guild/invited_guild_list</c> response.
/// </summary>
public sealed record GuildReceivedInviteEntry(
long InviteId,
Entities.Guild.Guild Guild,
int MemberNum,
string LeaderName);

View File

@@ -23,8 +23,8 @@ public interface IGuildService
Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default);
Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default);
Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default);
Task<GuildOpResult> RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default);
Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default);
Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default);
Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default);
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default);
@@ -36,5 +36,7 @@ public interface IGuildService
Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default);
Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
}