using Application.Abstractions.Data; using Application.Abstractions.Hub; using Application.Abstractions.Messaging; using Application.Abstractions.Notification; using SharedKernel; using SharedKernel.Results; using Domain.Entities.Donations; using Domain.Entities.Donations.ValueObject; using Domain.Entities.Notifications.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Crew.AddMember; internal sealed class Handler( IAppDbContext db, INotificationService notificationService, ICrewHubBroadcaster crewBroadcaster ) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { var crew = await db.Crew.FirstOrDefaultAsync(c => c.ID == request.CrewID, ct); if (crew is null) { return Result.Failure(Error.NotFound("Crew.NotFound", "크루를 찾을 수 없습니다.")); } // 자기 자신 초대 방지 if (request.TargetMemberID == request.InvitedByMemberID) { return Result.Failure(Error.Problem("Crew.InviteSelfNotAllowed", "본인을 초대할 수 없습니다.")); } // 이미 등록된 크루원 확인 var alreadyMember = await db.CrewMember.AnyAsync(m => m.CrewID == request.CrewID && m.MemberID == request.TargetMemberID, ct); if (alreadyMember) { return Result.Failure(Error.Conflict("Crew.MemberAlreadyExists", "이미 등록된 크루원입니다.")); } // 진행 중인 초대 확인 (만료된 건 새로 보내도 됨) var pendingInvite = await db.CrewInvitation .Where(i => i.CrewID == request.CrewID && i.TargetMemberID == request.TargetMemberID && i.Status == CrewInviteStatus.Pending) .FirstOrDefaultAsync(ct); if (pendingInvite is not null) { // 만료된 Pending 은 자동으로 Expired 처리하고 새 초대 발송 if (pendingInvite.IsExpired()) { pendingInvite.MarkExpired(); } else { return Result.Failure(Error.Conflict("Crew.InviteAlreadyPending", "이미 진행 중인 초대가 있습니다.")); } } // 초대 대상자 존재 확인 var targetExists = await db.Member.AnyAsync(m => m.ID == request.TargetMemberID && !m.IsWithdraw && !m.IsDenied, ct); if (!targetExists) { return Result.Failure(Error.NotFound("Member.NotFound", "초대 대상 회원을 찾을 수 없습니다.")); } // 초대 생성 var invitation = CrewInvitation.Create( request.CrewID, request.TargetMemberID, request.InvitedByMemberID, request.Nickname, request.Role, request.SortOrder ); db.CrewInvitation.Add(invitation); await db.SaveChangesAsync(ct); // 대상자에게 알림 발송 (SignalR push 포함) — invitation.ID 확정 후 await notificationService.SendAsync( memberID: request.TargetMemberID, type: NotificationType.CrewInvitation, title: $"크루 초대: {crew.Name}", message: $"'{crew.Name}' 크루의 초대가 도착했습니다. 24시간 내 응답해 주세요.", actionUrl: $"/crew/invite/{invitation.ID}", relatedType: "CrewInvitation", relatedID: invitation.ID, imageUrl: null, ct: ct ); // 운영자 그룹(crew:{crewID})에 멤버 리스트 변경 broadcast → 운영자 화면 자동 새로고침 await crewBroadcaster.BroadcastMemberChangeAsync( crewID: request.CrewID, kind: "added", data: new { invitationID = invitation.ID, crewID = request.CrewID, targetMemberID = request.TargetMemberID, nickname = request.Nickname }, ct: ct ); return Result.Success(new Response(invitation.ID)); } }