From fabeb23a8f776e99344b0f9a759640428a1e8984 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sun, 17 May 2026 21:46:21 -0400 Subject: [PATCH] feat(api): [AB#288] redesign employee invitation flow with Firebase account linking - Add EmployeeInvite email template (id 7) with friendlier copy including org and role - Add AcceptInviteRequest DTO carrying firebaseUid, firstName, lastName - Introduce IFirebaseUserManager abstraction in Business + FirebaseUserManager impl in Infrastructure (keeps FirebaseAdmin out of Business) - Rewrite AcceptInviteAsync to link Firebase UID to a new User + Employee, assign OrganizationEmployee role, and set custom claims - Add EmployeeInviteErrors.FirebaseUidRequired and AccountLinkFailed --- .../Controllers/EmployeeInviteController.cs | 4 +- .../ModelErrors/EmployeeInviteErrors.cs | 6 ++ .../Models/DTOs/AcceptInviteRequest.cs | 19 +++++ .../Builders/NotificationMessageBuilder.cs | 20 +++-- .../Notifications/Enums/EmailTemplate.cs | 3 +- .../Services/EmployeeInviteService.cs | 75 +++++++++++++++++-- .../IEmployeeInviteService.cs | 2 +- .../ServiceInterfaces/IFirebaseUserManager.cs | 14 ++++ .../Firebase/FirebaseUserManager.cs | 36 +++++++++ 9 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 JobFlow.Business/Models/DTOs/AcceptInviteRequest.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IFirebaseUserManager.cs create mode 100644 JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseUserManager.cs diff --git a/JobFlow.API/Controllers/EmployeeInviteController.cs b/JobFlow.API/Controllers/EmployeeInviteController.cs index 305be0e..9f1159c 100644 --- a/JobFlow.API/Controllers/EmployeeInviteController.cs +++ b/JobFlow.API/Controllers/EmployeeInviteController.cs @@ -56,9 +56,9 @@ public async Task Invite([FromBody] EmployeeInviteDto invite) [HttpPost("accept/{token}")] [AllowAnonymous] - public async Task AcceptInvite(Guid token) + public async Task AcceptInvite(Guid token, [FromBody] AcceptInviteRequest request) { - var result = await _inviteService.AcceptInviteAsync(token); + var result = await _inviteService.AcceptInviteAsync(token, request); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } diff --git a/JobFlow.Business/ModelErrors/EmployeeInviteErrors.cs b/JobFlow.Business/ModelErrors/EmployeeInviteErrors.cs index c61cc5f..97456bf 100644 --- a/JobFlow.Business/ModelErrors/EmployeeInviteErrors.cs +++ b/JobFlow.Business/ModelErrors/EmployeeInviteErrors.cs @@ -32,4 +32,10 @@ public static Error FailedToSendNotification(string recipient) "EmployeeInvites", $"Failed to send invite notification to {recipient}."); } + public static Error FirebaseUidRequired => Error.Failure( + "EmployeeInvites", "A Firebase account is required to accept this invitation."); + + public static Error AccountLinkFailed(string detail) => Error.Failure( + "EmployeeInvites", $"Could not link your account to this invitation: {detail}"); + } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/AcceptInviteRequest.cs b/JobFlow.Business/Models/DTOs/AcceptInviteRequest.cs new file mode 100644 index 0000000..dbe1604 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/AcceptInviteRequest.cs @@ -0,0 +1,19 @@ +namespace JobFlow.Business.Models.DTOs; + +/// +/// Payload sent by an invitee to finalize their invitation. The Firebase user +/// is created client-side (so passwords never cross our API), and the caller +/// passes the resulting UID + display name so the backend can link a User +/// and Employee record to the new identity. +/// +public class AcceptInviteRequest +{ + /// UID returned by Firebase Auth after the client created the account. + public string FirebaseUid { get; set; } = string.Empty; + + /// Display first name. Falls back to the value captured on the invite. + public string? FirstName { get; set; } + + /// Display last name. Falls back to the value captured on the invite. + public string? LastName { get; set; } +} diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 402e7c7..38af0a3 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -213,25 +213,29 @@ public NotificationMessage BuildClientJobTrackingArrival(OrganizationClient clie public NotificationMessage BuildEmployeeInvite(EmployeeInvite invite) { var link = $"{baseUrl}/i/{invite.ShortCode}"; + var orgName = invite.Organization?.OrganizationName ?? "JobFlow"; + var roleName = invite.Role?.Name ?? "team member"; return new NotificationMessage { Email = invite.Email, Name = invite.FirstName, Phone = invite.PhoneNumber, - Subject = $"You're invited to join {invite.Organization?.OrganizationName ?? "JobFlow"}", + Subject = $"You're invited to join {orgName} on JobFlow", Body = $""" - Hello {invite.FullName}, + Hi {invite.FirstName}, - You’ve been invited to join {invite.Organization?.OrganizationName ?? "JobFlow"}. - Click below to accept your invitation: + You've been invited to join {orgName} on JobFlow as a {roleName}. + + Click the link below to accept your invitation and set up your account: {link} - This link will expire on {invite.ExpiresAt:MMM dd, yyyy}. + This invitation expires on {invite.ExpiresAt:MMM dd, yyyy}. If you weren't expecting this email, you can safely ignore it. + + — The JobFlow Team """, - Sms = - $"You’ve been invited to join {invite.Organization?.OrganizationName ?? "JobFlow"}! Accept your invite: ", + Sms = $"You're invited to join {orgName} on JobFlow. Accept here: ", Link = $"{link}", - TemplateId = EmailTemplate.OrganizationWelcome + TemplateId = EmailTemplate.EmployeeInvite }; } diff --git a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs index fea1c6c..76db692 100644 --- a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs +++ b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs @@ -7,5 +7,6 @@ public enum EmailTemplate InvoiceCreated = 3, InvoiceReminder = 6, OnTheWayNotification = 4, - ArrivalNotification = 5 + ArrivalNotification = 5, + EmployeeInvite = 7 } \ No newline at end of file diff --git a/JobFlow.Business/Services/EmployeeInviteService.cs b/JobFlow.Business/Services/EmployeeInviteService.cs index da5e383..d66347f 100644 --- a/JobFlow.Business/Services/EmployeeInviteService.cs +++ b/JobFlow.Business/Services/EmployeeInviteService.cs @@ -16,25 +16,31 @@ namespace JobFlow.Business.Services; [ScopedService] public class EmployeeInviteService : IEmployeeInviteService { + private readonly IFirebaseUserManager _firebaseUserManager; private readonly IFrontendSettings _frontendSettings; private readonly IRepository _invites; private readonly ILogger _logger; private readonly IMapper _mapper; private readonly INotificationService _notifications; private readonly IUnitOfWork _unitOfWork; + private readonly IUserService _userService; public EmployeeInviteService( ILogger logger, IUnitOfWork unitOfWork, INotificationService notifications, IFrontendSettings frontendSettings, - IMapper mapper) + IMapper mapper, + IUserService userService, + IFirebaseUserManager firebaseUserManager) { _logger = logger; _unitOfWork = unitOfWork; _notifications = notifications; _frontendSettings = frontendSettings; _mapper = mapper; + _userService = userService; + _firebaseUserManager = firebaseUserManager; _invites = unitOfWork.RepositoryOf(); } @@ -124,11 +130,14 @@ public async Task RevokeAsync(Guid inviteId, Guid organizationId) return Result.Success(); } - public async Task> AcceptInviteAsync(Guid inviteToken) + public async Task> AcceptInviteAsync(Guid inviteToken, AcceptInviteRequest request) { if (inviteToken == Guid.Empty) return Result.Failure(EmployeeInviteErrors.NullOrEmptyId); + if (string.IsNullOrWhiteSpace(request.FirebaseUid)) + return Result.Failure(EmployeeInviteErrors.FirebaseUidRequired); + var invite = await _invites.Query() .Include(i => i.Organization) .Include(i => i.Role) @@ -148,15 +157,63 @@ public async Task> AcceptInviteAsync(Guid inviteToken) if (invite.ExpiresAt < DateTime.UtcNow) return Result.Failure(Error.Failure("EmployeeInvites", "This invitation has expired.")); - // Create new Employee from invite info + var firstName = !string.IsNullOrWhiteSpace(request.FirstName) + ? request.FirstName!.Trim() + : invite.FirstName ?? string.Empty; + var lastName = !string.IsNullOrWhiteSpace(request.LastName) + ? request.LastName!.Trim() + : invite.LastName ?? string.Empty; + + // Create the JobFlow User row linked to the Firebase account the + // invitee just signed up with on the client. + var user = new User + { + FirstName = firstName, + LastName = lastName, + Email = invite.Email, + PhoneNumber = invite.PhoneNumber, + OrganizationId = invite.OrganizationId, + FirebaseUid = request.FirebaseUid + }; + + var userResult = await _userService.UpsertUser(user); + if (userResult.IsFailure) + return Result.Failure(userResult.Error); + + var roleAssignResult = await _userService.AssignRole(userResult.Value.Id, UserRoles.OrganizationEmployee); + if (roleAssignResult.IsFailure) + return Result.Failure(roleAssignResult.Error); + + // Mirror DisplayName + custom claims onto the Firebase user so the + // existing auth pipeline (FirebaseAuthMiddleware) picks up the role. + try + { + await _firebaseUserManager.SetCustomClaimsAsync( + request.FirebaseUid, + UserRoles.OrganizationEmployee, + invite.OrganizationId); + + await _firebaseUserManager.SetDisplayNameAsync( + request.FirebaseUid, + $"{firstName} {lastName}".Trim()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Firebase claim/profile update failed for invite {InviteId}", invite.Id); + return Result.Failure(EmployeeInviteErrors.AccountLinkFailed(ex.Message)); + } + + // Create the Employee record linked to the new User. var employee = new Employee { Id = Guid.NewGuid(), - FirstName = invite.FirstName ?? string.Empty, - LastName = invite.LastName ?? string.Empty, + FirstName = firstName, + LastName = lastName, Email = invite.Email, + PhoneNumber = invite.PhoneNumber, RoleId = invite.RoleId, OrganizationId = invite.OrganizationId, + UserId = userResult.Value.Id, IsActive = true, CreatedAt = DateTime.UtcNow }; @@ -181,10 +238,12 @@ public async Task> AcceptInviteAsync(Guid inviteToken) await _unitOfWork.RepositoryOf().AddAsync(employee); await _unitOfWork.SaveChangesAsync(); - var employeeDto = _mapper.Map(employee); - _logger.LogInformation("Employee {Email} accepted invite for Org {OrgId}", employee.Email, - employee.OrganizationId); + _logger.LogInformation( + "Employee {Email} accepted invite for Org {OrgId} (UID={FirebaseUid})", + employee.Email, + employee.OrganizationId, + request.FirebaseUid); return Result.Success(employeeDto); } diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs index 64f026f..eb42a24 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs @@ -9,6 +9,6 @@ public interface IEmployeeInviteService Task>> GetByOrganizationAsync(Guid organizationId); Task RevokeAsync(Guid inviteId, Guid organizationId); Task> GetInviteByCode(string code); - Task> AcceptInviteAsync(Guid inviteToken); + Task> AcceptInviteAsync(Guid inviteToken, AcceptInviteRequest request); Task> ResolveShortCodeAsync(string code, string? ipAddress = null); } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IFirebaseUserManager.cs b/JobFlow.Business/Services/ServiceInterfaces/IFirebaseUserManager.cs new file mode 100644 index 0000000..838b3b6 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IFirebaseUserManager.cs @@ -0,0 +1,14 @@ +namespace JobFlow.Business.Services.ServiceInterfaces; + +/// +/// Abstraction over the Firebase Admin SDK so business-layer code can manage +/// user profile / custom claims without depending on the Infrastructure layer. +/// +public interface IFirebaseUserManager +{ + /// Set the JobFlow role + organization claims on a Firebase account. + Task SetCustomClaimsAsync(string firebaseUid, string role, Guid organizationId); + + /// Update the display name on a Firebase account. No-op when display name is blank. + Task SetDisplayNameAsync(string firebaseUid, string? displayName); +} diff --git a/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseUserManager.cs b/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseUserManager.cs new file mode 100644 index 0000000..1a5d126 --- /dev/null +++ b/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseUserManager.cs @@ -0,0 +1,36 @@ +using FirebaseAdmin.Auth; +using JobFlow.Business.DI; +using JobFlow.Business.Services.ServiceInterfaces; + +namespace JobFlow.Infrastructure.ExternalServices.Firebase; + +/// +/// Thin adapter over so callers in +/// the business layer can manage Firebase profile data without taking a hard +/// dependency on the FirebaseAdmin SDK. +/// +[ScopedService] +public class FirebaseUserManager : IFirebaseUserManager +{ + public Task SetCustomClaimsAsync(string firebaseUid, string role, Guid organizationId) + { + return FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(firebaseUid, + new Dictionary + { + { "role", role }, + { "organizationId", organizationId.ToString() } + }); + } + + public Task SetDisplayNameAsync(string firebaseUid, string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return Task.CompletedTask; + + return FirebaseAuth.DefaultInstance.UpdateUserAsync(new UserRecordArgs + { + Uid = firebaseUid, + DisplayName = displayName + }); + } +}