Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions JobFlow.API/Controllers/EmployeeInviteController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ public async Task<IResult> Invite([FromBody] EmployeeInviteDto invite)

[HttpPost("accept/{token}")]
[AllowAnonymous]
public async Task<IResult> AcceptInvite(Guid token)
public async Task<IResult> 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();
}

Expand Down
6 changes: 6 additions & 0 deletions JobFlow.Business/ModelErrors/EmployeeInviteErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

}
19 changes: 19 additions & 0 deletions JobFlow.Business/Models/DTOs/AcceptInviteRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace JobFlow.Business.Models.DTOs;

/// <summary>
/// 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.
/// </summary>
public class AcceptInviteRequest
{
/// <summary>UID returned by Firebase Auth after the client created the account.</summary>
public string FirebaseUid { get; set; } = string.Empty;

/// <summary>Display first name. Falls back to the value captured on the invite.</summary>
public string? FirstName { get; set; }

/// <summary>Display last name. Falls back to the value captured on the invite.</summary>
public string? LastName { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
3 changes: 2 additions & 1 deletion JobFlow.Business/Notifications/Enums/EmailTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public enum EmailTemplate
InvoiceCreated = 3,
InvoiceReminder = 6,
OnTheWayNotification = 4,
ArrivalNotification = 5
ArrivalNotification = 5,
EmployeeInvite = 7
}
75 changes: 67 additions & 8 deletions JobFlow.Business/Services/EmployeeInviteService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,31 @@ namespace JobFlow.Business.Services;
[ScopedService]
public class EmployeeInviteService : IEmployeeInviteService
{
private readonly IFirebaseUserManager _firebaseUserManager;
private readonly IFrontendSettings _frontendSettings;
private readonly IRepository<EmployeeInvite> _invites;
private readonly ILogger<EmployeeInviteService> _logger;
private readonly IMapper _mapper;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _unitOfWork;
private readonly IUserService _userService;

public EmployeeInviteService(
ILogger<EmployeeInviteService> 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<EmployeeInvite>();
}

Expand Down Expand Up @@ -124,11 +130,14 @@ public async Task<Result> RevokeAsync(Guid inviteId, Guid organizationId)
return Result.Success();
}

public async Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken)
public async Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken, AcceptInviteRequest request)
{
if (inviteToken == Guid.Empty)
return Result.Failure<EmployeeDto>(EmployeeInviteErrors.NullOrEmptyId);

if (string.IsNullOrWhiteSpace(request.FirebaseUid))
return Result.Failure<EmployeeDto>(EmployeeInviteErrors.FirebaseUidRequired);

var invite = await _invites.Query()
.Include(i => i.Organization)
.Include(i => i.Role)
Expand All @@ -148,15 +157,63 @@ public async Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken)
if (invite.ExpiresAt < DateTime.UtcNow)
return Result.Failure<EmployeeDto>(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<EmployeeDto>(userResult.Error);

var roleAssignResult = await _userService.AssignRole(userResult.Value.Id, UserRoles.OrganizationEmployee);
if (roleAssignResult.IsFailure)
return Result.Failure<EmployeeDto>(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<EmployeeDto>(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
};
Expand All @@ -181,10 +238,12 @@ public async Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken)
await _unitOfWork.RepositoryOf<Employee>().AddAsync(employee);
await _unitOfWork.SaveChangesAsync();


var employeeDto = _mapper.Map<EmployeeDto>(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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public interface IEmployeeInviteService
Task<Result<List<EmployeeInviteDto>>> GetByOrganizationAsync(Guid organizationId);
Task<Result> RevokeAsync(Guid inviteId, Guid organizationId);
Task<Result<EmployeeInviteDto>> GetInviteByCode(string code);
Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken);
Task<Result<EmployeeDto>> AcceptInviteAsync(Guid inviteToken, AcceptInviteRequest request);
Task<Result<string>> ResolveShortCodeAsync(string code, string? ipAddress = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace JobFlow.Business.Services.ServiceInterfaces;

/// <summary>
/// Abstraction over the Firebase Admin SDK so business-layer code can manage
/// user profile / custom claims without depending on the Infrastructure layer.
/// </summary>
public interface IFirebaseUserManager
{
/// <summary>Set the JobFlow role + organization claims on a Firebase account.</summary>
Task SetCustomClaimsAsync(string firebaseUid, string role, Guid organizationId);

/// <summary>Update the display name on a Firebase account. No-op when display name is blank.</summary>
Task SetDisplayNameAsync(string firebaseUid, string? displayName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using FirebaseAdmin.Auth;
using JobFlow.Business.DI;
using JobFlow.Business.Services.ServiceInterfaces;

namespace JobFlow.Infrastructure.ExternalServices.Firebase;

/// <summary>
/// Thin adapter over <see cref="FirebaseAuth.DefaultInstance"/> so callers in
/// the business layer can manage Firebase profile data without taking a hard
/// dependency on the FirebaseAdmin SDK.
/// </summary>
[ScopedService]
public class FirebaseUserManager : IFirebaseUserManager
{
public Task SetCustomClaimsAsync(string firebaseUid, string role, Guid organizationId)
{
return FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(firebaseUid,
new Dictionary<string, object>
{
{ "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
});
}
}
Loading