From 40d47bb903db0c640c9f758b136ee0462b8130c8 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 19 Mar 2026 09:00:34 -0400 Subject: [PATCH] feat(estimate): Add Estimate Revisions --- .../Controllers/ClientHubController.cs | 93 +- JobFlow.API/Hubs/ChatHub.cs | 1 + JobFlow.API/Hubs/NotifierHub.cs | 23 + JobFlow.API/Program.cs | 10 + JobFlow.API/appsettings.Development.json | 4 - JobFlow.API/appsettings.json | 4 - .../ModelErrors/EstimateRevisionErrors.cs | 46 + .../Models/DTOs/EstimateRevisionDtos.cs | 43 + .../Builders/INotificationMessageBuilder.cs | 1 + .../Builders/NotificationMessageBuilder.cs | 23 + .../Notifications/NotificationService.cs | 10 + .../Services/EstimateRevisionService.cs | 210 ++ .../IEstimateRevisionService.cs | 24 + .../ServiceInterfaces/INotificationService.cs | 1 + .../CreateEstimateRevisionRequestValidator.cs | 56 + .../Enums/EstimateRevisionStatus.cs | 10 + JobFlow.Domain/Enums/EstimateStatus.cs | 3 +- JobFlow.Domain/Models/Estimate.cs | 1 + .../Models/EstimateRevisionAttachment.cs | 12 + .../Models/EstimateRevisionRequest.cs | 21 + .../Configurations/EstimateConfiguration.cs | 5 + ...EstimateRevisionAttachmentConfiguration.cs | 19 + .../EstimateRevisionRequestConfiguration.cs | 30 + .../JobFlowDbContext.cs | 2 + ...5731_AddEstimateRevisionTables.Designer.cs | 2397 +++++++++++++++++ ...0260319025731_AddEstimateRevisionTables.cs | 108 + .../JobFlowDbContextModelSnapshot.cs | 155 ++ .../IReCAPTCHASettings.cs | 6 - .../ConfigurationModels/ReCAPTCHASettings.cs | 8 - .../ReCAPTCHA/ReCAPTCHAService.cs | 51 - .../Middleware/FirebaseAuthMiddleware.cs | 19 +- 31 files changed, 3318 insertions(+), 78 deletions(-) create mode 100644 JobFlow.API/Hubs/NotifierHub.cs create mode 100644 JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs create mode 100644 JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs create mode 100644 JobFlow.Business/Services/EstimateRevisionService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs create mode 100644 JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs create mode 100644 JobFlow.Domain/Enums/EstimateRevisionStatus.cs create mode 100644 JobFlow.Domain/Models/EstimateRevisionAttachment.cs create mode 100644 JobFlow.Domain/Models/EstimateRevisionRequest.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs delete mode 100644 JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs delete mode 100644 JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs delete mode 100644 JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 597bba3..7492c0c 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -1,9 +1,12 @@ using JobFlow.API.Extensions; +using JobFlow.API.Hubs; using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace JobFlow.API.Controllers; @@ -13,17 +16,23 @@ namespace JobFlow.API.Controllers; public class ClientHubController : ControllerBase { private readonly IEstimateService _estimates; + private readonly IEstimateRevisionService _estimateRevisions; private readonly IInvoiceService _invoices; private readonly IOrganizationClientService _clients; + private readonly IHubContext _hubContext; public ClientHubController( IEstimateService estimates, + IEstimateRevisionService estimateRevisions, IInvoiceService invoices, - IOrganizationClientService clients) + IOrganizationClientService clients, + IHubContext hubContext) { _estimates = estimates; + _estimateRevisions = estimateRevisions; _invoices = invoices; _clients = clients; + _hubContext = hubContext; } [HttpGet("me")] @@ -121,6 +130,84 @@ public async Task DeclineEstimate(Guid id) return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + [HttpPost("estimates/{id:guid}/revision-requests")] + [RequestSizeLimit(55_000_000)] + public async Task RequestEstimateRevision(Guid id, [FromForm] CreateEstimateRevisionFormRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var attachments = new List(); + + if (request.Attachments is not null) + { + foreach (var file in request.Attachments) + { + if (file.Length <= 0) + continue; + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream); + + attachments.Add(new EstimateRevisionAttachmentUpload( + file.FileName, + file.ContentType, + stream.ToArray(), + file.Length)); + } + } + + var result = await _estimateRevisions.CreateAsync( + id, + organizationId, + orgClientId, + new CreateEstimateRevisionRequest(request.Message ?? string.Empty, attachments)); + + if (!result.IsSuccess) + return result.ToProblemDetails(); + + await _hubContext.Clients.Group($"org:{organizationId}:dashboard") + .SendAsync("EstimateRevisionRequested", new + { + estimateId = id, + revisionRequestId = result.Value.Id, + revisionNumber = result.Value.RevisionNumber, + requestedAt = result.Value.RequestedAt, + message = result.Value.RequestMessage + }); + + return Results.Ok(result.Value); + } + + [HttpGet("estimates/{id:guid}/revision-requests")] + public async Task GetEstimateRevisionRequests(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimateRevisions.GetByEstimateAsync(id, organizationId, orgClientId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("estimates/{estimateId:guid}/revision-requests/{revisionRequestId:guid}/attachments/{attachmentId:guid}")] + public async Task DownloadEstimateRevisionAttachment(Guid estimateId, Guid revisionRequestId, Guid attachmentId) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimateRevisions.GetAttachmentAsync( + estimateId, + revisionRequestId, + attachmentId, + organizationId, + orgClientId); + + if (!result.IsSuccess) + return result.ToProblemDetails(); + + return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName); + } + [HttpGet("invoices")] public async Task GetMyInvoices() { @@ -140,3 +227,7 @@ public record UpdateOrganizationClientRequest( string? City, string? State, string? ZipCode); + +public record CreateEstimateRevisionFormRequest( + string? Message, + List? Attachments); diff --git a/JobFlow.API/Hubs/ChatHub.cs b/JobFlow.API/Hubs/ChatHub.cs index 7b31c34..303a653 100644 --- a/JobFlow.API/Hubs/ChatHub.cs +++ b/JobFlow.API/Hubs/ChatHub.cs @@ -25,4 +25,5 @@ public async Task LeaveConversation(Guid conversationId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString()); } + } \ No newline at end of file diff --git a/JobFlow.API/Hubs/NotifierHub.cs b/JobFlow.API/Hubs/NotifierHub.cs new file mode 100644 index 0000000..a8b404e --- /dev/null +++ b/JobFlow.API/Hubs/NotifierHub.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace JobFlow.API.Hubs; + +[Authorize] +public class NotifierHub : Hub +{ + public async Task JoinOrganizationDashboard() + { + var organizationId = Context.User?.FindFirst("organizationId")?.Value; + if (Guid.TryParse(organizationId, out var orgId)) + await Groups.AddToGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard"); + } + + public async Task LeaveOrganizationDashboard() + { + var organizationId = Context.User?.FindFirst("organizationId")?.Value; + if (Guid.TryParse(organizationId, out var orgId)) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard"); + } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 30e4a2b..6bef1be 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -147,6 +147,8 @@ o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); +builder.Services.AddValidatorsFromAssemblyContaining(); + // ============================================================ // SIGNALR // ============================================================ @@ -342,6 +344,13 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.Database.MigrateAsync(); +} + StripeConfiguration.ApiKey = builder.Configuration["StripeSettings-ApiKey"]; if (app.Environment.IsDevelopment()) @@ -367,5 +376,6 @@ app.MapControllers(); app.MapHub("/hubs/chat"); +app.MapHub("/hubs/notifier"); app.Run(); \ No newline at end of file diff --git a/JobFlow.API/appsettings.Development.json b/JobFlow.API/appsettings.Development.json index 86f227f..69f099b 100644 --- a/JobFlow.API/appsettings.Development.json +++ b/JobFlow.API/appsettings.Development.json @@ -18,9 +18,5 @@ }, "Backend": { "BaseUrl": "https://localhost:44398" - }, - "Turnstile": { - "SecretKey": "YOUR_TURNSTILE_SECRET", - "ExpectedHostname": "localhost" } } diff --git a/JobFlow.API/appsettings.json b/JobFlow.API/appsettings.json index a9c4094..b220748 100644 --- a/JobFlow.API/appsettings.json +++ b/JobFlow.API/appsettings.json @@ -7,15 +7,11 @@ }, "AllowedHosts": "*", "KeyVaultUri": "https://katharix-vault.vault.azure.net/", - "WebhookKey": "whsec_449239427e6f306629fdd3cf4a2d4e8157b1817c8ae85de887bd76380a12bf9a", "Frontend": { "BaseUrl": "https://gojobflow.com" }, "Backend": { "BaseUrl": "https://api.gojobflow.com" - }, - "Turnstile": { - "ExpectedHostname": "gojobflow.com" } } diff --git a/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs b/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs new file mode 100644 index 0000000..2c3371c --- /dev/null +++ b/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs @@ -0,0 +1,46 @@ +namespace JobFlow.Business.ModelErrors; + +public static class EstimateRevisionErrors +{ + public static readonly Error EstimateNotFound = + Error.NotFound("EstimateRevision.EstimateNotFound", "The estimate was not found."); + + public static readonly Error UnauthorizedEstimateAccess = + Error.NotFound("EstimateRevision.UnauthorizedEstimateAccess", "The estimate was not found."); + + public static readonly Error InvalidEstimateStatus = + Error.Conflict("EstimateRevision.InvalidEstimateStatus", "Revisions can only be requested for sent or accepted estimates."); + + public static readonly Error OpenRevisionAlreadyExists = + Error.Conflict("EstimateRevision.OpenRevisionAlreadyExists", "A revision request is already open for this estimate."); + + public static readonly Error MessageRequired = + Error.Validation("EstimateRevision.MessageRequired", "A revision message is required."); + + public static readonly Error MessageTooLong = + Error.Validation("EstimateRevision.MessageTooLong", "Revision message must be 2000 characters or less."); + + public static readonly Error TooManyAttachments = + Error.Validation("EstimateRevision.TooManyAttachments", "A maximum of 5 attachments are allowed per revision request."); + + public static readonly Error AttachmentTooLarge = + Error.Validation("EstimateRevision.AttachmentTooLarge", "Each attachment must be 10 MB or less."); + + public static readonly Error InvalidAttachmentContentType = + Error.Validation("EstimateRevision.InvalidAttachmentContentType", "One or more attachments use an unsupported file type."); + + public static readonly Error InvalidAttachment = + Error.Validation("EstimateRevision.InvalidAttachment", "One or more attachments are invalid."); + + public static readonly Error RevisionRequestNotFound = + Error.NotFound("EstimateRevision.RevisionRequestNotFound", "Revision request was not found."); + + public static readonly Error AttachmentNotFound = + Error.NotFound("EstimateRevision.AttachmentNotFound", "Attachment was not found."); + + public static readonly Error OrganizationNotFound = + Error.NotFound("EstimateRevision.OrganizationNotFound", "Organization was not found."); + + public static readonly Error ClientNotFound = + Error.NotFound("EstimateRevision.ClientNotFound", "Organization client was not found."); +} diff --git a/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs b/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs new file mode 100644 index 0000000..37da905 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs @@ -0,0 +1,43 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public sealed record EstimateRevisionAttachmentUpload( + string FileName, + string ContentType, + byte[] Content, + long SizeBytes +); + +public sealed record CreateEstimateRevisionRequest( + string Message, + IReadOnlyList Attachments +); + +public sealed record EstimateRevisionAttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes +); + +public sealed record EstimateRevisionRequestDto( + Guid Id, + Guid EstimateId, + Guid OrganizationId, + Guid OrganizationClientId, + int RevisionNumber, + EstimateRevisionStatus Status, + string RequestMessage, + string? OrganizationResponseMessage, + DateTimeOffset RequestedAt, + DateTimeOffset? ReviewedAt, + DateTimeOffset? ResolvedAt, + IReadOnlyList Attachments +); + +public sealed record EstimateRevisionAttachmentDownloadDto( + string FileName, + string ContentType, + byte[] Content +); diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index 2040211..f44ec29 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -21,6 +21,7 @@ public interface INotificationMessageBuilder NotificationMessage BuildEmployeeInvite(EmployeeInvite invite); NotificationMessage BuildClientEstimateSent(OrganizationClient client, Estimate estimate); + NotificationMessage BuildOrganizationEstimateRevisionRequested(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink); } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 303f413..1eb3ae6 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -204,6 +204,29 @@ Your estimate is ready. }; } + public NotificationMessage BuildOrganizationEstimateRevisionRequested( + Organization organization, + OrganizationClient client, + Estimate estimate, + string revisionMessage) + { + return new NotificationMessage + { + Name = organization.OrganizationName, + Email = organization.EmailAddress, + Phone = organization.PhoneNumber, + Subject = $"Estimate Revision Requested: {estimate.EstimateNumber}", + Body = $""" + Client {client.ClientFullName()} requested estimate revisions. + + Estimate: {estimate.EstimateNumber} + Message: {revisionMessage} + """, + Sms = $"Estimate revision requested for {estimate.EstimateNumber}.", + TemplateId = EmailTemplate.Default + }; + } + public NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink) { return new NotificationMessage diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 9bf56a8..6312bf7 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -109,6 +109,16 @@ public async Task SendOrganizationSubscriptionPaymentFailedNotificationAsync(Org await SendNotificationAsync(message); } + public async Task SendOrganizationEstimateRevisionRequestedNotificationAsync( + Organization organization, + OrganizationClient client, + Estimate estimate, + string revisionMessage) + { + var message = _builder.BuildOrganizationEstimateRevisionRequested(organization, client, estimate, revisionMessage); + await SendNotificationAsync(message); + } + /// /// Shared helper for sending email and SMS. /// diff --git a/JobFlow.Business/Services/EstimateRevisionService.cs b/JobFlow.Business/Services/EstimateRevisionService.cs new file mode 100644 index 0000000..46cc904 --- /dev/null +++ b/JobFlow.Business/Services/EstimateRevisionService.cs @@ -0,0 +1,210 @@ +using FluentValidation; +using JobFlow.Business.DI; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class EstimateRevisionService : IEstimateRevisionService +{ + private static readonly EstimateRevisionStatus[] OpenStatuses = + [ + EstimateRevisionStatus.Requested, + EstimateRevisionStatus.InReview + ]; + + private readonly IUnitOfWork _unitOfWork; + private readonly INotificationService _notificationService; + private readonly IValidator _validator; + private readonly IRepository _estimates; + private readonly IRepository _revisionRequests; + private readonly IRepository _attachments; + private readonly IRepository _organizations; + private readonly IRepository _clients; + + public EstimateRevisionService( + IUnitOfWork unitOfWork, + INotificationService notificationService, + IValidator validator) + { + _unitOfWork = unitOfWork; + _notificationService = notificationService; + _validator = validator; + + _estimates = unitOfWork.RepositoryOf(); + _revisionRequests = unitOfWork.RepositoryOf(); + _attachments = unitOfWork.RepositoryOf(); + _organizations = unitOfWork.RepositoryOf(); + _clients = unitOfWork.RepositoryOf(); + } + + public async Task> CreateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId, + CreateEstimateRevisionRequest request) + { + var validationResult = await _validator.ValidateAsync(request); + if (!validationResult.IsValid) + return Result.Failure( + Error.Validation("EstimateRevision.ValidationFailed", string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)))); + + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + if (estimate.Status is not (EstimateStatus.Sent or EstimateStatus.Accepted)) + return Result.Failure(EstimateRevisionErrors.InvalidEstimateStatus); + + var hasOpenRevision = await _revisionRequests.Query() + .AnyAsync(x => x.EstimateId == estimateId && OpenStatuses.Contains(x.Status)); + + if (hasOpenRevision) + return Result.Failure(EstimateRevisionErrors.OpenRevisionAlreadyExists); + + var nextRevisionNumber = (await _revisionRequests.Query() + .Where(x => x.EstimateId == estimateId) + .Select(x => (int?)x.RevisionNumber) + .MaxAsync() ?? 0) + 1; + + var revisionRequest = new EstimateRevisionRequest + { + EstimateId = estimateId, + OrganizationId = organizationId, + OrganizationClientId = organizationClientId, + RevisionNumber = nextRevisionNumber, + Status = EstimateRevisionStatus.Requested, + RequestMessage = request.Message.Trim(), + RequestedAt = DateTimeOffset.UtcNow + }; + + if (request.Attachments.Count > 0) + { + foreach (var attachment in request.Attachments) + { + revisionRequest.Attachments.Add(new EstimateRevisionAttachment + { + FileName = attachment.FileName, + ContentType = attachment.ContentType, + FileSizeBytes = attachment.SizeBytes, + FileData = attachment.Content + }); + } + } + + await _revisionRequests.AddAsync(revisionRequest); + + estimate.Status = EstimateStatus.RevisionRequested; + estimate.UpdatedAt = DateTimeOffset.UtcNow; + _estimates.Update(estimate); + + await _unitOfWork.SaveChangesAsync(); + + var organization = await _organizations.Query() + .FirstOrDefaultAsync(x => x.Id == organizationId); + if (organization is null) + return Result.Failure(EstimateRevisionErrors.OrganizationNotFound); + + var client = await _clients.Query() + .FirstOrDefaultAsync(x => x.Id == organizationClientId && x.OrganizationId == organizationId); + if (client is null) + return Result.Failure(EstimateRevisionErrors.ClientNotFound); + + await _notificationService.SendOrganizationEstimateRevisionRequestedNotificationAsync( + organization, + client, + estimate, + revisionRequest.RequestMessage); + + return Result.Success(ToDto(revisionRequest)); + } + + public async Task>> GetByEstimateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId) + { + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure>(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure>(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + var revisions = await _revisionRequests.Query() + .Where(x => x.EstimateId == estimateId) + .Include(x => x.Attachments) + .OrderByDescending(x => x.RequestedAt) + .ToListAsync(); + + return Result.Success>(revisions.Select(ToDto).ToList()); + } + + public async Task> GetAttachmentAsync( + Guid estimateId, + Guid revisionRequestId, + Guid attachmentId, + Guid organizationId, + Guid organizationClientId) + { + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + var revision = await _revisionRequests.Query() + .FirstOrDefaultAsync(x => x.Id == revisionRequestId && x.EstimateId == estimateId); + + if (revision is null) + return Result.Failure(EstimateRevisionErrors.RevisionRequestNotFound); + + var attachment = await _attachments.Query() + .FirstOrDefaultAsync(x => x.Id == attachmentId && x.EstimateRevisionRequestId == revisionRequestId); + + if (attachment is null) + return Result.Failure(EstimateRevisionErrors.AttachmentNotFound); + + return Result.Success(new EstimateRevisionAttachmentDownloadDto( + attachment.FileName, + attachment.ContentType, + attachment.FileData)); + } + + private static EstimateRevisionRequestDto ToDto(EstimateRevisionRequest request) + { + return new EstimateRevisionRequestDto( + request.Id, + request.EstimateId, + request.OrganizationId, + request.OrganizationClientId, + request.RevisionNumber, + request.Status, + request.RequestMessage, + request.OrganizationResponseMessage, + request.RequestedAt, + request.ReviewedAt, + request.ResolvedAt, + request.Attachments.Select(x => new EstimateRevisionAttachmentDto( + x.Id, + x.FileName, + x.ContentType, + x.FileSizeBytes)).ToList()); + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs new file mode 100644 index 0000000..3be714a --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs @@ -0,0 +1,24 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IEstimateRevisionService +{ + Task> CreateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId, + CreateEstimateRevisionRequest request); + + Task>> GetByEstimateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId); + + Task> GetAttachmentAsync( + Guid estimateId, + Guid revisionRequestId, + Guid attachmentId, + Guid organizationId, + Guid organizationClientId); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 7256143..4fa9a3a 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -18,6 +18,7 @@ public interface INotificationService Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job); Task SendClientEstimateSentNotificationAsync(OrganizationClient client, Estimate estimate); + Task SendOrganizationEstimateRevisionRequestedNotificationAsync(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink); // Employee notifications diff --git a/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs b/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs new file mode 100644 index 0000000..f85e0b4 --- /dev/null +++ b/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs @@ -0,0 +1,56 @@ +using FluentValidation; +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Validators; + +public class CreateEstimateRevisionRequestValidator : AbstractValidator +{ + private static readonly HashSet AllowedContentTypes = + [ + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain" + ]; + + public CreateEstimateRevisionRequestValidator() + { + RuleFor(x => x.Message) + .NotEmpty() + .MaximumLength(2000); + + RuleFor(x => x.Attachments) + .NotNull(); + + RuleFor(x => x.Attachments.Count) + .LessThanOrEqualTo(5); + + RuleForEach(x => x.Attachments).ChildRules(attachment => + { + attachment.RuleFor(x => x.FileName) + .NotEmpty() + .MaximumLength(260); + + attachment.RuleFor(x => x.ContentType) + .NotEmpty() + .Must(contentType => AllowedContentTypes.Contains(contentType)) + .WithMessage("Unsupported attachment content type."); + + attachment.RuleFor(x => x.Content) + .NotNull() + .Must(content => content.Length > 0) + .WithMessage("Attachment content is required."); + + attachment.RuleFor(x => x.SizeBytes) + .GreaterThan(0) + .LessThanOrEqualTo(10 * 1024 * 1024); + + attachment.RuleFor(x => x) + .Must(x => x.Content.Length == x.SizeBytes) + .WithMessage("Attachment size does not match payload size."); + }); + } +} diff --git a/JobFlow.Domain/Enums/EstimateRevisionStatus.cs b/JobFlow.Domain/Enums/EstimateRevisionStatus.cs new file mode 100644 index 0000000..bc9df98 --- /dev/null +++ b/JobFlow.Domain/Enums/EstimateRevisionStatus.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Enums; + +public enum EstimateRevisionStatus +{ + Requested = 0, + InReview = 1, + Resolved = 2, + Rejected = 3, + Cancelled = 4 +} diff --git a/JobFlow.Domain/Enums/EstimateStatus.cs b/JobFlow.Domain/Enums/EstimateStatus.cs index 62cfd69..88b511c 100644 --- a/JobFlow.Domain/Enums/EstimateStatus.cs +++ b/JobFlow.Domain/Enums/EstimateStatus.cs @@ -7,5 +7,6 @@ public enum EstimateStatus Accepted = 2, Declined = 3, Cancelled = 4, - Expired = 5 + Expired = 5, + RevisionRequested = 6 } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Estimate.cs b/JobFlow.Domain/Models/Estimate.cs index f12f7e7..39f9de1 100644 --- a/JobFlow.Domain/Models/Estimate.cs +++ b/JobFlow.Domain/Models/Estimate.cs @@ -29,4 +29,5 @@ public class Estimate : Entity public OrganizationClient? OrganizationClient { get; set; } public ICollection LineItems { get; set; } = new List(); + public ICollection RevisionRequests { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EstimateRevisionAttachment.cs b/JobFlow.Domain/Models/EstimateRevisionAttachment.cs new file mode 100644 index 0000000..a35b06d --- /dev/null +++ b/JobFlow.Domain/Models/EstimateRevisionAttachment.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Domain.Models; + +public class EstimateRevisionAttachment : Entity +{ + public Guid EstimateRevisionRequestId { get; set; } + public string FileName { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public byte[] FileData { get; set; } = Array.Empty(); + + public EstimateRevisionRequest RevisionRequest { get; set; } = null!; +} diff --git a/JobFlow.Domain/Models/EstimateRevisionRequest.cs b/JobFlow.Domain/Models/EstimateRevisionRequest.cs new file mode 100644 index 0000000..6d485f5 --- /dev/null +++ b/JobFlow.Domain/Models/EstimateRevisionRequest.cs @@ -0,0 +1,21 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class EstimateRevisionRequest : Entity +{ + public Guid EstimateId { get; set; } + public Guid OrganizationId { get; set; } + public Guid OrganizationClientId { get; set; } + public int RevisionNumber { get; set; } + public EstimateRevisionStatus Status { get; set; } = EstimateRevisionStatus.Requested; + public string RequestMessage { get; set; } = string.Empty; + public string? OrganizationResponseMessage { get; set; } + public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ReviewedAt { get; set; } + public DateTimeOffset? ResolvedAt { get; set; } + + public Estimate Estimate { get; set; } = null!; + public OrganizationClient OrganizationClient { get; set; } = null!; + public ICollection Attachments { get; set; } = new List(); +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs index e126ce5..5548924 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs @@ -25,5 +25,10 @@ public void Configure(EntityTypeBuilder builder) .WithOne(x => x.Estimate!) .HasForeignKey(x => x.EstimateId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.RevisionRequests) + .WithOne(x => x.Estimate) + .HasForeignKey(x => x.EstimateId) + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs new file mode 100644 index 0000000..5d70572 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs @@ -0,0 +1,19 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class EstimateRevisionAttachmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EstimateRevisionAttachments"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.FileName).HasMaxLength(260).IsRequired(); + builder.Property(x => x.ContentType).HasMaxLength(200).IsRequired(); + builder.Property(x => x.FileSizeBytes).IsRequired(); + builder.Property(x => x.FileData).HasColumnType("varbinary(max)").IsRequired(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs new file mode 100644 index 0000000..a48ba2a --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs @@ -0,0 +1,30 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class EstimateRevisionRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EstimateRevisionRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RevisionNumber).IsRequired(); + builder.Property(x => x.RequestMessage).HasMaxLength(4000).IsRequired(); + builder.Property(x => x.OrganizationResponseMessage).HasMaxLength(4000); + + builder.HasIndex(x => new { x.EstimateId, x.RevisionNumber }).IsUnique(); + + builder.HasOne(x => x.OrganizationClient) + .WithMany() + .HasForeignKey(x => x.OrganizationClientId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(x => x.Attachments) + .WithOne(x => x.RevisionRequest) + .HasForeignKey(x => x.EstimateRevisionRequestId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 91b2988..4e85da1 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -13,6 +13,8 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet Estimates { get; set; } public DbSet EstimateLineItems { get; set; } + public DbSet EstimateRevisionRequests { get; set; } + public DbSet EstimateRevisionAttachments { get; set; } public DbSet InvoiceSequences { get; set; } public DbSet Organizations { get; set; } public DbSet OrganizationTypes { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs new file mode 100644 index 0000000..b377fc0 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs @@ -0,0 +1,2397 @@ +// +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("20260319025731_AddEstimateRevisionTables")] + partial class AddEstimateRevisionTables + { + /// + 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("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + 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("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.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) + .IsRequired(); + + 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/20260319025731_AddEstimateRevisionTables.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs new file mode 100644 index 0000000..012e291 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEstimateRevisionTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EstimateRevisionRequests", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EstimateId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationClientId = table.Column(type: "uniqueidentifier", nullable: false), + RevisionNumber = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + RequestMessage = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: false), + OrganizationResponseMessage = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + RequestedAt = table.Column(type: "datetimeoffset", nullable: false), + ReviewedAt = table.Column(type: "datetimeoffset", nullable: true), + ResolvedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EstimateRevisionRequests", x => x.Id); + table.ForeignKey( + name: "FK_EstimateRevisionRequests_Estimates_EstimateId", + column: x => x.EstimateId, + principalTable: "Estimates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_EstimateRevisionRequests_OrganizationClient_OrganizationClientId", + column: x => x.OrganizationClientId, + principalTable: "OrganizationClient", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "EstimateRevisionAttachments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EstimateRevisionRequestId = table.Column(type: "uniqueidentifier", nullable: false), + FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + ContentType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + FileData = table.Column(type: "varbinary(max)", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EstimateRevisionAttachments", x => x.Id); + table.ForeignKey( + name: "FK_EstimateRevisionAttachments_EstimateRevisionRequests_EstimateRevisionRequestId", + column: x => x.EstimateRevisionRequestId, + principalTable: "EstimateRevisionRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionAttachments_EstimateRevisionRequestId", + table: "EstimateRevisionAttachments", + column: "EstimateRevisionRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionRequests_EstimateId_RevisionNumber", + table: "EstimateRevisionRequests", + columns: new[] { "EstimateId", "RevisionNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionRequests_OrganizationClientId", + table: "EstimateRevisionRequests", + column: "OrganizationClientId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EstimateRevisionAttachments"); + + migrationBuilder.DropTable( + name: "EstimateRevisionRequests"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 86b31eb..12703af 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -633,6 +633,124 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1929,6 +2047,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -2176,6 +2324,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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 => diff --git a/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs b/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs deleted file mode 100644 index 6cabe0f..0000000 --- a/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; - -public interface IReCAPTCHASettings -{ - string SecretKey { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs b/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs deleted file mode 100644 index 9abe6ba..0000000 --- a/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs +++ /dev/null @@ -1,8 +0,0 @@ -using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; - -namespace JobFlow.Infrastructure.ExternalServices.ConfigurationModels; - -public class ReCAPTCHASettings : IReCAPTCHASettings -{ - public string SecretKey { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs b/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs deleted file mode 100644 index 949ea15..0000000 --- a/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using JobFlow.Business.DI; -using JobFlow.Infrastructure.ExternalServices.ConfigurationModels; -using Microsoft.Extensions.Options; - -namespace JobFlow.Infrastructure.ExternalServices.ReCAPTCHA; - -public interface IReCAPTCHAService -{ - Task VerifyTokenAsync(string token); -} - -[ScopedService] -public class ReCAPTCHAService : IReCAPTCHAService -{ - private readonly HttpClient _httpClient; - private readonly ReCAPTCHASettings _settings; - - public ReCAPTCHAService(IOptions settings, IHttpClientFactory httpClientFactory) - { - _settings = settings.Value; - _httpClient = httpClientFactory.CreateClient(); - } - - public async Task VerifyTokenAsync(string token) - { - var values = new Dictionary - { - { "secret", _settings.SecretKey }, - { "response", token } - }; - - var content = new FormUrlEncodedContent(values); - - var response = await _httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content); - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json); - - return result?.Success ?? false; - } -} - -public class RecaptchaResponse -{ - [JsonPropertyName("success")] public bool Success { get; set; } - - [JsonPropertyName("score")] public float Score { get; set; } - - [JsonPropertyName("action")] public string? Action { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index c4de7d2..31af277 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -38,15 +38,28 @@ public async Task Invoke(HttpContext context, IUserService userService) return; } + string? token = null; + var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) + if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ")) + { + token = authHeader.Substring("Bearer ".Length); + } + + if (string.IsNullOrWhiteSpace(token) + && path is not null + && path.StartsWith("/hubs/") + && context.Request.Query.TryGetValue("access_token", out var accessToken)) + { + token = accessToken.FirstOrDefault(); + } + + if (string.IsNullOrWhiteSpace(token)) { await _next(context); return; } - var token = authHeader.Substring("Bearer ".Length); - // If this is a locally-issued Client Portal JWT, do not attempt Firebase verification. // The JwtBearer handler for the ClientPortalJwt scheme will validate and populate claims. try