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>
438 lines
20 KiB
C#
438 lines
20 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using SVSim.Database.Entities.Guild;
|
|
using SVSim.Database.Models.Config;
|
|
using SVSim.Database.Repositories.Guild;
|
|
using SVSim.Database.Repositories.Viewer;
|
|
using SVSim.Database.Services;
|
|
|
|
namespace SVSim.Database.Services.Guild;
|
|
|
|
public sealed class GuildService : IGuildService
|
|
{
|
|
private readonly IGuildRepository _guilds;
|
|
private readonly IGuildMemberRepository _members;
|
|
private readonly IGuildInviteRepository _invites;
|
|
private readonly IGuildJoinRequestRepository _joinRequests;
|
|
private readonly IGuildChatMessageRepository _chatMessages;
|
|
private readonly IGameConfigService _config;
|
|
private readonly IGuildIdGenerator _idGen;
|
|
private readonly IGuildChatService _chat;
|
|
private readonly IViewerRepository _viewers;
|
|
private readonly SVSimDbContext _db;
|
|
|
|
public GuildService(
|
|
IGuildRepository guilds,
|
|
IGuildMemberRepository members,
|
|
IGuildInviteRepository invites,
|
|
IGuildJoinRequestRepository joinRequests,
|
|
IGuildChatMessageRepository chatMessages,
|
|
IGameConfigService config,
|
|
IGuildIdGenerator idGen,
|
|
IGuildChatService chat,
|
|
IViewerRepository viewers,
|
|
SVSimDbContext db)
|
|
{
|
|
_guilds = guilds;
|
|
_members = members;
|
|
_invites = invites;
|
|
_joinRequests = joinRequests;
|
|
_chatMessages = chatMessages;
|
|
_config = config;
|
|
_idGen = idGen;
|
|
_chat = chat;
|
|
_viewers = viewers;
|
|
_db = db;
|
|
}
|
|
|
|
public async Task<GuildFullView?> GetMyGuildAsync(long viewerId, CancellationToken ct = default)
|
|
{
|
|
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
|
if (membership is null) return null;
|
|
|
|
var guild = await _guilds.GetWithMembersAsync(membership.GuildId, ct);
|
|
if (guild is null || guild.BreakupAt is not null) return null;
|
|
|
|
int joinReqCount = await _joinRequests.CountPendingForGuildAsync(guild.GuildId, ct);
|
|
int inviteCount = await _invites.CountPendingForInviteeAsync(viewerId, ct);
|
|
|
|
return new GuildFullView(guild, guild.Members, joinReqCount, inviteCount);
|
|
}
|
|
|
|
public Task<Entities.Guild.Guild?> GetActiveAsync(int guildId, CancellationToken ct = default)
|
|
=> _guilds.GetActiveByIdAsync(guildId, ct);
|
|
|
|
public async Task<IReadOnlyList<GuildSearchEntry>> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default)
|
|
{
|
|
var cfg = _config.Get<GuildConfig>();
|
|
var rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct);
|
|
var guildIds = rows.Select(r => r.GuildId).ToList();
|
|
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
|
|
var leaderIds = rows.Select(r => r.LeaderViewerId).Distinct().ToList();
|
|
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
|
|
return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0), leaderNames.GetValueOrDefault(r.LeaderViewerId, ""))).ToList();
|
|
}
|
|
|
|
public async Task<GuildOpResult> CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64)
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
if (req.Activity is < 1 or > 16)
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
if (req.JoinCondition is < 1 or > 3)
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
|
|
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
|
|
return new(GuildOpResultCode.AlreadyInGuild);
|
|
if (await _guilds.NameExistsAsync(req.Name, ct))
|
|
return new(GuildOpResultCode.NameTaken);
|
|
|
|
var viewer = await _db.Viewers
|
|
.Include(v => v.Info.SelectedEmblem)
|
|
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
|
if (viewer is null) return new(GuildOpResultCode.PermissionDenied);
|
|
|
|
var id = await _idGen.NextAsync(
|
|
(cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c),
|
|
ct);
|
|
var now = DateTime.UtcNow;
|
|
var g = new Entities.Guild.Guild
|
|
{
|
|
GuildId = id,
|
|
Name = req.Name,
|
|
Description = "",
|
|
LeaderViewerId = viewerId,
|
|
EmblemId = DefaultEmblemId(viewer),
|
|
Activity = (GuildActivity)req.Activity,
|
|
JoinCondition = (GuildJoinCondition)req.JoinCondition,
|
|
CreatedAt = now,
|
|
};
|
|
await _guilds.AddAsync(g, ct);
|
|
await _members.AddAsync(
|
|
new GuildMember { GuildId = id, ViewerId = viewerId, Role = GuildRole.Leader, JoinedAt = now },
|
|
ct);
|
|
|
|
// Side-effects: clear any pending invites + cancel pending join requests for this viewer.
|
|
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
|
|
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
|
|
|
|
// Update Viewer.GuildId pointer for quick lookups.
|
|
viewer.GuildId = id;
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct);
|
|
return new(GuildOpResultCode.Ok, GuildId: id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the viewer's currently-equipped emblem id, or 100000000 (default) if none.
|
|
/// </summary>
|
|
private static long DefaultEmblemId(Models.Viewer viewer)
|
|
{
|
|
var emblemId = viewer.Info?.SelectedEmblem?.Id;
|
|
return emblemId is > 0 ? emblemId.Value : 100_000_000L;
|
|
}
|
|
|
|
public async Task<GuildOpResult> UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default)
|
|
{
|
|
var m = await _members.GetMembershipAsync(viewerId, ct);
|
|
if (m is null) return new(GuildOpResultCode.NotInGuild);
|
|
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
|
|
|
if (req.Name is not null && (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64))
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
if (req.Activity.HasValue && req.Activity.Value is < 1 or > 16)
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
if (req.JoinCondition.HasValue && req.JoinCondition.Value is < 1 or > 3)
|
|
return new(GuildOpResultCode.NameInvalid);
|
|
|
|
await _guilds.UpdateActivityAndJoinConditionAsync(m.GuildId, req.Activity, req.JoinCondition, req.Name, ct);
|
|
return GuildOpResult.Ok;
|
|
}
|
|
|
|
public async Task<GuildOpResult> UpdateDescriptionAsync(long viewerId, string description, CancellationToken ct = default)
|
|
{
|
|
var m = await _members.GetMembershipAsync(viewerId, ct);
|
|
if (m is null) return new(GuildOpResultCode.NotInGuild);
|
|
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
|
if (description.Length > 512) return new(GuildOpResultCode.NameInvalid);
|
|
|
|
await _guilds.UpdateDescriptionAsync(m.GuildId, description, ct);
|
|
await _chat.EmitSystemEventAsync(m.GuildId, viewerId, GuildChatMessageType.Description, body: null, ct);
|
|
return GuildOpResult.Ok;
|
|
}
|
|
|
|
public async Task<GuildOpResult> UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default)
|
|
{
|
|
var m = await _members.GetMembershipAsync(viewerId, ct);
|
|
if (m is null) return new(GuildOpResultCode.NotInGuild);
|
|
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
|
|
|
await _guilds.UpdateEmblemAsync(m.GuildId, emblemId, ct);
|
|
return GuildOpResult.Ok;
|
|
}
|
|
|
|
public async Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default)
|
|
{
|
|
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
|
if (membership is null) return new(GuildOpResultCode.NotInGuild);
|
|
if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
|
|
|
|
var now = DateTime.UtcNow;
|
|
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
|
|
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
|
|
|
|
await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct);
|
|
await _invites.DeleteAllForGuildAsync(membership.GuildId, ct);
|
|
await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct);
|
|
await _members.DeleteAllForGuildAsync(membership.GuildId, ct);
|
|
await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct);
|
|
|
|
return GuildOpResult.Ok;
|
|
}
|
|
|
|
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);
|
|
|
|
// Target must not already be in any guild.
|
|
var targetMembership = await _members.GetMembershipAsync(targetViewerId, ct);
|
|
if (targetMembership is not null) return new(GuildOpResultCode.AlreadyInGuild);
|
|
|
|
// 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 async Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
|
|
{
|
|
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
|
|
return new(GuildOpResultCode.AlreadyInGuild);
|
|
|
|
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();
|
|
|
|
public Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<GuildOpResult> RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
|
|
=> _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 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();
|
|
}
|
|
}
|