using Application.Abstractions.Data; using Application.Abstractions.Hub; using Application.Abstractions.Messaging; using Application.Abstractions.YouTube; using SharedKernel.Results; using Domain.Entities.Donations.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Crew.GetCrewMembers; internal sealed class Handler( IAppDbContext db, IPresenceTracker presence, IYouTubeChannelCache channelCache ) : IQueryHandler> { public async Task> Handle(Query request, CancellationToken ct) { var crew = await db.Crew.AsNoTracking() .FirstOrDefaultAsync(c => c.ID == request.CrewID, ct); if (crew is null) { return Result.Failure(Error.NotFound("Crew.NotFound", "크루를 찾을 수 없습니다.")); } // 1) 확정 멤버 (Accepted) var members = await db.CrewMember.AsNoTracking() .Where(m => m.CrewID == request.CrewID) .OrderBy(m => m.SortOrder) .ThenBy(m => m.JoinedAt) .Select(m => new { m.ID, m.MemberID, m.ChannelID, m.Nickname, m.Role, m.SortOrder, m.IsActive, m.JoinedAt, Thumb = m.Member != null ? m.Member.Thumb : null, ChannelName = m.Channel != null ? m.Channel.Name : null, ChannelSID = m.Channel != null ? m.Channel.SID : null, ChannelDbThumb = m.Channel != null ? m.Channel.ThumbnailUrl : null }) .ToListAsync(ct); // 2) 미응답/거절/만료 초대 var invitations = await db.CrewInvitation.AsNoTracking() .Where(i => i.CrewID == request.CrewID && i.Status != CrewInviteStatus.Accepted) .OrderByDescending(i => i.InvitedAt) .Select(i => new { ID = -i.ID, // 음수: CrewMember.ID와 충돌 방지 MemberID = i.TargetMemberID, ChannelID = (int?)null, i.Nickname, i.Role, i.SortOrder, IsActive = false, JoinedAt = i.InvitedAt, Thumb = i.TargetMember != null ? i.TargetMember.Thumb : null, ChannelInfo = db.Channel.AsNoTracking() .Where(c => c.MemberID == i.TargetMemberID) .Select(c => new { c.Name, c.SID, c.ThumbnailUrl }) .FirstOrDefault(), i.Status, i.ExpiresAt }) .ToListAsync(ct); // 3) 모든 MemberID 일괄 online 체크 (Redis 1회) var allMemberIDs = members.Select(m => m.MemberID) .Concat(invitations.Select(i => i.MemberID)) .Distinct() .ToList(); var onlineSet = await presence.GetOnlineMembersAsync(allMemberIDs); // 4) 채널 SID 일괄 수집 → YouTube 캐시 조회 (Redis MGET 1회, 미스 시 DB ThumbnailUrl fallback) var channelSIDs = members.Where(m => m.ChannelSID != null).Select(m => m.ChannelSID!) .Concat(invitations.Where(i => i.ChannelInfo != null).Select(i => i.ChannelInfo!.SID)) .Distinct() .ToList(); var ytCache = channelSIDs.Count > 0 ? await channelCache.GetManyAsync(channelSIDs) : []; string? ResolveChannelThumb(string? sid, string? dbThumb) { if (sid == null) { return null; } return ytCache.TryGetValue(sid, out var info) ? info.ThumbnailUrl : dbThumb; } // 5) 통합 응답 var combined = new List(members.Count + invitations.Count); foreach (var m in members) { combined.Add(new CrewMemberItem( m.ID, m.MemberID, m.ChannelID, m.Nickname, m.Role, m.SortOrder, m.IsActive, m.JoinedAt, m.Thumb, m.ChannelName, ResolveChannelThumb(m.ChannelSID, m.ChannelDbThumb), "Accepted", null, onlineSet.Contains(m.MemberID) )); } var now = DateTime.UtcNow; foreach (var i in invitations) { // lazy expiration: Pending인데 ExpiresAt 지난 것은 Expired 처리 var isExpired = i.Status == CrewInviteStatus.Pending && i.ExpiresAt < now; var statusLabel = i.Status == CrewInviteStatus.Declined ? "Declined" : isExpired || i.Status == CrewInviteStatus.Expired ? "Expired" : "Pending"; combined.Add(new CrewMemberItem( i.ID, i.MemberID, i.ChannelID, i.Nickname, i.Role, i.SortOrder, i.IsActive, i.JoinedAt, i.Thumb, i.ChannelInfo?.Name, ResolveChannelThumb(i.ChannelInfo?.SID, i.ChannelInfo?.ThumbnailUrl), statusLabel, statusLabel == "Pending" ? i.ExpiresAt : null, onlineSet.Contains(i.MemberID) )); } return Result.Success(new Response(combined)); } }