feat(guild): join / cancel_join_request / join_request_list

Implements three join-path state machine endpoints:
- /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply),
  ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels
  join requests; MaxMemberNum cap enforced.
- /guild/cancel_join_request: cancels all pending requests for caller (no
  request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept).
- /guild/join_request_list: leader/subleader sees pending applicants with
  viewer profile (name/emblem/degree/request_time unix secs).

IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId)
to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched
record added to GuildServiceTypes. Re-apply after cancel resets existing row
in-place to avoid composite-PK conflict. 11 integration tests, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 13:44:04 -04:00
parent 11a129ea90
commit d7f9ba8f2a
6 changed files with 619 additions and 14 deletions

View File

@@ -198,7 +198,8 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.HasIndex("GuildId", "InviteeViewerId")
.IsUnique();
.IsUnique()
.HasFilter("\"Status\" = 0");
b.HasIndex("InviteeViewerId", "Status");

View File

@@ -250,11 +250,82 @@ public sealed class GuildService : IGuildService
return GuildOpResult.Ok;
}
public Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
{
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
return new(GuildOpResultCode.AlreadyInGuild);
public Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
var g = await _guilds.GetActiveByIdAsync(guildId, ct);
if (g is null) return new(GuildOpResultCode.GuildNotFound);
var cfg = _config.Get<GuildConfig>();
var memberCount = await _members.CountByGuildAsync(guildId, ct);
if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached);
var now = DateTime.UtcNow;
bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct)
is { Status: GuildInviteStatus.Pending };
if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite)
return new(GuildOpResultCode.PermissionDenied);
if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite)
{
var existing = await _joinRequests.GetAsync(guildId, viewerId, ct);
if (existing is { Status: GuildJoinRequestStatus.Pending })
{
// Already pending — idempotent, no-op.
return GuildOpResult.Ok;
}
if (existing is not null)
{
// Row exists but was previously Canceled/Rejected — reset to Pending in place.
existing.Status = GuildJoinRequestStatus.Pending;
existing.CreatedAt = now;
existing.RespondedAt = null;
await _db.SaveChangesAsync(ct);
}
else
{
await _joinRequests.AddAsync(new GuildJoinRequest
{
GuildId = guildId,
ViewerId = viewerId,
Status = GuildJoinRequestStatus.Pending,
CreatedAt = now,
}, ct);
}
return GuildOpResult.Ok;
}
await CommitJoinAsync(viewerId, guildId, now, ct);
return GuildOpResult.Ok;
}
private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct)
{
await _members.AddAsync(
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
ct);
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct);
}
public async Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default)
{
// Cancel all pending requests for this viewer (client sends no guildId — see decompile).
var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct);
if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound);
var now = DateTime.UtcNow;
foreach (var req in pending)
await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct);
return GuildOpResult.Ok;
}
public Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
@@ -328,6 +399,39 @@ public sealed class GuildService : IGuildService
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
}
public Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(viewerId, ct);
if (membership is null) return Array.Empty<GuildJoinRequestEntry>();
// Only leader/subleader may view the list — return empty for regular members.
if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return Array.Empty<GuildJoinRequestEntry>();
var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct);
if (requests.Count == 0) return Array.Empty<GuildJoinRequestEntry>();
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct);
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => applicantIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
return requests.Select(r =>
{
viewerRows.TryGetValue(r.ViewerId, out var v);
return new GuildJoinRequestEntry(
ApplicantViewerId: r.ViewerId,
Name: names.GetValueOrDefault(r.ViewerId, ""),
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,
IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true,
RequestedAt: r.CreatedAt);
}).ToList();
}
}

View File

@@ -62,3 +62,17 @@ public sealed record GuildReceivedInviteEntry(
Entities.Guild.Guild Guild,
int MemberNum,
string LeaderName);
/// <summary>
/// Enriched join-request entry returned by <see cref="IGuildService.ListPendingJoinRequestsForMyGuildAsync"/>.
/// Combines applicant profile data with the request timestamp.
/// </summary>
public sealed record GuildJoinRequestEntry(
long ApplicantViewerId,
string Name,
long EmblemId,
string CountryCode,
int Rank,
int DegreeId,
bool IsOfficialMarkDisplayed,
DateTime RequestedAt);

View File

@@ -27,7 +27,7 @@ public interface IGuildService
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);
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default);
Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
@@ -38,5 +38,5 @@ public interface IGuildService
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);
Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
}