using System.Net; using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Invitations; using ROLAC.API.Entities; using ROLAC.API.Entities.Logging; using ROLAC.API.Services.Logging; using ROLAC.API.Services.Notifications; namespace ROLAC.API.Services; public class InvitationService : IInvitationService { /// Lifetime of a freshly issued invitation link. private const int InvitationLifetimeDays = 7; private readonly UserManager _userManager; private readonly AppDbContext _db; private readonly ITokenService _tokenService; private readonly IEmailService _emailService; private readonly IAuditLogger _audit; private readonly CurrentUserAccessor _currentUser; public InvitationService( UserManager userManager, AppDbContext db, ITokenService tokenService, IEmailService emailService, IAuditLogger audit, CurrentUserAccessor currentUser) { _userManager = userManager; _db = db; _tokenService = tokenService; _emailService = emailService; _audit = audit; _currentUser = currentUser; } // ── Create ─────────────────────────────────────────────────────────────── public async Task CreateAsync(CreateInvitationRequest request) { var member = await _db.Members.FindAsync(request.MemberId) ?? throw new InvalidOperationException($"Member {request.MemberId} does not exist."); var email = (request.Email ?? member.Email)?.Trim(); if (string.IsNullOrWhiteSpace(email)) throw new InvalidOperationException( "This member has no email address. Add an email before creating an invitation."); var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId); if (user is null) user = await CreateAccountAsync(member, email, request.Roles); var now = DateTime.UtcNow; // Re-issue: revoke any prior unused invitation so only one link is ever live. var existing = await _db.UserInvitations .Where(invitation => invitation.UserId == user.Id && invitation.UsedAt == null && invitation.RevokedAt == null) .ToListAsync(); foreach (var invitation in existing) invitation.RevokedAt = now; var rawToken = GenerateRawToken(); var expiresAt = now.AddDays(InvitationLifetimeDays); _db.UserInvitations.Add(new UserInvitation { UserId = user.Id, TokenHash = _tokenService.HashToken(rawToken), ExpiresAt = expiresAt, CreatedAt = now, CreatedBy = _currentUser.UserIdOrSystem, }); await _db.SaveChangesAsync(); _audit.Write( AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information, entityName: nameof(AppUser), entityId: user.Id, summary: $"Invitation link created for {user.Email}"); return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt }; } /// Creates a passwordless login account linked to the member; mirrors UserManagementService. private async Task CreateAccountAsync(Member member, string email, List? roles) { if (await _userManager.FindByEmailAsync(email) is not null) throw new InvalidOperationException($"Email '{email}' is already in use by another account."); var user = new AppUser { UserName = email, Email = email, EmailConfirmed = true, MemberId = member.Id, LanguagePreference = member.LanguagePreference, IsActive = true, CreatedAt = DateTime.UtcNow, }; // No-password overload: the member sets their own password via the invitation link. var result = await _userManager.CreateAsync(user); if (!result.Succeeded) throw new InvalidOperationException( string.Join("; ", result.Errors.Select(error => error.Description))); var rolesToAssign = roles is { Count: > 0 } ? roles : new List { "member" }; await _userManager.AddToRolesAsync(user, rolesToAssign); return user; } // ── Validate ─────────────────────────────────────────────────────────────── public async Task ValidateAsync(string rawToken) { var invitation = await FindByRawTokenAsync(rawToken); if (invitation is null || invitation.IsUsed || invitation.IsRevoked) return new ValidateInvitationResult { Valid = false, Expired = false }; if (invitation.IsExpired) return new ValidateInvitationResult { Valid = false, Expired = true }; var user = await _userManager.FindByIdAsync(invitation.UserId); return new ValidateInvitationResult { Valid = true, Expired = false, Email = user?.Email, MemberName = await ResolveMemberNameAsync(user), }; } // ── Accept ─────────────────────────────────────────────────────────────── public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword) { var invitation = await FindByRawTokenAsync(rawToken); if (invitation is null || invitation.IsUsed || invitation.IsRevoked) return (null, "This invitation link is invalid or has already been used."); if (invitation.IsExpired) return (null, "This invitation link has expired. Please ask for a new one."); var user = await _userManager.FindByIdAsync(invitation.UserId); if (user is null) return (null, "The account for this invitation no longer exists."); // Set the password — works whether or not one already exists, and enforces the policy. var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user); var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword); if (!result.Succeeded) return (null, string.Join(" ", result.Errors.Select(error => error.Description))); invitation.UsedAt = DateTime.UtcNow; user.EmailConfirmed = true; user.IsActive = true; await _userManager.UpdateAsync(user); await _db.SaveChangesAsync(); _audit.Write( AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information, entityName: nameof(AppUser), entityId: user.Id, summary: $"Invitation accepted — password set for {user.Email}", userId: user.Id, userEmail: user.Email); return (user, null); } // ── Send email ─────────────────────────────────────────────────────────── public async Task SendEmailAsync(int memberId, string link) { var member = await _db.Members.FindAsync(memberId) ?? throw new InvalidOperationException($"Member {memberId} does not exist."); var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en); var safeLink = WebUtility.HtmlEncode(link); var subject = "Your River Of Life Christian Church account invitation"; var htmlBody = $"

Hi {name},

" + "

You've been invited to set up your account for the River Of Life Christian Church portal.

" + $"

Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.

" + $"

Set your password and sign in

" + "

If the button doesn't work, copy and paste this address into your browser:

" + $"

{safeLink}

"; var result = await _emailService.SendAsync(new EmailMessage( MemberIds: new[] { memberId }, Addresses: Array.Empty(), Subject: subject, HtmlBody: htmlBody)); if (result.SentCount == 0) throw new InvalidOperationException( result.Failures.Count > 0 ? $"Failed to send email: {result.Failures[0].Error}" : "No email address on file for this member."); } // ── Helpers ─────────────────────────────────────────────────────────────── private Task FindByRawTokenAsync(string rawToken) { if (string.IsNullOrWhiteSpace(rawToken)) return Task.FromResult(null); var hash = _tokenService.HashToken(rawToken); return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash); } private async Task ResolveMemberNameAsync(AppUser? user) { if (user?.MemberId is not int memberId) return null; return await _db.Members .Where(member => member.Id == memberId) .Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en) .FirstOrDefaultAsync(); } /// 32 cryptographically-random bytes as a URL-safe base64 string. private static string GenerateRawToken() { var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); } }