diff --git a/JobFlow.API/Controllers/ChatController.cs b/JobFlow.API/Controllers/ChatController.cs index 173acea..bd3f654 100644 --- a/JobFlow.API/Controllers/ChatController.cs +++ b/JobFlow.API/Controllers/ChatController.cs @@ -1,9 +1,12 @@ using System.Security.Claims; using JobFlow.API.Extensions; +using JobFlow.API.Hubs; using JobFlow.API.Models; +using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; +using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -15,11 +18,22 @@ public class ChatController : ControllerBase { private readonly IUserService _userService; private readonly IUnitOfWork _unitOfWork; - - public ChatController(IUserService userService, IUnitOfWork unitOfWork) + private readonly ITwilioService _twilioService; + private readonly IHubContext _chatHubContext; + private readonly IHubContext _clientChatHubContext; + + public ChatController( + IUserService userService, + IUnitOfWork unitOfWork, + ITwilioService twilioService, + IHubContext chatHubContext, + IHubContext clientChatHubContext) { _userService = userService; _unitOfWork = unitOfWork; + _twilioService = twilioService; + _chatHubContext = chatHubContext; + _clientChatHubContext = clientChatHubContext; } [HttpGet("conversations")] @@ -53,8 +67,21 @@ public async Task GetConversations() .Where(u => participantIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id); + var clientIds = conversations + .Where(c => c.OrganizationClientId.HasValue) + .Select(c => c.OrganizationClientId!.Value) + .Distinct() + .ToList(); + + var clientLookup = clientIds.Count == 0 + ? new Dictionary() + : await _unitOfWork.RepositoryOf() + .Query() + .Where(c => clientIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + var results = conversations - .Select(conversation => MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)) + .Select(conversation => MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, clientLookup)) .OrderByDescending(c => c.LastMessage?.SentAt ?? DateTime.MinValue) .ToList(); @@ -92,7 +119,11 @@ public async Task GetMessages( .Take(pageSize) .ToListAsync(); - var senderIds = paged.Select(m => m.SenderId).Distinct().ToList(); + var senderIds = paged + .Where(m => m.SenderId.HasValue) + .Select(m => m.SenderId!.Value) + .Distinct() + .ToList(); var employeeLookup = await _unitOfWork.RepositoryOf() .Query() @@ -123,8 +154,8 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r if (request.ConversationId == Guid.Empty) return BadRequest("ConversationId is required."); - if (string.IsNullOrWhiteSpace(request.Content)) - return BadRequest("Message content is required."); + if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl)) + return BadRequest("Message content or attachment is required."); var isParticipant = await _unitOfWork.RepositoryOf() .Query() @@ -147,6 +178,9 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r await _unitOfWork.RepositoryOf().AddAsync(message); await _unitOfWork.SaveChangesAsync(); + await TrySendClientSmsAsync(request.ConversationId, request.Content, request.AttachmentUrl); + await SendToClientHubAsync(request.ConversationId, message); + var employeeLookup = await _unitOfWork.RepositoryOf() .Query() .Include(e => e.Role) @@ -190,9 +224,23 @@ public async Task MarkConversationRead(Guid conversationId) } await _unitOfWork.SaveChangesAsync(); + + var readIds = messages.Select(m => m.Id).ToList(); + await _chatHubContext.Clients.Group(conversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId, + messageIds = readIds + }); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId, + messageIds = readIds + }); return Ok(); } + [HttpPost("conversations")] public async Task CreateConversation([FromBody] CreateConversationRequest request) { @@ -241,7 +289,7 @@ public async Task CreateConversation([FromBody] CreateConversatio .ToDictionaryAsync(e => e.UserId!.Value); var userLookupExisting = users.ToDictionary(u => u.Id); - return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting)); + return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting, new Dictionary())); } } @@ -271,20 +319,113 @@ public async Task CreateConversation([FromBody] CreateConversatio var userLookup = users.ToDictionary(u => u.Id); - return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)); + return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, new Dictionary())); + } + + [HttpPost("conversations/client")] + public async Task CreateClientConversation([FromBody] CreateClientConversationRequest request) + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + if (request.OrganizationClientId == Guid.Empty) + return BadRequest("OrganizationClientId is required."); + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.OrganizationClientId && c.OrganizationId == organizationId); + + if (client is null) + return NotFound("Client not found for this organization."); + + var existing = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => c.OrganizationClientId == request.OrganizationClientId); + + if (existing is not null) + { + var employeeLookupExisting = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookupExisting = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => employeeLookupExisting.Keys.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting, + new Dictionary { { client.Id, client } })); + } + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + Title = null, + OrganizationClientId = client.Id + }; + + var participantIds = await GetOrganizationUserIdsAsync(organizationId, currentUser.Id); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => employeeLookup.Keys.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + var clientLookup = new Dictionary { { client.Id, client } }; + return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, clientLookup)); } private static ChatConversationDto MapConversation( Conversation conversation, Guid currentUserId, IDictionary employeeLookup, - IDictionary userLookup) + IDictionary userLookup, + IDictionary clientLookup) { - var otherParticipant = conversation.Participants - .Select(p => p.UserId) - .FirstOrDefault(id => id != currentUserId); + string? name = null; + string? role = null; + string? avatar = null; - var (name, role, avatar) = ResolveParticipantDisplay(otherParticipant, employeeLookup, userLookup); + if (conversation.OrganizationClientId.HasValue + && clientLookup.TryGetValue(conversation.OrganizationClientId.Value, out var client)) + { + name = client.ClientFullName().Trim(); + role = "Client"; + avatar = null; + } + else + { + var otherParticipant = conversation.Participants + .Select(p => p.UserId) + .FirstOrDefault(id => id != currentUserId); + + var resolved = ResolveParticipantDisplay(otherParticipant, employeeLookup, userLookup); + name = resolved.name; + role = resolved.role; + avatar = resolved.avatarUrl; + } var lastMessage = conversation.Messages .OrderByDescending(m => m.SentAt) @@ -312,7 +453,20 @@ private static ChatMessageDto MapMessage( IDictionary employeeLookup, IDictionary userLookup) { - var (name, _, avatar) = ResolveParticipantDisplay(message.SenderId, employeeLookup, userLookup); + string? name = null; + string? avatar = null; + + if (message.SenderId.HasValue) + { + var resolved = ResolveParticipantDisplay(message.SenderId.Value, employeeLookup, userLookup); + name = resolved.name; + avatar = resolved.avatarUrl; + } + else + { + name = message.ExternalSenderName; + avatar = null; + } return new ChatMessageDto( message.Id, @@ -323,7 +477,8 @@ private static ChatMessageDto MapMessage( message.SentAt, name, avatar, - message.SenderId == currentUserId); + message.SenderId.HasValue && message.SenderId.Value == currentUserId, + message.IsRead); } private static (string? name, string? role, string? avatarUrl) ResolveParticipantDisplay( @@ -361,4 +516,87 @@ private static (string? name, string? role, string? avatarUrl) ResolveParticipan var organizationId = HttpContext.GetOrganizationId(); return (userResult.Value, organizationId, null); } + + private async Task SendToClientHubAsync(Guid conversationId, Message message) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is null) + return; + + var clientDto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + "JobFlow Team", + null, + false, + message.IsRead); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()) + .SendAsync("ReceiveMessage", clientDto); + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId, Guid fallbackUserId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + if (!userIds.Contains(fallbackUserId)) + userIds.Add(fallbackUserId); + + return userIds; + } + + private async Task TrySendClientSmsAsync(Guid conversationId, string? content, string? attachmentUrl) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId); + + if (conversation?.OrganizationClientId is null) + return; + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversation.OrganizationClientId.Value); + + if (client is null || string.IsNullOrWhiteSpace(client.PhoneNumber)) + return; + + var smsBody = BuildSmsBody(content, attachmentUrl); + if (string.IsNullOrWhiteSpace(smsBody)) + return; + + await _twilioService.SendTextMessage(new TwilioModel + { + RecipientPhoneNumber = client.PhoneNumber, + Message = smsBody + }); + } + + private static string BuildSmsBody(string? content, string? attachmentUrl) + { + var message = content?.Trim() ?? string.Empty; + var attachment = attachmentUrl?.Trim(); + + if (!string.IsNullOrWhiteSpace(attachment)) + { + if (string.IsNullOrWhiteSpace(message)) + message = attachment; + else + message = $"{message}\n{attachment}"; + } + + return message; + } } diff --git a/JobFlow.API/Controllers/ChatSmsController.cs b/JobFlow.API/Controllers/ChatSmsController.cs new file mode 100644 index 0000000..51b6beb --- /dev/null +++ b/JobFlow.API/Controllers/ChatSmsController.cs @@ -0,0 +1,148 @@ +using JobFlow.API.Hubs; +using JobFlow.API.Models; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/chat/sms")] +public class ChatSmsController : ControllerBase +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _hubContext; + private readonly IHubContext _clientHubContext; + + public ChatSmsController( + IUnitOfWork unitOfWork, + IHubContext hubContext, + IHubContext clientHubContext) + { + _unitOfWork = unitOfWork; + _hubContext = hubContext; + _clientHubContext = clientHubContext; + } + + [HttpPost("inbound")] + [AllowAnonymous] + public async Task Inbound([FromForm] TwilioInboundSmsRequest request) + { + if (string.IsNullOrWhiteSpace(request.From)) + return TwilioOk(); + + var fromNormalized = NormalizePhone(request.From); + if (string.IsNullOrWhiteSpace(fromNormalized)) + return TwilioOk(); + + var clients = await _unitOfWork.RepositoryOf() + .Query() + .Where(c => !string.IsNullOrWhiteSpace(c.PhoneNumber)) + .ToListAsync(); + + var client = clients.FirstOrDefault(c => NormalizePhone(c.PhoneNumber) == fromNormalized); + if (client is null) + return TwilioOk(); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .FirstOrDefaultAsync(c => c.OrganizationClientId == client.Id); + + if (conversation is null) + { + conversation = new Conversation + { + Id = Guid.NewGuid(), + OrganizationClientId = client.Id + }; + + var participantIds = await GetOrganizationUserIdsAsync(client.OrganizationId); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + } + + var content = request.Body?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(content)) + return TwilioOk(); + + var senderName = client.ClientFullName().Trim(); + var message = new Message + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + SenderId = null, + Content = content, + SentAt = DateTime.UtcNow, + IsRead = false, + ExternalSenderName = senderName, + ExternalSenderType = "client", + ExternalSenderPhone = client.PhoneNumber + }; + + await _unitOfWork.RepositoryOf().AddAsync(message); + await _unitOfWork.SaveChangesAsync(); + + var dto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + senderName, + null, + false, + message.IsRead); + + await _hubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + await _clientHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + + return TwilioOk(); + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + return userIds; + } + + private static string NormalizePhone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + return digits; + } + + private static ContentResult TwilioOk() + { + return new ContentResult + { + Content = "", + ContentType = "text/xml", + StatusCode = 200 + }; + } +} + +public record TwilioInboundSmsRequest(string? From, string? To, string? Body); diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 7492c0c..9e9e08a 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -1,12 +1,16 @@ using JobFlow.API.Extensions; using JobFlow.API.Hubs; +using JobFlow.API.Models; using JobFlow.Business.Extensions; using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; namespace JobFlow.API.Controllers; @@ -20,19 +24,28 @@ public class ClientHubController : ControllerBase private readonly IInvoiceService _invoices; private readonly IOrganizationClientService _clients; private readonly IHubContext _hubContext; + private readonly IHubContext _chatHubContext; + private readonly IHubContext _clientChatHubContext; + private readonly IUnitOfWork _unitOfWork; public ClientHubController( IEstimateService estimates, IEstimateRevisionService estimateRevisions, IInvoiceService invoices, IOrganizationClientService clients, - IHubContext hubContext) + IHubContext hubContext, + IHubContext chatHubContext, + IHubContext clientChatHubContext, + IUnitOfWork unitOfWork) { _estimates = estimates; _estimateRevisions = estimateRevisions; _invoices = invoices; _clients = clients; _hubContext = hubContext; + _chatHubContext = chatHubContext; + _clientChatHubContext = clientChatHubContext; + _unitOfWork = unitOfWork; } [HttpGet("me")] @@ -79,6 +92,193 @@ public async Task UpdateMe([FromBody] UpdateOrganizationClientRequest r return upsert.IsSuccess ? Results.Ok(upsert.Value) : upsert.ToProblemDetails(); } + [HttpGet("chat/conversation")] + public async Task GetChatConversation() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + var conversation = await FindOrCreateClientConversationAsync(orgClientId, organizationId); + + var lastMessage = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversation.Id) + .OrderByDescending(m => m.SentAt) + .FirstOrDefaultAsync(); + + var org = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(o => o.Id == organizationId); + + var name = org?.OrganizationName ?? "Your Team"; + var lastMessageDto = lastMessage is null + ? null + : MapClientHubMessage(lastMessage, clientResult.Value); + + var dto = new ChatConversationDto( + conversation.Id, + name, + null, + "Organization", + "online", + 0, + lastMessageDto); + + return Results.Ok(dto); + } + + [HttpGet("chat/messages")] + public async Task GetChatMessages([FromQuery] Guid conversationId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 200) pageSize = 200; + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var messages = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversationId) + .OrderByDescending(m => m.SentAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = messages + .OrderBy(m => m.SentAt) + .Select(m => MapClientHubMessage(m, clientResult.Value)) + .ToList(); + + return Results.Ok(result); + } + + [HttpPost("chat/messages")] + public async Task CreateChatMessage([FromBody] CreateMessageRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + if (request.ConversationId == Guid.Empty) + return Results.BadRequest("ConversationId is required."); + + if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl)) + return Results.BadRequest("Message content or attachment is required."); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.ConversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var senderName = clientResult.Value.ClientFullName().Trim(); + var message = new Message + { + Id = Guid.NewGuid(), + ConversationId = request.ConversationId, + SenderId = null, + Content = request.Content?.Trim() ?? string.Empty, + AttachmentUrl = string.IsNullOrWhiteSpace(request.AttachmentUrl) ? null : request.AttachmentUrl, + SentAt = DateTime.UtcNow, + IsRead = false, + ExternalSenderName = senderName, + ExternalSenderType = "client", + ExternalSenderPhone = clientResult.Value.PhoneNumber + }; + + await _unitOfWork.RepositoryOf().AddAsync(message); + await _unitOfWork.SaveChangesAsync(); + + var dto = MapClientHubMessage(message, clientResult.Value); + await _chatHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + await _clientChatHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + + return Results.Ok(dto); + } + + [HttpPost("chat/read")] + public async Task MarkChatRead([FromBody] ClientHubReadRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + if (request.ConversationId == Guid.Empty) + return Results.BadRequest("ConversationId is required."); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.ConversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var messages = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == request.ConversationId && m.SenderId.HasValue && !m.IsRead) + .ToListAsync(); + + if (messages.Count == 0) + return Results.Ok(new { updated = 0 }); + + foreach (var message in messages) + { + message.IsRead = true; + } + + await _unitOfWork.SaveChangesAsync(); + + var readIds = messages.Select(m => m.Id).ToList(); + await _chatHubContext.Clients.Group(request.ConversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId = request.ConversationId, + messageIds = readIds + }); + + await _clientChatHubContext.Clients.Group(request.ConversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId = request.ConversationId, + messageIds = readIds + }); + + return Results.Ok(new { updated = readIds.Count }); + } + [HttpGet("estimates")] public async Task GetMyEstimates() { @@ -215,6 +415,67 @@ public async Task GetMyInvoices() var result = await _invoices.GetInvoicesByClientAsync(orgClientId); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + + private async Task FindOrCreateClientConversationAsync(Guid orgClientId, Guid organizationId) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .FirstOrDefaultAsync(c => c.OrganizationClientId == orgClientId); + + if (conversation is not null) + return conversation; + + conversation = new Conversation + { + Id = Guid.NewGuid(), + OrganizationClientId = orgClientId + }; + + var participantIds = await GetOrganizationUserIdsAsync(organizationId); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + return conversation; + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + return userIds; + } + + private static ChatMessageDto MapClientHubMessage(Message message, OrganizationClient client) + { + var isMine = !message.SenderId.HasValue; + var senderName = isMine ? client.ClientFullName().Trim() : "JobFlow Team"; + + return new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + senderName, + null, + isMine, + message.IsRead); + } } public record UpdateOrganizationClientRequest( @@ -231,3 +492,5 @@ public record UpdateOrganizationClientRequest( public record CreateEstimateRevisionFormRequest( string? Message, List? Attachments); + +public record ClientHubReadRequest(Guid ConversationId); diff --git a/JobFlow.API/Hubs/ChatHub.cs b/JobFlow.API/Hubs/ChatHub.cs index 4273098..2bfcb30 100644 --- a/JobFlow.API/Hubs/ChatHub.cs +++ b/JobFlow.API/Hubs/ChatHub.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using JobFlow.API.Models; +using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; @@ -13,11 +14,19 @@ public class ChatHub : Hub { private readonly IUserService _userService; private readonly IUnitOfWork _unitOfWork; + private readonly ITwilioService _twilioService; + private readonly IHubContext _clientChatHubContext; - public ChatHub(IUserService userService, IUnitOfWork unitOfWork) + public ChatHub( + IUserService userService, + IUnitOfWork unitOfWork, + ITwilioService twilioService, + IHubContext clientChatHubContext) { _userService = userService; _unitOfWork = unitOfWork; + _twilioService = twilioService; + _clientChatHubContext = clientChatHubContext; } // Called when sending a message @@ -53,6 +62,10 @@ public async Task SendMessage(Guid conversationId, object message) await _unitOfWork.RepositoryOf().AddAsync(entity); await _unitOfWork.SaveChangesAsync(); + await TrySendClientSmsAsync(conversationId, entity.Id, content, attachmentUrl); + + await SendToClientHubAsync(conversationId, entity); + var dto = await BuildMessageDtoAsync(entity, currentUser.Id, true); await Clients.Caller.SendAsync("ReceiveMessage", dto); @@ -105,7 +118,8 @@ private async Task BuildMessageDtoAsync(Message message, Guid se message.SentAt, senderName, senderAvatarUrl, - isMine); + isMine, + message.IsRead); } private static string? ExtractContent(object? payload) @@ -163,4 +177,128 @@ private static bool TryGetString(JsonElement json, string propertyName, out stri return true; } + private async Task SendToClientHubAsync(Guid conversationId, Message message) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is null) + return; + + var clientDto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + "JobFlow Team", + null, + false, + message.IsRead); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()) + .SendAsync("ReceiveMessage", clientDto); + } + + private async Task TrySendClientSmsAsync(Guid conversationId, Guid messageId, string? content, string? attachmentUrl) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId); + + if (conversation?.OrganizationClientId is null) + return; + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversation.OrganizationClientId.Value); + + if (client is null || string.IsNullOrWhiteSpace(client.PhoneNumber)) + return; + + var smsBody = BuildSmsBody(content, attachmentUrl); + if (string.IsNullOrWhiteSpace(smsBody)) + return; + + try + { + await _twilioService.SendTextMessage(new TwilioModel + { + RecipientPhoneNumber = client.PhoneNumber, + Message = smsBody + }); + + await Clients.Caller.SendAsync("SmsStatus", new + { + conversationId, + messageId, + status = "sent", + to = client.PhoneNumber + }); + } + catch + { + await Clients.Caller.SendAsync("SmsStatus", new + { + conversationId, + messageId, + status = "failed", + to = client.PhoneNumber + }); + } + } + + public async Task Typing(Guid conversationId, bool isTyping) + { + var currentUser = await ResolveCurrentUserAsync(); + if (currentUser is null) + return; + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return; + + await Clients.OthersInGroup(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "org" + }); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is not null) + { + await _clientChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "org" + }); + } + } + + private static string BuildSmsBody(string? content, string? attachmentUrl) + { + var message = content?.Trim() ?? string.Empty; + var attachment = attachmentUrl?.Trim(); + + if (!string.IsNullOrWhiteSpace(attachment)) + { + if (string.IsNullOrWhiteSpace(message)) + message = attachment; + else + message = $"{message}\n{attachment}"; + } + + return message; + } + } \ No newline at end of file diff --git a/JobFlow.API/Hubs/ClientChatHub.cs b/JobFlow.API/Hubs/ClientChatHub.cs new file mode 100644 index 0000000..48887f4 --- /dev/null +++ b/JobFlow.API/Hubs/ClientChatHub.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Hubs; + +[Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")] +public class ClientChatHub : Hub +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _orgChatHubContext; + + public ClientChatHub(IUnitOfWork unitOfWork, IHubContext orgChatHubContext) + { + _unitOfWork = unitOfWork; + _orgChatHubContext = orgChatHubContext; + } + + public override async Task OnConnectedAsync() + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId != Guid.Empty) + { + var conversationId = await _unitOfWork.RepositoryOf() + .Query() + .Where(c => c.OrganizationClientId == orgClientId) + .Select(c => c.Id) + .FirstOrDefaultAsync(); + + if (conversationId != Guid.Empty) + { + await Groups.AddToGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + } + + await base.OnConnectedAsync(); + } + + public async Task JoinConversation(Guid conversationId) + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId == Guid.Empty) + return; + + var isClientConversation = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (!isClientConversation) + return; + + await Groups.AddToGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + + public async Task LeaveConversation(Guid conversationId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + + public async Task Typing(Guid conversationId, bool isTyping) + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId == Guid.Empty) + return; + + var isClientConversation = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (!isClientConversation) + return; + + await Clients.OthersInGroup(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "client" + }); + + await _orgChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "client" + }); + } + + private Guid GetOrganizationClientId() + { + var claim = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(claim, out var id) ? id : Guid.Empty; + } +} diff --git a/JobFlow.API/Models/ChatDtos.cs b/JobFlow.API/Models/ChatDtos.cs index df05bba..8f4d45e 100644 --- a/JobFlow.API/Models/ChatDtos.cs +++ b/JobFlow.API/Models/ChatDtos.cs @@ -3,13 +3,14 @@ namespace JobFlow.API.Models; public record ChatMessageDto( Guid Id, Guid ConversationId, - Guid SenderId, + Guid? SenderId, string Content, string? AttachmentUrl, DateTime SentAt, string? SenderName, string? SenderAvatarUrl, - bool IsMine); + bool IsMine, + bool IsRead); public record ChatConversationDto( Guid Id, @@ -22,4 +23,6 @@ public record ChatConversationDto( public record CreateConversationRequest(List ParticipantIds); +public record CreateClientConversationRequest(Guid OrganizationClientId); + public record CreateMessageRequest(Guid ConversationId, string Content, string? AttachmentUrl); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 41e0f91..e2de241 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -122,6 +122,23 @@ if (string.IsNullOrWhiteSpace(signingKey)) throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"].FirstOrDefault(); + var path = context.HttpContext.Request.Path; + + if (!string.IsNullOrWhiteSpace(accessToken) + && path.StartsWithSegments("/hubs/client-chat")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -380,6 +397,7 @@ app.MapControllers(); app.MapHub("/hubs/chat"); +app.MapHub("/hubs/client-chat"); app.MapHub("/hubs/notifier"); app.Run(); \ No newline at end of file diff --git a/JobFlow.Domain/Models/Conversation.cs b/JobFlow.Domain/Models/Conversation.cs index 62e046b..bbc40b3 100644 --- a/JobFlow.Domain/Models/Conversation.cs +++ b/JobFlow.Domain/Models/Conversation.cs @@ -3,6 +3,8 @@ public class Conversation : Entity { public string? Title { get; set; } // Optional – could be job title or username + public Guid? OrganizationClientId { get; set; } public ICollection Participants { get; set; } = new List(); public ICollection Messages { get; set; } = new List(); + public OrganizationClient? OrganizationClient { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Message.cs b/JobFlow.Domain/Models/Message.cs index 933d63d..227f473 100644 --- a/JobFlow.Domain/Models/Message.cs +++ b/JobFlow.Domain/Models/Message.cs @@ -3,11 +3,14 @@ public class Message : Entity { public Guid ConversationId { get; set; } - public Guid SenderId { get; set; } + public Guid? SenderId { get; set; } public string Content { get; set; } = string.Empty; public DateTime SentAt { get; set; } = DateTime.UtcNow; public bool IsRead { get; set; } public string? AttachmentUrl { get; set; } // <-- For file uploads + public string? ExternalSenderName { get; set; } + public string? ExternalSenderType { get; set; } + public string? ExternalSenderPhone { get; set; } public Conversation Conversation { get; set; } = null!; - public User Sender { get; set; } = null!; + public User? Sender { get; set; } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs index 4f77be9..2ddcee8 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs @@ -11,6 +11,11 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("Conversation", "messaging"); builder.HasKey(e => e.Id); + builder.HasOne(c => c.OrganizationClient) + .WithMany() + .HasForeignKey(c => c.OrganizationClientId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasMany(c => c.Participants) .WithOne(p => p.Conversation) .HasForeignKey(p => p.ConversationId); diff --git a/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs index c2f75bb..324d990 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs @@ -11,6 +11,11 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("Message", "messaging"); builder.HasKey(e => e.Id); builder.Property(e => e.Content).IsRequired(); + builder.Property(e => e.SenderId).IsRequired(false); + + builder.Property(e => e.ExternalSenderName).HasMaxLength(200); + builder.Property(e => e.ExternalSenderType).HasMaxLength(50); + builder.Property(e => e.ExternalSenderPhone).HasMaxLength(32); builder.HasOne(m => m.Sender) .WithMany() diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs new file mode 100644 index 0000000..be5157f --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs @@ -0,0 +1,2423 @@ +// +using System; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(JobFlowDbContext))] + [Migration("20260319171453_AddChatExternalSenders")] + partial class AddChatExternalSenders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", 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("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", 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("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", 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("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", 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("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", 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("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalPaymentId") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaidAt") + .HasColumnType("datetimeoffset"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", 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("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", 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("LastSequence") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("InvoiceSequence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", 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("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", 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("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationClient", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("OrganizationClientPortalSession"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", 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("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationService", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationType", 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("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeInvoiceId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("PaymentHistory"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", 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("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookCategories", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsTaxable") + .HasColumnType("bit"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanceledAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PaymentProfileId"); + + b.ToTable("SubscriptionRecord", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("Invoices") + .HasForeignKey("OrderId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", 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") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentOrders"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Navigation("LineItems"); + + b.Navigation("RevisionRequests"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Navigation("LineItems"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Navigation("Assignments"); + + b.Navigation("JobTrackings"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Navigation("AssignmentOrders"); + + b.Navigation("Invoices"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Navigation("EmployeeRoles"); + + b.Navigation("Employees"); + + b.Navigation("OnboardingSteps"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Navigation("Jobs"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Navigation("Employees"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs new file mode 100644 index 0000000..02c050e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddChatExternalSenders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SenderId", + schema: "messaging", + table: "Message", + type: "uniqueidentifier", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uniqueidentifier"); + + migrationBuilder.AddColumn( + name: "ExternalSenderName", + schema: "messaging", + table: "Message", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalSenderPhone", + schema: "messaging", + table: "Message", + type: "nvarchar(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalSenderType", + schema: "messaging", + table: "Message", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "OrganizationClientId", + schema: "messaging", + table: "Conversation", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Conversation_OrganizationClientId", + schema: "messaging", + table: "Conversation", + column: "OrganizationClientId"); + + migrationBuilder.AddForeignKey( + name: "FK_Conversation_OrganizationClient_OrganizationClientId", + schema: "messaging", + table: "Conversation", + column: "OrganizationClientId", + principalTable: "OrganizationClient", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Conversation_OrganizationClient_OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.DropIndex( + name: "IX_Conversation_OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.DropColumn( + name: "ExternalSenderName", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "ExternalSenderPhone", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "ExternalSenderType", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.AlterColumn( + name: "SenderId", + schema: "messaging", + table: "Message", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uniqueidentifier", + oldNullable: true); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 12703af..af6eb4a 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -213,6 +213,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("bit"); + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + b.Property("Title") .HasColumnType("nvarchar(max)"); @@ -224,6 +227,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrganizationClientId"); + b.ToTable("Conversation", "messaging"); }); @@ -1150,13 +1155,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("IsActive") .HasColumnType("bit"); b.Property("IsRead") .HasColumnType("bit"); - b.Property("SenderId") + b.Property("SenderId") .HasColumnType("uniqueidentifier"); b.Property("SentAt") @@ -1939,6 +1956,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Order"); }); + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => { b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") @@ -2157,8 +2184,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("JobFlow.Domain.Models.User", "Sender") .WithMany() .HasForeignKey("SenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.Restrict); b.Navigation("Conversation"); diff --git a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs index 148aaf3..832581e 100644 --- a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs +++ b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs @@ -15,32 +15,46 @@ public OpenMeteoWeatherService(IHttpClientFactory httpClientFactory) _httpClientFactory = httpClientFactory; } - public async Task GetForecastAsync(double latitude, double longitude, int days = 5, CancellationToken cancellationToken = default) + public async Task GetForecastAsync( + double latitude, double longitude, int days = 5, CancellationToken cancellationToken = default) { days = Math.Clamp(days, 1, 7); var url = $"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m,wind_speed_10m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days={days}"; + var client = _httpClientFactory.CreateClient("OpenMeteo"); - using var client = _httpClientFactory.CreateClient(); - using var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + try + { + using var response = await client.GetAsync(url, linkedCts.Token); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: linkedCts.Token); - var root = doc.RootElement; - var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; + var root = doc.RootElement; + var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; - var current = ParseCurrent(root.GetProperty("current")); - var daily = ParseDaily(root.GetProperty("daily")); + var current = ParseCurrent(root.GetProperty("current")); + var daily = ParseDaily(root.GetProperty("daily")); - return new WeatherForecastDto + return new WeatherForecastDto + { + Timezone = timezone, + Current = current, + Daily = daily, + RiskAlerts = BuildRiskAlerts(daily) + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - Timezone = timezone, - Current = current, - Daily = daily, - RiskAlerts = BuildRiskAlerts(daily) - }; + throw; // caller aborted request + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException("OpenMeteo request timed out."); + } } private static WeatherCurrentDto ParseCurrent(JsonElement current)