using System.Net; using System.Security.Claims; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation; public sealed class TeamMemberInviteService : ITeamMemberInviteService { /// The prototype states no lifetime; seven days is the product decision. public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7); private readonly ITeamMemberInviteDataService _inviteDataService; private readonly UserManager _userManager; private readonly IEmailSender _emailSender; private readonly FrontendOptions _frontendOptions; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public TeamMemberInviteService( ITeamMemberInviteDataService inviteDataService, UserManager userManager, IEmailSender emailSender, IOptions frontendOptions, TimeProvider timeProvider, ILogger logger) { _inviteDataService = inviteDataService; _userManager = userManager; _emailSender = emailSender; _frontendOptions = frontendOptions.Value; _timeProvider = timeProvider; _logger = logger; } public async Task CreateAsync(string userId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); var (invite, issued) = NewInvite(userId); await _inviteDataService.AddAsync(invite, cancellationToken); return issued; } public async Task SendAsync( IssuedTeamMemberInvite invite, string email, string name, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(invite); cancellationToken.ThrowIfCancellationRequested(); var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}"; var body = $"

Hi {WebUtility.HtmlEncode(name)},

" + "

You've been added to Seahaven. Set your password and confirm your email to finish creating your account.

" + $"

Finish registration

" + $"

This link expires in {InviteLifetime.Days} days and can be used once.

"; var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body); if (!sent) _logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt); return sent; } public async Task ResendAsync( string userId, ClaimsPrincipal caller, CancellationToken cancellationToken) { if (caller?.IsInRole("Admin") != true) return ResendFailure("Forbidden"); cancellationToken.ThrowIfCancellationRequested(); var user = await _userManager.FindByIdAsync(userId); if (user is null) return ResendFailure("Team member not found."); if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email)) return ResendFailure("Only pending team members can be re-invited."); var (invite, issued) = NewInvite(user.Id); await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken); var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); if (!await SendAsync(issued, user.Email, name, cancellationToken)) return ResendFailure("The invite could not be emailed. Try again."); return new TeamMemberInviteResendOutcomeDTO { Success = true }; } private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId) { var now = NowUtc(); var token = TeamMemberInviteSecrets.NewToken(); var invite = new TeamMemberInvite { UserId = userId, TokenHash = TeamMemberInviteSecrets.HashToken(token), CreatedAt = now, ExpiresAt = now.Add(InviteLifetime) }; return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt)); } private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) => new() { Success = false, Error = error }; }