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
170 changes: 153 additions & 17 deletions JobFlow.API/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,83 @@ public async Task<IActionResult> GetConversations()
if (currentUser is null)
return firebaseUidResult ?? Unauthorized();

var isOrganizationMember = await _unitOfWork.RepositoryOf<User>()
.Query()
.AnyAsync(u => u.Id == currentUser.Id && u.OrganizationId == organizationId);

var orgUserIds = isOrganizationMember
? await _unitOfWork.RepositoryOf<User>()
.Query()
.Where(u => u.OrganizationId == organizationId)
.Select(u => u.Id)
.Distinct()
.ToListAsync()
: new List<Guid>();

var orgClientConversationIds = isOrganizationMember
? await _unitOfWork.RepositoryOf<Conversation>()
.Query()
.Where(c => c.OrganizationClientId.HasValue
&& c.OrganizationClient != null
&& c.OrganizationClient.OrganizationId == organizationId)
.Select(c => c.Id)
.ToListAsync()
: new List<Guid>();

var legacyClientInitiatedConversationIds = isOrganizationMember
? await _unitOfWork.RepositoryOf<Conversation>()
.Query()
.Where(c => !c.OrganizationClientId.HasValue
&& c.Messages.Any(m => m.ExternalSenderType == "client"
|| (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName)))
&& c.Participants.Any(p => orgUserIds.Contains(p.UserId)))
.Select(c => c.Id)
.ToListAsync()
: new List<Guid>();

var conversations = await _unitOfWork.RepositoryOf<Conversation>()
.Query()
.Include(c => c.Participants)
.Include(c => c.Messages)
.Where(c => c.Participants.Any(p => p.UserId == currentUser.Id))
.Where(c => c.Participants.Any(p => p.UserId == currentUser.Id)
|| orgClientConversationIds.Contains(c.Id)
|| legacyClientInitiatedConversationIds.Contains(c.Id))
.ToListAsync();

if (isOrganizationMember)
{
var missingParticipantConversationIds = conversations
.Where(c => (c.OrganizationClientId.HasValue
|| c.Messages.Any(m => m.ExternalSenderType == "client"
|| (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName))))
&& !c.Participants.Any(p => p.UserId == currentUser.Id))
.Select(c => c.Id)
.ToList();

if (missingParticipantConversationIds.Count > 0)
{
foreach (var conversationId in missingParticipantConversationIds)
{
await _unitOfWork.RepositoryOf<ConversationParticipant>().AddAsync(new ConversationParticipant
{
ConversationId = conversationId,
UserId = currentUser.Id
});
}

await _unitOfWork.SaveChangesAsync();

conversations = await _unitOfWork.RepositoryOf<Conversation>()
.Query()
.Include(c => c.Participants)
.Include(c => c.Messages)
.Where(c => c.Participants.Any(p => p.UserId == currentUser.Id)
|| orgClientConversationIds.Contains(c.Id)
|| legacyClientInitiatedConversationIds.Contains(c.Id))
.ToListAsync();
}
}

var participantIds = conversations
.SelectMany(c => c.Participants)
.Select(p => p.UserId)
Expand Down Expand Up @@ -102,11 +172,8 @@ public async Task<IActionResult> GetMessages(
if (pageSize < 1) pageSize = 50;
if (pageSize > 200) pageSize = 200;

var isParticipant = await _unitOfWork.RepositoryOf<ConversationParticipant>()
.Query()
.AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id);

if (!isParticipant)
var canAccessConversation = await EnsureConversationAccessAsync(conversationId, currentUser.Id, organizationId);
if (!canAccessConversation)
return Forbid();

var messageQuery = _unitOfWork.RepositoryOf<Message>()
Expand Down Expand Up @@ -157,11 +224,8 @@ public async Task<IActionResult> CreateMessage([FromBody] CreateMessageRequest r
if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl))
return BadRequest("Message content or attachment is required.");

var isParticipant = await _unitOfWork.RepositoryOf<ConversationParticipant>()
.Query()
.AnyAsync(p => p.ConversationId == request.ConversationId && p.UserId == currentUser.Id);

if (!isParticipant)
var canAccessConversation = await EnsureConversationAccessAsync(request.ConversationId, currentUser.Id, organizationId);
if (!canAccessConversation)
return Forbid();

