From 0d7df3423ee31ed003550aebc07c0fa659546a2d Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Mon, 23 Mar 2026 21:26:32 -0400 Subject: [PATCH] feat(support): Initial Implementation for the Support Hub --- .../Controllers/SupportHubController.cs | 201 +++++++++++++ .../Extensions/HttpContextExtensions.cs | 17 ++ .../Models/DTOs/SupportHubDtos.cs | 56 ++++ .../ISupportHubInviteService.cs | 11 + .../ServiceInterfaces/ISupportHubService.cs | 13 + .../Services/SupportHubInviteService.cs | 169 +++++++++++ .../Services/SupportHubService.cs | 284 ++++++++++++++++++ JobFlow.Domain/Enums/SupportHubInviteRole.cs | 7 + .../Enums/SupportHubSessionStatus.cs | 9 + .../Enums/SupportHubTicketStatus.cs | 10 + JobFlow.Domain/Models/SupportHubInvite.cs | 12 + JobFlow.Domain/Models/SupportHubSession.cs | 14 + JobFlow.Domain/Models/SupportHubTicket.cs | 14 + .../SupportHubInviteConfiguration.cs | 21 ++ .../SupportHubSessionConfiguration.cs | 25 ++ .../SupportHubTicketConfiguration.cs | 26 ++ .../JobFlowDbContext.cs | 3 + .../20260323235900_AddSupportHubTables.cs | 126 ++++++++ .../JobFlowDbContextModelSnapshot.cs | 169 +++++++++++ .../Middleware/FirebaseAuthMiddleware.cs | 23 +- 20 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 JobFlow.API/Controllers/SupportHubController.cs create mode 100644 JobFlow.Business/Models/DTOs/SupportHubDtos.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs create mode 100644 JobFlow.Business/Services/SupportHubInviteService.cs create mode 100644 JobFlow.Business/Services/SupportHubService.cs create mode 100644 JobFlow.Domain/Enums/SupportHubInviteRole.cs create mode 100644 JobFlow.Domain/Enums/SupportHubSessionStatus.cs create mode 100644 JobFlow.Domain/Enums/SupportHubTicketStatus.cs create mode 100644 JobFlow.Domain/Models/SupportHubInvite.cs create mode 100644 JobFlow.Domain/Models/SupportHubSession.cs create mode 100644 JobFlow.Domain/Models/SupportHubTicket.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs diff --git a/JobFlow.API/Controllers/SupportHubController.cs b/JobFlow.API/Controllers/SupportHubController.cs new file mode 100644 index 0000000..1989026 --- /dev/null +++ b/JobFlow.API/Controllers/SupportHubController.cs @@ -0,0 +1,201 @@ +using FirebaseAdmin.Auth; +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/supporthub")] +public class SupportHubController : ControllerBase +{ + private readonly ISupportHubInviteService _inviteService; + private readonly ISupportHubService _supportHubService; + private readonly IUserService _userService; + private readonly IOrganizationService _organizationService; + + public SupportHubController( + ISupportHubService supportHubService, + ISupportHubInviteService inviteService, + IUserService userService, + IOrganizationService organizationService) + { + _supportHubService = supportHubService; + _inviteService = inviteService; + _userService = userService; + _organizationService = organizationService; + } + + [HttpPost("register")] + [Authorize] + public async Task RegisterSupportHubUser() + { + var firebaseUid = HttpContext.GetFirebaseUid(); + if (string.IsNullOrWhiteSpace(firebaseUid)) + { + return Results.Unauthorized(); + } + + var orgResult = await _organizationService.GetAllOrganizations(); + if (orgResult.IsFailure) + { + return orgResult.ToProblemDetails(); + } + + var masterOrg = orgResult.Value + .FirstOrDefault(o => o.OrganizationType?.TypeName == "Master Account"); + if (masterOrg == null) + { + return Results.Problem("Master account organization not found."); + } + + var userResult = await _userService.GetUserByFirebaseUid(firebaseUid); + if (userResult.IsFailure) + { + var email = HttpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; + var newUser = new User + { + Email = email, + FirebaseUid = firebaseUid, + OrganizationId = masterOrg.Id + }; + + var createResult = await _userService.UpsertUser(newUser); + if (createResult.IsFailure) + { + return createResult.ToProblemDetails(); + } + + await _userService.AssignRole(createResult.Value.Id, UserRoles.KatharixEmployee); + } + else + { + var existingUser = userResult.Value; + if (existingUser.OrganizationId == Guid.Empty) + { + existingUser.OrganizationId = masterOrg.Id; + var updateResult = await _userService.UpsertUser(existingUser); + if (updateResult.IsFailure) + { + return updateResult.ToProblemDetails(); + } + } + + await _userService.AssignRole(existingUser.Id, UserRoles.KatharixEmployee); + } + + await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync( + firebaseUid, + new Dictionary + { + { "role", UserRoles.KatharixEmployee } + }); + + return Results.Ok(); + } + + [HttpGet("tickets")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task GetTickets() + { + var result = await _supportHubService.GetTicketsAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("sessions")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task GetSessions() + { + var result = await _supportHubService.GetSessionsAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("sessions/{sessionId:guid}/screen")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task StartScreenView([FromRoute] Guid sessionId) + { + var result = await _supportHubService.CreateScreenViewAsync(sessionId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("tickets")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task CreateTicket([FromBody] SupportHubTicketCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.CreateTicketAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("sessions")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task CreateSession([FromBody] SupportHubSessionCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.CreateSessionAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("seed")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task SeedDemo([FromBody] SupportHubSeedRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.SeedDemoAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("invites")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task GetInvites() + { + var result = await _inviteService.GetActiveInvitesAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("invites")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task CreateInvite([FromBody] SupportHubInviteCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _inviteService.CreateInviteAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("invites/validate/{code}")] + [AllowAnonymous] + public async Task ValidateInvite([FromRoute] string code) + { + var result = await _inviteService.ValidateInviteAsync(code); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("invites/redeem")] + [Authorize] + public async Task RedeemInvite([FromBody] SupportHubInviteRedeemRequest request) + { + var firebaseUid = HttpContext.GetFirebaseUid(); + var result = await _inviteService.RedeemInviteAsync(request.Code, firebaseUid); + if (result.IsFailure) + { + return result.ToProblemDetails(); + } + + if (!string.IsNullOrWhiteSpace(firebaseUid)) + { + await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync( + firebaseUid, + new Dictionary + { + { "role", result.Value.Role.ToString() } + }); + } + + return Results.Ok(result.Value); + } +} diff --git a/JobFlow.API/Extensions/HttpContextExtensions.cs b/JobFlow.API/Extensions/HttpContextExtensions.cs index fdd01ab..f0637f5 100644 --- a/JobFlow.API/Extensions/HttpContextExtensions.cs +++ b/JobFlow.API/Extensions/HttpContextExtensions.cs @@ -23,4 +23,21 @@ public static Guid GetUserId(this HttpContext context) return userId; } + + public static string? GetFirebaseUid(this HttpContext context) + { + var uid = context.User.FindFirst("user_id")?.Value; + if (!string.IsNullOrWhiteSpace(uid)) + { + return uid; + } + + uid = context.User.FindFirst("sub")?.Value; + if (!string.IsNullOrWhiteSpace(uid)) + { + return uid; + } + + return context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/SupportHubDtos.cs b/JobFlow.Business/Models/DTOs/SupportHubDtos.cs new file mode 100644 index 0000000..a62df66 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/SupportHubDtos.cs @@ -0,0 +1,56 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public record SupportHubTicketDto( + Guid Id, + string Title, + SupportHubTicketStatus Status, + string OrganizationName, + DateTimeOffset CreatedAt); + +public record SupportHubSessionDto( + Guid Id, + string OrganizationName, + string AgentName, + SupportHubSessionStatus Status, + DateTimeOffset? StartedAt); + +public record SupportHubScreenResponseDto( + Guid SessionId, + string ViewerUrl); + +public record SupportHubTicketCreateRequest( + Guid OrganizationId, + string Title, + string? Summary, + SupportHubTicketStatus Status); + +public record SupportHubSessionCreateRequest( + Guid OrganizationId, + string AgentName, + SupportHubSessionStatus Status); + +public record SupportHubSeedRequest(Guid OrganizationId); + +public record SupportHubSeedResponse(int TicketsCreated, int SessionsCreated); + +public record SupportHubInviteDto( + Guid Id, + string Code, + SupportHubInviteRole Role, + DateTimeOffset CreatedAt, + string? CreatedBy, + DateTimeOffset ExpiresAt, + DateTimeOffset? RedeemedAt, + string? RedeemedBy); + +public record SupportHubInviteCreateRequest( + SupportHubInviteRole Role, + DateTimeOffset? ExpiresAt); + +public record SupportHubInviteRedeemRequest(string Code); + +public record SupportHubInviteValidationDto( + SupportHubInviteDto? Invite, + string? Error); diff --git a/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs new file mode 100644 index 0000000..1e82f4f --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs @@ -0,0 +1,11 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface ISupportHubInviteService +{ + Task> CreateInviteAsync(SupportHubInviteCreateRequest request, string? createdBy); + Task>> GetActiveInvitesAsync(); + Task> ValidateInviteAsync(string code); + Task> RedeemInviteAsync(string code, string? redeemedBy); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs new file mode 100644 index 0000000..6ee4c60 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs @@ -0,0 +1,13 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface ISupportHubService +{ + Task>> GetTicketsAsync(); + Task>> GetSessionsAsync(); + Task> CreateScreenViewAsync(Guid sessionId); + Task> CreateTicketAsync(SupportHubTicketCreateRequest request, string? createdBy); + Task> CreateSessionAsync(SupportHubSessionCreateRequest request, string? createdBy); + Task> SeedDemoAsync(SupportHubSeedRequest request, string? createdBy); +} diff --git a/JobFlow.Business/Services/SupportHubInviteService.cs b/JobFlow.Business/Services/SupportHubInviteService.cs new file mode 100644 index 0000000..5fc60d5 --- /dev/null +++ b/JobFlow.Business/Services/SupportHubInviteService.cs @@ -0,0 +1,169 @@ +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class SupportHubInviteService : ISupportHubInviteService +{ + private const int MaxCodeAttempts = 5; + private readonly IRepository _invites; + private readonly IUnitOfWork _unitOfWork; + + public SupportHubInviteService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _invites = unitOfWork.RepositoryOf(); + } + + public async Task> CreateInviteAsync( + SupportHubInviteCreateRequest request, + string? createdBy) + { + var code = await GenerateUniqueCodeAsync(); + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Failure( + Error.Failure("SupportHub.InviteGenerationFailed", "Unable to generate invite code.")); + } + + var invite = new SupportHubInvite + { + Id = Guid.NewGuid(), + Code = code, + Role = request.Role, + CreatedBy = createdBy, + ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddDays(7), + }; + + await _invites.AddAsync(invite); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(invite)); + } + + public async Task>> GetActiveInvitesAsync() + { + var invites = await _invites.Query() + .AsNoTracking() + .Where(x => x.RedeemedAt == null && x.ExpiresAt > DateTimeOffset.UtcNow) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + + var results = invites.Select(ToDto).ToList(); + return Result.Success(results); + } + + public async Task> ValidateInviteAsync(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code is required.")); + } + + var normalized = code.Trim().ToUpperInvariant(); + var invite = await _invites.Query() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Code == normalized); + + if (invite is null) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code not found.")); + } + + if (invite.RedeemedAt.HasValue) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code already redeemed.")); + } + + if (invite.ExpiresAt <= DateTimeOffset.UtcNow) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code expired.")); + } + + return Result.Success(new SupportHubInviteValidationDto(ToDto(invite), null)); + } + + public async Task> RedeemInviteAsync(string code, string? redeemedBy) + { + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Failure( + Error.Validation("SupportHub.InviteRequired", "Invite code is required.")); + } + + var normalized = code.Trim().ToUpperInvariant(); + var invite = await _invites.Query().FirstOrDefaultAsync(x => x.Code == normalized); + if (invite is null) + { + return Result.Failure( + Error.NotFound("SupportHub.InviteNotFound", "Invite code not found.")); + } + + if (invite.RedeemedAt.HasValue) + { + return Result.Failure( + Error.Conflict("SupportHub.InviteAlreadyRedeemed", "Invite code already redeemed.")); + } + + if (invite.ExpiresAt <= DateTimeOffset.UtcNow) + { + return Result.Failure( + Error.Validation("SupportHub.InviteExpired", "Invite code expired.")); + } + + invite.RedeemedAt = DateTimeOffset.UtcNow; + invite.RedeemedByUid = redeemedBy; + + _invites.Update(invite); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(invite)); + } + + private async Task GenerateUniqueCodeAsync() + { + for (var attempt = 0; attempt < MaxCodeAttempts; attempt += 1) + { + var code = GenerateCode(); + var exists = await _invites.ExistsAsync(x => x.Code == code); + if (!exists) + { + return code; + } + } + + return null; + } + + private static string GenerateCode() + { + const string alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + Span chars = stackalloc char[8]; + var random = new Random(); + for (var i = 0; i < chars.Length; i += 1) + { + chars[i] = alphabet[random.Next(alphabet.Length)]; + } + + return new string(chars); + } + + private static SupportHubInviteDto ToDto(SupportHubInvite invite) + { + return new SupportHubInviteDto( + invite.Id, + invite.Code, + invite.Role, + invite.CreatedAt, + invite.CreatedBy, + invite.ExpiresAt, + invite.RedeemedAt, + invite.RedeemedByUid); + } +} diff --git a/JobFlow.Business/Services/SupportHubService.cs b/JobFlow.Business/Services/SupportHubService.cs new file mode 100644 index 0000000..90f1fd1 --- /dev/null +++ b/JobFlow.Business/Services/SupportHubService.cs @@ -0,0 +1,284 @@ +using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class SupportHubService : ISupportHubService +{ + private readonly IRepository _tickets; + private readonly IRepository _sessions; + private readonly IRepository _organizations; + private readonly IFrontendSettings _frontendSettings; + private readonly IUnitOfWork _unitOfWork; + + public SupportHubService( + IUnitOfWork unitOfWork, + IFrontendSettings frontendSettings) + { + _unitOfWork = unitOfWork; + _frontendSettings = frontendSettings; + _tickets = unitOfWork.RepositoryOf(); + _sessions = unitOfWork.RepositoryOf(); + _organizations = unitOfWork.RepositoryOf(); + } + + public async Task>> GetTicketsAsync() + { + var tickets = await _tickets.Query() + .AsNoTracking() + .Include(x => x.Organization) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new SupportHubTicketDto( + x.Id, + x.Title, + x.Status, + x.Organization != null ? x.Organization.OrganizationName ?? "Unknown" : "Unknown", + new DateTimeOffset(x.CreatedAt, TimeSpan.Zero))) + .ToListAsync(); + + return Result.Success(tickets); + } + + public async Task>> GetSessionsAsync() + { + var sessions = await _sessions.Query() + .AsNoTracking() + .Include(x => x.Organization) + .OrderByDescending(x => x.StartedAt ?? DateTimeOffset.MinValue) + .Select(x => new SupportHubSessionDto( + x.Id, + x.Organization != null ? x.Organization.OrganizationName ?? "Unknown" : "Unknown", + x.AgentName, + x.Status, + x.StartedAt)) + .ToListAsync(); + + return Result.Success(sessions); + } + + public async Task> CreateScreenViewAsync(Guid sessionId) + { + var session = await _sessions.Query().AsNoTracking().FirstOrDefaultAsync(x => x.Id == sessionId); + if (session is null) + { + return Result.Failure( + Error.NotFound("SupportHub.SessionNotFound", "Support session not found.")); + } + + var baseUrl = _frontendSettings.BaseUrl?.TrimEnd('/') ?? string.Empty; + var viewerUrl = string.IsNullOrWhiteSpace(baseUrl) + ? $"/support-hub/sessions/{sessionId}" + : $"{baseUrl}/support-hub/sessions/{sessionId}"; + + var response = new SupportHubScreenResponseDto(sessionId, viewerUrl); + return Result.Success(response); + } + + public async Task> CreateTicketAsync( + SupportHubTicketCreateRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + return Result.Failure( + Error.Validation("SupportHub.TitleRequired", "Ticket title is required.")); + } + + if (request.Title.Length > 160) + { + return Result.Failure( + Error.Validation("SupportHub.TitleTooLong", "Ticket title is too long.")); + } + + if (!string.IsNullOrWhiteSpace(request.Summary) && request.Summary.Length > 500) + { + return Result.Failure( + Error.Validation("SupportHub.SummaryTooLong", "Ticket summary is too long.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var ticket = new SupportHubTicket + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = request.Title.Trim(), + Summary = request.Summary?.Trim(), + Status = request.Status, + LastActivityAt = DateTimeOffset.UtcNow, + CreatedBy = createdBy + }; + + await _tickets.AddAsync(ticket); + await _unitOfWork.SaveChangesAsync(); + + var orgName = await _organizations.Query() + .Where(x => x.Id == request.OrganizationId) + .Select(x => x.OrganizationName) + .FirstOrDefaultAsync(); + + var dto = new SupportHubTicketDto( + ticket.Id, + ticket.Title, + ticket.Status, + orgName ?? "Unknown", + new DateTimeOffset(ticket.CreatedAt, TimeSpan.Zero)); + + return Result.Success(dto); + } + + public async Task> CreateSessionAsync( + SupportHubSessionCreateRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + if (string.IsNullOrWhiteSpace(request.AgentName)) + { + return Result.Failure( + Error.Validation("SupportHub.AgentRequired", "Agent name is required.")); + } + + if (request.AgentName.Length > 120) + { + return Result.Failure( + Error.Validation("SupportHub.AgentTooLong", "Agent name is too long.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var session = new SupportHubSession + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = request.AgentName.Trim(), + Status = request.Status, + StartedAt = request.Status == Domain.Enums.SupportHubSessionStatus.Live + ? DateTimeOffset.UtcNow + : null, + CreatedBy = createdBy + }; + + await _sessions.AddAsync(session); + await _unitOfWork.SaveChangesAsync(); + + var orgName = await _organizations.Query() + .Where(x => x.Id == request.OrganizationId) + .Select(x => x.OrganizationName) + .FirstOrDefaultAsync(); + + var dto = new SupportHubSessionDto( + session.Id, + orgName ?? "Unknown", + session.AgentName, + session.Status, + session.StartedAt); + + return Result.Success(dto); + } + + public async Task> SeedDemoAsync( + SupportHubSeedRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var tickets = new List + { + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Invoice payment failed", + Summary = "Customer card failed on invoice payment.", + Status = Domain.Enums.SupportHubTicketStatus.Urgent, + LastActivityAt = DateTimeOffset.UtcNow, + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Crew schedule not saving", + Summary = "Dispatch cannot persist schedule edits.", + Status = Domain.Enums.SupportHubTicketStatus.High, + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-45), + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Branding logo upload error", + Summary = "Admin sees error when updating logo.", + Status = Domain.Enums.SupportHubTicketStatus.Normal, + LastActivityAt = DateTimeOffset.UtcNow.AddHours(-2), + CreatedBy = createdBy + } + }; + + var sessions = new List + { + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = "Support Agent", + Status = Domain.Enums.SupportHubSessionStatus.Live, + StartedAt = DateTimeOffset.UtcNow.AddMinutes(-12), + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = "Support Agent", + Status = Domain.Enums.SupportHubSessionStatus.Queued, + CreatedBy = createdBy + } + }; + + await _tickets.AddRangeAsync(tickets); + await _sessions.AddRangeAsync(sessions); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(new SupportHubSeedResponse(tickets.Count, sessions.Count)); + } +} diff --git a/JobFlow.Domain/Enums/SupportHubInviteRole.cs b/JobFlow.Domain/Enums/SupportHubInviteRole.cs new file mode 100644 index 0000000..1cf05e6 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubInviteRole.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubInviteRole +{ + KatharixAdmin = 0, + KatharixEmployee = 1 +} diff --git a/JobFlow.Domain/Enums/SupportHubSessionStatus.cs b/JobFlow.Domain/Enums/SupportHubSessionStatus.cs new file mode 100644 index 0000000..6218509 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubSessionStatus.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubSessionStatus +{ + Live = 0, + Queued = 1, + FollowUp = 2, + Ended = 3 +} diff --git a/JobFlow.Domain/Enums/SupportHubTicketStatus.cs b/JobFlow.Domain/Enums/SupportHubTicketStatus.cs new file mode 100644 index 0000000..2466fd7 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubTicketStatus.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubTicketStatus +{ + Urgent = 0, + High = 1, + Normal = 2, + Low = 3, + Resolved = 4 +} diff --git a/JobFlow.Domain/Models/SupportHubInvite.cs b/JobFlow.Domain/Models/SupportHubInvite.cs new file mode 100644 index 0000000..b56fbe6 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubInvite.cs @@ -0,0 +1,12 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubInvite : Entity +{ + public string Code { get; set; } = string.Empty; + public SupportHubInviteRole Role { get; set; } = SupportHubInviteRole.KatharixEmployee; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RedeemedAt { get; set; } + public string? RedeemedByUid { get; set; } +} diff --git a/JobFlow.Domain/Models/SupportHubSession.cs b/JobFlow.Domain/Models/SupportHubSession.cs new file mode 100644 index 0000000..9dfbea5 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubSession.cs @@ -0,0 +1,14 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubSession : Entity +{ + public Guid OrganizationId { get; set; } + public string AgentName { get; set; } = string.Empty; + public SupportHubSessionStatus Status { get; set; } = SupportHubSessionStatus.Queued; + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? EndedAt { get; set; } + + public Organization? Organization { get; set; } +} diff --git a/JobFlow.Domain/Models/SupportHubTicket.cs b/JobFlow.Domain/Models/SupportHubTicket.cs new file mode 100644 index 0000000..89554e8 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubTicket.cs @@ -0,0 +1,14 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubTicket : Entity +{ + public Guid OrganizationId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + public SupportHubTicketStatus Status { get; set; } = SupportHubTicketStatus.Normal; + public DateTimeOffset? LastActivityAt { get; set; } + + public Organization? Organization { get; set; } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs new file mode 100644 index 0000000..f001b49 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs @@ -0,0 +1,21 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubInviteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Code).HasMaxLength(12).IsRequired(); + builder.Property(x => x.Role).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.RedeemedByUid).HasMaxLength(128); + + builder.HasIndex(x => x.Code).IsUnique(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs new file mode 100644 index 0000000..1bbd74f --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs @@ -0,0 +1,25 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.OrganizationId).IsRequired(); + builder.Property(x => x.AgentName).HasMaxLength(120).IsRequired(); + builder.Property(x => x.Status).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.OrganizationId); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs new file mode 100644 index 0000000..506a978 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs @@ -0,0 +1,26 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubTicketConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.OrganizationId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(160).IsRequired(); + builder.Property(x => x.Summary).HasMaxLength(500); + builder.Property(x => x.Status).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.OrganizationId); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 95ce179..d8b2196 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -22,6 +22,9 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet OrganizationTypes { get; set; } public DbSet EmployeeRolePresets { get; set; } public DbSet EmployeeRolePresetItems { get; set; } + public DbSet SupportHubTickets { get; set; } + public DbSet SupportHubSessions { get; set; } + public DbSet SupportHubInvites { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs new file mode 100644 index 0000000..ca8d8f4 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs @@ -0,0 +1,126 @@ +using System; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(JobFlowDbContext))] + [Migration("20260323235900_AddSupportHubTables")] + public partial class AddSupportHubTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SupportHubInvites", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(12)", maxLength: 12, nullable: false), + Role = table.Column(type: "int", nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false), + RedeemedAt = table.Column(type: "datetimeoffset", nullable: true), + RedeemedByUid = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubInvites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupportHubSessions", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + AgentName = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Status = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetimeoffset", nullable: true), + EndedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubSessions", x => x.Id); + table.ForeignKey( + name: "FK_SupportHubSessions_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SupportHubTickets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + Summary = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "int", nullable: false), + LastActivityAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubTickets", x => x.Id); + table.ForeignKey( + name: "FK_SupportHubTickets_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubInvites_Code", + table: "SupportHubInvites", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubSessions_OrganizationId", + table: "SupportHubSessions", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubTickets_OrganizationId", + table: "SupportHubTickets", + column: "OrganizationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SupportHubInvites"); + + migrationBuilder.DropTable( + name: "SupportHubSessions"); + + migrationBuilder.DropTable( + name: "SupportHubTickets"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 42f15a5..2b2f34e 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -2345,6 +2345,153 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PriceBookItems", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => { b.Property("Id") @@ -2896,6 +3043,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => { b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index b606ebe..929033f 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using FirebaseAdmin.Auth; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -90,9 +91,10 @@ public async Task Invoke(HttpContext context, IUserService userService) new Claim(ClaimTypes.NameIdentifier, decodedToken.Uid) }; + var roleValue = string.Empty; if (decodedToken.Claims.TryGetValue("role", out var role)) { - var roleValue = Convert.ToString(role); + roleValue = Convert.ToString(role) ?? string.Empty; if (!string.IsNullOrWhiteSpace(roleValue)) claims.Add(new Claim(ClaimTypes.Role, roleValue)); } @@ -104,6 +106,25 @@ public async Task Invoke(HttpContext context, IUserService userService) claims.Add(new Claim(ClaimTypes.Email, emailValue)); } + var isSupportHubUser = string.Equals(roleValue, UserRoles.KatharixAdmin, StringComparison.Ordinal) + || string.Equals(roleValue, UserRoles.KatharixEmployee, StringComparison.Ordinal); + + if (path is not null && (path.StartsWith("/api/supporthub/invites/redeem") || path.StartsWith("/api/supporthub/register"))) + { + var redeemIdentity = new ClaimsIdentity(claims, "Firebase"); + context.User.AddIdentity(redeemIdentity); + await _next(context); + return; + } + + if (isSupportHubUser) + { + var supportIdentity = new ClaimsIdentity(claims, "Firebase"); + context.User.AddIdentity(supportIdentity); + await _next(context); + return; + } + var userResult = await userService.GetUserByFirebaseUid(decodedToken.Uid); if (!userResult.IsSuccess)