var message = new Message
Expand Down Expand Up @@ -199,15 +263,12 @@ public async Task<IActionResult> CreateMessage([FromBody] CreateMessageRequest r
[HttpPost("conversations/{conversationId:guid}/read")]
public async Task<IActionResult> MarkConversationRead(Guid conversationId)
{
var (currentUser, _, firebaseUidResult) = await ResolveCurrentUserAsync();
var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync();
if (currentUser is null)
return firebaseUidResult ?? Unauthorized();

var isParticipant = await _unitOfWork.RepositoryOf<ConversationParticipant>()
.Query()
.AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id);

if (!isParticipant)
var canAccessConversation = await EnsureConversationAccessAsync(conversationId, currentUser.Id, organizationId);
if (!canAccessConversation)
return Forbid();

var messages = await _unitOfWork.RepositoryOf<Message>()
Expand Down Expand Up @@ -517,6 +578,81 @@ private static (string? name, string? role, string? avatarUrl) ResolveParticipan
return (userResult.Value, organizationId, null);
}

private async Task<bool> EnsureConversationAccessAsync(Guid conversationId, Guid userId, Guid organizationId)
{
var participantExists = await _unitOfWork.RepositoryOf<ConversationParticipant>()
.Query()
.AnyAsync(p => p.ConversationId == conversationId && p.UserId == userId);

if (participantExists)
return true;

var conversation = await _unitOfWork.RepositoryOf<Conversation>()
.Query()
.FirstOrDefaultAsync(c => c.Id == conversationId);

if (conversation is null)
return false;

if (!conversation.OrganizationClientId.HasValue)
{
var orgUserIds = await _unitOfWork.RepositoryOf<User>()
.Query()
.Where(u => u.OrganizationId == organizationId)
.Select(u => u.Id)
.Distinct()
.ToListAsync();

var isLegacyClientInitiated = await _unitOfWork.RepositoryOf<Message>()
.Query()
.AnyAsync(m => m.ConversationId == conversationId
&& (m.ExternalSenderType == "client"
|| (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName))));

if (!isLegacyClientInitiated)
return false;

var hasOrgParticipant = await _unitOfWork.RepositoryOf<ConversationParticipant>()
.Query()
.AnyAsync(p => p.ConversationId == conversationId && orgUserIds.Contains(p.UserId));

if (!hasOrgParticipant)
return false;

await _unitOfWork.RepositoryOf<ConversationParticipant>().AddAsync(new ConversationParticipant
{
ConversationId = conversationId,
UserId = userId
});
await _unitOfWork.SaveChangesAsync();

return true;
}

var isOrganizationMember = await _unitOfWork.RepositoryOf<User>()
.Query()
.AnyAsync(u => u.Id == userId && u.OrganizationId == organizationId);

if (!isOrganizationMember)
return false;

var belongsToOrganization = await _unitOfWork.RepositoryOf<OrganizationClient>()
.Query()
.AnyAsync(c => c.Id == conversation.OrganizationClientId.Value && c.OrganizationId == organizationId);

if (!belongsToOrganization)
return false;

await _unitOfWork.RepositoryOf<ConversationParticipant>().AddAsync(new ConversationParticipant
{
ConversationId = conversationId,
UserId = userId
});
await _unitOfWork.SaveChangesAsync();

return true;
}

private async Task SendToClientHubAsync(Guid conversationId, Message message)
{
var conversation = await _unitOfWork.RepositoryOf<Conversation>()
Expand Down
12 changes: 9 additions & 3 deletions JobFlow.API/Controllers/OrganizationClientController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using JobFlow.API.Extensions;
using JobFlow.API.Mappings;
using JobFlow.Business;
using JobFlow.API.Models;
using JobFlow.Business.Extensions;
using JobFlow.Business.Models.DTOs;
Expand Down Expand Up @@ -64,14 +65,19 @@ public async Task<IResult> UpsertClient(
if (organizationId == Guid.Empty)
return Results.BadRequest("OrganizationId is required.");

model.Organization = null;
model.OrganizationId = organizationId;
var entity = _mapper.Map<OrganizationClient>(model);

var result = await organizationClientService.UpsertClient(entity);

return result.IsSuccess
? Results.Ok(result)
: result.ToProblemDetails();
if (!result.IsSuccess)
return result.ToProblemDetails();

var responseDto = _mapper.Map<OrganizationClientDto>(result.Value);
responseDto.Organization = null;

return Results.Ok(Result.Success(responseDto));
}


Expand Down
28 changes: 27 additions & 1 deletion JobFlow.API/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using JobFlow.Business.Extensions;
using JobFlow.API.Extensions;
using JobFlow.Business.Extensions;
using JobFlow.Business.Models.DTOs;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain.Models;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -62,4 +64,28 @@ public async Task<IResult> GetByFirebaseUid(string uid)
var result = await _userService.GetUserByFirebaseUid(uid);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[Authorize]
[HttpGet("me")]
public async Task<IResult> GetMe()
{
var firebaseUid = HttpContext.GetFirebaseUid();
if (string.IsNullOrWhiteSpace(firebaseUid))
return Results.Unauthorized();

var result = await _userService.GetProfileByFirebaseUid(firebaseUid);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[Authorize]
[HttpPut("me")]
public async Task<IResult> UpdateMe([FromBody] UserProfileUpdateRequest request)
{
var firebaseUid = HttpContext.GetFirebaseUid();
if (string.IsNullOrWhiteSpace(firebaseUid))
return Results.Unauthorized();

var result = await _userService.UpdateProfile(firebaseUid, request);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}
}
4 changes: 4 additions & 0 deletions JobFlow.API/Mappings/MapsterConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public void Register(TypeAdapterConfig config)
//OrganizationClient → DTO
config.NewConfig<OrganizationClient, OrganizationClientDto>();

//DTO → OrganizationClient
config.NewConfig<OrganizationClientDto, OrganizationClient>()
.Ignore(dest => dest.Organization);

//Invoice → DTO
config.NewConfig<Invoice, InvoiceDto>();

Expand Down
2 changes: 2 additions & 0 deletions JobFlow.API/Mappings/OrganizationBrandingMappingExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public static BrandingDto ToDto(OrganizationBranding entity)
LogoUrl = entity.LogoUrl,
PrimaryColor = entity.PrimaryColor,
SecondaryColor = entity.SecondaryColor,
BusinessName = entity.BusinessName,
Tagline = entity.Tagline,
FooterNote = entity.FooterNote
};
Expand All @@ -26,6 +27,7 @@ public static OrganizationBranding ToEntity(BrandingDto dto)
LogoUrl = dto.LogoUrl,
PrimaryColor = dto.PrimaryColor,
SecondaryColor = dto.SecondaryColor,
BusinessName = dto.BusinessName,
Tagline = dto.Tagline,
FooterNote = dto.FooterNote
};
Expand Down
1 change: 1 addition & 0 deletions JobFlow.API/Models/BrandingDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class BrandingDto
public string? LogoUrl { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
public string? BusinessName { get; set; }
public string? Tagline { get; set; }
public string? FooterNote { get; set; }
}
9 changes: 9 additions & 0 deletions JobFlow.Business/Models/DTOs/UserProfileDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace JobFlow.Business.Models.DTOs;

public class UserProfileDto
{
public Guid Id { get; set; }
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public string? PreferredLanguage { get; set; }
}
8 changes: 8 additions & 0 deletions JobFlow.Business/Models/DTOs/UserProfileUpdateRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace JobFlow.Business.Models.DTOs;

public class UserProfileUpdateRequest
{
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public string? PreferredLanguage { get; set; }
}
2 changes: 1 addition & 1 deletion JobFlow.Business/Services/OrganizationBrandingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<Result<OrganizationBranding>> GetByOrganizationIdAsync(Guid or
.FirstOrDefaultAsync(b => b.OrganizationId == organizationId);

return branding is null
? Result.Failure<OrganizationBranding>(Error.NotFound("", "Branding not found."))
? Result.Success(new OrganizationBranding { OrganizationId = organizationId })
: Result.Success(branding);
}
catch (Exception ex)
Expand Down
3 changes: 3 additions & 0 deletions JobFlow.Business/Services/ServiceInterfaces/IUserService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JobFlow.Domain.Models;
using JobFlow.Business.Models.DTOs;

namespace JobFlow.Business.Services.ServiceInterfaces;

Expand All @@ -11,4 +12,6 @@ public interface IUserService
Task<Result> DeleteUser(Guid userId);
Task<Result<User>> GetUserByEmail(string email);
Task<Result> AssignRole(Guid userId, string role);
Task<Result<UserProfileDto>> GetProfileByFirebaseUid(string uid);
Task<Result<UserProfileDto>> UpdateProfile(string uid, UserProfileUpdateRequest request);
}
Loading
Loading