diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 9e9e08a..762fa3c 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -1,4 +1,5 @@ using JobFlow.API.Extensions; +using JobFlow.API.Mappings; using JobFlow.API.Hubs; using JobFlow.API.Models; using JobFlow.Business.Extensions; @@ -19,6 +20,7 @@ namespace JobFlow.API.Controllers; [Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")] public class ClientHubController : ControllerBase { + private readonly ILogger _logger; private readonly IEstimateService _estimates; private readonly IEstimateRevisionService _estimateRevisions; private readonly IInvoiceService _invoices; @@ -29,6 +31,7 @@ public class ClientHubController : ControllerBase private readonly IUnitOfWork _unitOfWork; public ClientHubController( + ILogger logger, IEstimateService estimates, IEstimateRevisionService estimateRevisions, IInvoiceService invoices, @@ -38,6 +41,7 @@ public ClientHubController( IHubContext clientChatHubContext, IUnitOfWork unitOfWork) { + _logger = logger; _estimates = estimates; _estimateRevisions = estimateRevisions; _invoices = invoices; @@ -413,7 +417,46 @@ public async Task GetMyInvoices() { var orgClientId = HttpContext.GetUserId(); var result = await _invoices.GetInvoicesByClientAsync(orgClientId); - return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + return result.IsSuccess ? Results.Ok(result.Value.ToDto()) : result.ToProblemDetails(); + } + + [HttpGet("invoices/{id:guid}")] + public async Task GetMyInvoiceById(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + _logger.LogInformation( + "ClientHub invoice detail requested. InvoiceId={InvoiceId} OrgId={OrgId} ClientId={ClientId}", + id, + organizationId, + orgClientId); + + var result = await _invoices.GetInvoiceByIdAsync(id); + if (!result.IsSuccess) + { + _logger.LogWarning( + "ClientHub invoice not found. InvoiceId={InvoiceId} OrgId={OrgId} ClientId={ClientId}", + id, + organizationId, + orgClientId); + return result.ToProblemDetails(); + } + + var invoice = result.Value; + if (invoice.OrganizationId != organizationId || invoice.OrganizationClientId != orgClientId) + { + _logger.LogWarning( + "ClientHub invoice mismatch. InvoiceId={InvoiceId} InvoiceOrgId={InvoiceOrgId} InvoiceClientId={InvoiceClientId} OrgId={OrgId} ClientId={ClientId}", + id, + invoice.OrganizationId, + invoice.OrganizationClientId, + organizationId, + orgClientId); + return Results.NotFound(); + } + + return Results.Ok(invoice.ToDto()); } private async Task FindOrCreateClientConversationAsync(Guid orgClientId, Guid organizationId) diff --git a/JobFlow.API/Controllers/InvoiceComtroller.cs b/JobFlow.API/Controllers/InvoiceComtroller.cs index 4a770dd..9b2f06e 100644 --- a/JobFlow.API/Controllers/InvoiceComtroller.cs +++ b/JobFlow.API/Controllers/InvoiceComtroller.cs @@ -15,6 +15,7 @@ public class InvoiceController : ControllerBase private readonly IInvoiceLineItemService lineItemService; private readonly IJobService _jobService; private readonly INotificationService notificationService; + private readonly IOrganizationClientPortalService _clientPortal; private readonly IInvoiceNumberGenerator numberGenerator; private readonly IPdfGenerator pdfGenerator; private readonly IMapper _mapper; @@ -25,6 +26,7 @@ public InvoiceController( IInvoiceNumberGenerator numberGenerator, IPdfGenerator pdfGenerator, INotificationService notificationService, + IOrganizationClientPortalService clientPortal, IJobService jobService, IMapper mapper ) @@ -34,6 +36,7 @@ IMapper mapper this.numberGenerator = numberGenerator; this.pdfGenerator = pdfGenerator; this.notificationService = notificationService; + _clientPortal = clientPortal; this._jobService = jobService; this._mapper = mapper; } @@ -79,9 +82,16 @@ public async Task Upsert( var result = await invoiceService.UpsertInvoiceAsync(invoice); - return result.IsSuccess - ? Ok(result.Value.ToDto()) - : BadRequest(result.Error); + if (!result.IsSuccess) + return BadRequest(result.Error); + + var hydratedInvoice = await invoiceService.GetInvoiceByIdAsync(result.Value.Id); + if (hydratedInvoice.IsSuccess) + { + await invoiceService.SendInvoiceToClientAsync(hydratedInvoice.Value.Id); + } + + return Ok(result.Value.ToDto()); } [HttpPost("organization")] @@ -96,6 +106,18 @@ public Task UpsertForOrganization([FromBody] CreateInvoiceRequest [HttpPost("{id:guid}/send")] public async Task SendInvoice(Guid id) + { + var result = await invoiceService.GetInvoiceByIdAsync(id); + if (!result.IsSuccess) + return NotFound(result.Error); + + await invoiceService.SendInvoiceToClientAsync(result.Value.Id); + + return Ok(); + } + + [HttpPost("{id:guid}/remind")] + public async Task SendInvoiceReminder(Guid id) { var result = await invoiceService.GetInvoiceByIdAsync(id); if (!result.IsSuccess) @@ -103,13 +125,29 @@ public async Task SendInvoice(Guid id) var invoice = result.Value; - await notificationService.SendClientInvoiceCreatedNotificationAsync( + string? linkOverride = null; + var email = invoice.OrganizationClient?.EmailAddress; + if (!string.IsNullOrWhiteSpace(email)) + { + var returnUrl = $"/client-hub/invoices/{invoice.Id}"; + var linkResult = await _clientPortal.CreateMagicLinkAsync( + invoice.OrganizationId, + invoice.OrganizationClientId, + email, + returnUrl); + + if (linkResult.IsSuccess) + { + linkOverride = linkResult.Value; + } + } + + await notificationService.SendClientInvoiceReminderNotificationAsync( invoice.OrganizationClient, - invoice + invoice, + linkOverride ); - await invoiceService.MarkInvoiceSentAsync(invoice.Id); - return Ok(); } @@ -133,7 +171,7 @@ public async Task GeneratePdf(Guid id) //46455c4d-58c0-49ef-b18a-84704dbd50aa var pdf = await pdfGenerator.GenerateInvoicePdfAsync(result.Value); var invoice = result.Value; - await notificationService.SendClientInvoiceCreatedNotificationAsync(invoice.OrganizationClient, invoice); + await invoiceService.SendInvoiceToClientAsync(invoice.Id); var pdfName = $"{invoice.OrganizationClient.Organization.OrganizationName}-Invoice-{invoice.InvoiceNumber}.pdf"; return File(pdf, "application/pdf", $"{pdfName}"); } diff --git a/JobFlow.API/Controllers/InvoicingSettingsController.cs b/JobFlow.API/Controllers/InvoicingSettingsController.cs new file mode 100644 index 0000000..9faf64d --- /dev/null +++ b/JobFlow.API/Controllers/InvoicingSettingsController.cs @@ -0,0 +1,42 @@ +using JobFlow.API.Extensions; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/invoicing-settings")] +public class InvoicingSettingsController : ControllerBase +{ + private readonly IInvoicingSettingsService _settings; + + public InvoicingSettingsController(IInvoicingSettingsService settings) + { + _settings = settings; + } + + [HttpGet] + public async Task Get() + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _settings.GetInvoicingSettingsAsync(organizationId); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + [HttpPut] + public async Task Update([FromBody] InvoicingSettingsUpsertRequestDto dto) + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _settings.UpsertInvoicingSettingsAsync(organizationId, dto); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } +} \ No newline at end of file diff --git a/JobFlow.API/Controllers/OnboardingController.cs b/JobFlow.API/Controllers/OnboardingController.cs index cf13c12..64d9cce 100644 --- a/JobFlow.API/Controllers/OnboardingController.cs +++ b/JobFlow.API/Controllers/OnboardingController.cs @@ -1,5 +1,8 @@ -using JobFlow.Business.Extensions; +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; @@ -9,10 +12,12 @@ namespace JobFlow.API.Controllers; public class OnboardingController : ControllerBase { private readonly IOnboardingService onboarding; + private readonly IOrganizationService organizations; - public OnboardingController(IOnboardingService onboarding) + public OnboardingController(IOnboardingService onboarding, IOrganizationService organizations) { this.onboarding = onboarding; + this.organizations = organizations; } [HttpGet("{organizationId:guid}")] @@ -23,4 +28,80 @@ public async Task Get(Guid organizationId) ? Results.Ok(result.Value) : result.ToProblemDetails(); } + + [HttpGet("quick-start")] + public async Task GetQuickStartState() + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await onboarding.GetQuickStartStateAsync(organizationId); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.ToProblemDetails(); + } + + [HttpPost("quick-start")] + public async Task ApplyQuickStart([FromBody] OnboardingQuickStartApplyRequestDto request) + { + var organizationId = HttpContext.GetOrganizationId(); + + var orgResult = await organizations.GetOrganizationDtoById(organizationId); + if (orgResult.IsFailure) + { + return orgResult.ToProblemDetails(); + } + + if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow")) + { + return Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "Subscription Required", + detail: "A Flow plan is required to apply quick-start presets."); + } + + var result = await onboarding.ApplyQuickStartAsync(organizationId, request); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.ToProblemDetails(); + } + + [HttpPost("complete")] + public async Task CompleteOnboarding() + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await onboarding.MarkOrganizationCompleteIfEligibleAsync(organizationId); + if (!result.IsSuccess) + return result.ToProblemDetails(); + + if (!result.Value) + { + return Results.Conflict(new + { + completed = false, + message = "Onboarding checklist is not complete yet." + }); + } + + var orgResult = await organizations.GetOrganizationDtoById(organizationId); + return orgResult.IsSuccess + ? Results.Ok(orgResult.Value) + : orgResult.ToProblemDetails(); + } + + private static bool HasMinPlan(string? planName, string required) + { + static int Rank(string? plan) + { + var value = (plan ?? string.Empty).Trim().ToLowerInvariant(); + return value switch + { + "go" => 0, + "flow" => 1, + "max" => 2, + _ => -1 + }; + } + + return Rank(planName) >= Rank(required); + } } \ No newline at end of file diff --git a/JobFlow.API/Controllers/OrganizationClientController.cs b/JobFlow.API/Controllers/OrganizationClientController.cs index 4dfcd79..6c409be 100644 --- a/JobFlow.API/Controllers/OrganizationClientController.cs +++ b/JobFlow.API/Controllers/OrganizationClientController.cs @@ -97,8 +97,14 @@ public async Task SendClientHubLink(Guid organizationClientId) if (string.IsNullOrWhiteSpace(clientResult.Value.EmailAddress)) return Results.BadRequest("Client email address is required."); - var result = await _clientPortal.SendMagicLinkAsync(organizationId, organizationClientId, clientResult.Value.EmailAddress); - return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); + var result = await _clientPortal.SendMagicLinkWithUrlAsync( + organizationId, + organizationClientId, + clientResult.Value.EmailAddress); + + return result.IsSuccess + ? Results.Ok(new { magicLink = result.Value }) + : result.ToProblemDetails(); } [HttpPost] diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index 16fcc80..c1c5e6d 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -73,6 +73,36 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque var org = await _organizationService.GetOrganiztionById(orgId); if (org.IsFailure) return NotFound("Organization not found."); + if (request.InvoiceId.HasValue) + { + var invoiceResult = await _invoiceService.GetInvoiceByIdAsync(request.InvoiceId.Value); + if (!invoiceResult.IsSuccess) + return NotFound("Invoice not found."); + + var invoice = invoiceResult.Value; + if (invoice.OrganizationId != orgId) + return Unauthorized(); + + if (User.IsInRole(UserRoles.OrganizationClient)) + { + var clientId = HttpContext.GetUserId(); + if (invoice.OrganizationClientId != clientId) + return Unauthorized(); + } + + if (!request.Amount.HasValue) + { + request.Amount = invoice.BalanceDue; + } + + if (request.Amount <= 0) + return BadRequest("Payment amount is required."); + + request.OrganizationId = invoice.OrganizationId; + request.OrganizationClientId = invoice.OrganizationClientId; + request.ProductName ??= $"Invoice {invoice.InvoiceNumber}"; + } + var processor = _processorFactory.GetProcessor(org.Value.PaymentProvider.ToString()); string checkoutUrl; diff --git a/JobFlow.API/Hubs/ClientPortalHub.cs b/JobFlow.API/Hubs/ClientPortalHub.cs new file mode 100644 index 0000000..f298c7d --- /dev/null +++ b/JobFlow.API/Hubs/ClientPortalHub.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace JobFlow.API.Hubs; + +[Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")] +public class ClientPortalHub : Hub +{ + public override async Task OnConnectedAsync() + { + var clientId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier); + if (Guid.TryParse(clientId, out var orgClientId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"client:{orgClientId}"); + } + + await base.OnConnectedAsync(); + } +} diff --git a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs index 1272436..f9fd586 100644 --- a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs +++ b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs @@ -1,4 +1,5 @@ using JobFlow.API.Models; +using JobFlow.Business.Models.DTOs; using JobFlow.Domain.Models; namespace JobFlow.API.Mappings; @@ -12,6 +13,7 @@ public static Invoice ToInvoice(this CreateInvoiceRequest request, string invoic Id = Guid.NewGuid(), OrganizationId = request.OrganizationId, OrganizationClientId = request.OrganizationClientId.Value, + JobId = request.JobId, InvoiceNumber = invoiceNumber, InvoiceDate = DateTime.UtcNow, DueDate = request.DueDate, @@ -38,6 +40,7 @@ public static InvoiceDto ToDto(this Invoice invoice) InvoiceNumber = invoice.InvoiceNumber, OrganizationId = invoice.OrganizationId, OrganizationClientId = invoice.OrganizationClientId, + JobId = invoice.JobId, OrderId = invoice.OrderId, InvoiceDate = invoice.InvoiceDate, DueDate = invoice.DueDate, @@ -45,8 +48,32 @@ public static InvoiceDto ToDto(this Invoice invoice) AmountPaid = invoice.AmountPaid, BalanceDue = invoice.BalanceDue, Status = invoice.Status, + PaymentProvider = invoice.PaymentProvider, ExternalPaymentId = invoice.ExternalPaymentId, - + PaidAt = invoice.PaidAt, + OrganizationClient = invoice.OrganizationClient == null + ? new OrganizationClientDto { Id = invoice.OrganizationClientId } + : new OrganizationClientDto + { + Id = invoice.OrganizationClient.Id, + OrganizationId = invoice.OrganizationClient.OrganizationId, + FirstName = invoice.OrganizationClient.FirstName, + LastName = invoice.OrganizationClient.LastName, + EmailAddress = invoice.OrganizationClient.EmailAddress, + PhoneNumber = invoice.OrganizationClient.PhoneNumber, + Address1 = invoice.OrganizationClient.Address1, + Address2 = invoice.OrganizationClient.Address2, + City = invoice.OrganizationClient.City, + State = invoice.OrganizationClient.State, + ZipCode = invoice.OrganizationClient.ZipCode, + Organization = invoice.OrganizationClient.Organization == null + ? null + : new OrganizationDto + { + Id = invoice.OrganizationClient.Organization.Id, + OrganizationName = invoice.OrganizationClient.Organization.OrganizationName + } + }, LineItems = invoice.LineItems.Select(li => li.ToDto()).ToList() }; } diff --git a/JobFlow.API/Models/InvoiceDto.cs b/JobFlow.API/Models/InvoiceDto.cs index bd119e5..504efc5 100644 --- a/JobFlow.API/Models/InvoiceDto.cs +++ b/JobFlow.API/Models/InvoiceDto.cs @@ -9,6 +9,7 @@ public class InvoiceDto public string InvoiceNumber { get; set; } public Guid OrganizationId { get; set; } public Guid OrganizationClientId { get; set; } + public Guid? JobId { get; set; } public Guid? OrderId { get; set; } public DateTime InvoiceDate { get; set; } public DateTime DueDate { get; set; } diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index e2de241..cdc4d1d 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -9,6 +9,7 @@ using JobFlow.API.Constants; using JobFlow.API.Hubs; using JobFlow.API.Mappings; +using JobFlow.API.Services; using JobFlow.Business.ConfigurationSettings; using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; using JobFlow.Business.DI; @@ -130,7 +131,8 @@ var path = context.HttpContext.Request.Path; if (!string.IsNullOrWhiteSpace(accessToken) - && path.StartsWithSegments("/hubs/client-chat")) + && (path.StartsWithSegments("/hubs/client-chat") + || path.StartsWithSegments("/hubs/client-portal"))) { context.Token = accessToken; } @@ -321,6 +323,7 @@ builder.Services.AddMapsterConfiguration(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddJobFlowHttpClients(); builder.Services.AddAttributedServices(typeof(IJobFlowHttpClientFactory).Assembly, typeof(IUserService).Assembly); @@ -399,5 +402,6 @@ app.MapHub("/hubs/chat"); app.MapHub("/hubs/client-chat"); app.MapHub("/hubs/notifier"); +app.MapHub("/hubs/client-portal"); app.Run(); \ No newline at end of file diff --git a/JobFlow.API/Services/InvoiceRealtimeNotifier.cs b/JobFlow.API/Services/InvoiceRealtimeNotifier.cs new file mode 100644 index 0000000..efe46bb --- /dev/null +++ b/JobFlow.API/Services/InvoiceRealtimeNotifier.cs @@ -0,0 +1,40 @@ +using JobFlow.API.Hubs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.SignalR; + +namespace JobFlow.API.Services; + +public class InvoiceRealtimeNotifier : IInvoiceRealtimeNotifier +{ + private readonly IHubContext _notifierHub; + private readonly IHubContext _clientHub; + + public InvoiceRealtimeNotifier( + IHubContext notifierHub, + IHubContext clientHub) + { + _notifierHub = notifierHub; + _clientHub = clientHub; + } + + public async Task NotifyInvoicePaidAsync(Invoice invoice) + { + var payload = new + { + invoiceId = invoice.Id, + organizationId = invoice.OrganizationId, + organizationClientId = invoice.OrganizationClientId, + status = invoice.Status, + balanceDue = invoice.BalanceDue, + amountPaid = invoice.AmountPaid, + paidAt = invoice.PaidAt + }; + + await _notifierHub.Clients.Group($"org:{invoice.OrganizationId}:dashboard") + .SendAsync("InvoicePaid", payload); + + await _clientHub.Clients.Group($"client:{invoice.OrganizationClientId}") + .SendAsync("InvoicePaid", payload); + } +} \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/JobDto.cs b/JobFlow.Business/Models/DTOs/JobDto.cs index 7cb1968..1f499ab 100644 --- a/JobFlow.Business/Models/DTOs/JobDto.cs +++ b/JobFlow.Business/Models/DTOs/JobDto.cs @@ -8,6 +8,7 @@ public class JobDto public string? Title { get; set; } public string? Comments { get; set; } public JobLifecycleStatus LifecycleStatus { get; set; } + public InvoicingWorkflow? InvoicingWorkflow { get; set; } public Guid OrganizationClientId { get; set; } public OrganizationClientDto? OrganizationClient { get; set; } diff --git a/JobFlow.Business/Models/DTOs/OnboardingQuickStartDtos.cs b/JobFlow.Business/Models/DTOs/OnboardingQuickStartDtos.cs new file mode 100644 index 0000000..61ee767 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/OnboardingQuickStartDtos.cs @@ -0,0 +1,39 @@ +namespace JobFlow.Business.Models.DTOs; + +public class OnboardingQuickStartTrackDto +{ + public string Key { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} + +public class OnboardingQuickStartServiceDto +{ + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public decimal Price { get; set; } +} + +public class OnboardingQuickStartPresetDto +{ + public string Key { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public List DefaultServices { get; set; } = new(); +} + +public class OnboardingQuickStartStateDto +{ + public string? SelectedTrackKey { get; set; } + public string? SelectedPresetKey { get; set; } + public bool IsPresetApplied { get; set; } + public List Tracks { get; set; } = new(); + public List Presets { get; set; } = new(); +} + +public class OnboardingQuickStartApplyRequestDto +{ + public string TrackKey { get; set; } = string.Empty; + public string PresetKey { get; set; } = string.Empty; +} diff --git a/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs index 84e69d1..0ef47d3 100644 --- a/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs +++ b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs @@ -1,3 +1,5 @@ +using JobFlow.Domain.Enums; + namespace JobFlow.Business.Models.DTOs; public class WorkflowStatusDto @@ -13,3 +15,13 @@ public class WorkflowStatusUpsertRequestDto public string Label { get; set; } = string.Empty; public int SortOrder { get; set; } } + +public class InvoicingSettingsDto +{ + public InvoicingWorkflow DefaultWorkflow { get; set; } +} + +public class InvoicingSettingsUpsertRequestDto +{ + public InvoicingWorkflow DefaultWorkflow { get; set; } +} diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index 5373cb8..d45e45c 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -20,6 +20,7 @@ NotificationMessage BuildClientJobRescheduled( DateTimeOffset newStart, DateTimeOffset? newEnd); NotificationMessage BuildClientInvoiceCreated(OrganizationClient client, Invoice invoice); + NotificationMessage BuildClientInvoiceReminder(OrganizationClient client, Invoice invoice); NotificationMessage BuildClientPaymentReceived(OrganizationClient client, Invoice invoice); NotificationMessage BuildClientJobTrackingEta(OrganizationClient client, Job job, int etaMinutes); diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 435e135..d0aed12 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -136,6 +136,21 @@ public NotificationMessage BuildClientInvoiceCreated(OrganizationClient client, }; } + public NotificationMessage BuildClientInvoiceReminder(OrganizationClient client, Invoice invoice) + { + return new NotificationMessage + { + Name = client.ClientFullName(), + Email = client.EmailAddress, + Phone = client.PhoneNumber, + Subject = $"Payment Reminder: Invoice #{invoice.InvoiceNumber}", + Body = $"Just a reminder that invoice #{invoice.InvoiceNumber} for {invoice.TotalAmount:C} is still open.", + Sms = $"Reminder: invoice #{invoice.InvoiceNumber} is still open.", + TemplateId = EmailTemplate.InvoiceReminder, + Link = $"{baseUrl}/invoice/view/{invoice.Id}" + }; + } + public NotificationMessage BuildClientPaymentReceived(OrganizationClient client, Invoice invoice) { return new NotificationMessage diff --git a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs index 1dad7d4..fea1c6c 100644 --- a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs +++ b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs @@ -5,6 +5,7 @@ public enum EmailTemplate Default = 0, OrganizationWelcome = 2, InvoiceCreated = 3, + InvoiceReminder = 6, OnTheWayNotification = 4, ArrivalNotification = 5 } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 170412c..6d8af79 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -79,9 +79,29 @@ public async Task SendClientJobRescheduledNotificationAsync( await SendNotificationAsync(message); } - public async Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice) + public async Task SendClientInvoiceCreatedNotificationAsync( + OrganizationClient client, + Invoice invoice, + string? linkOverride = null) { var message = _builder.BuildClientInvoiceCreated(client, invoice); + if (!string.IsNullOrWhiteSpace(linkOverride)) + { + message.Link = linkOverride; + } + await SendNotificationAsync(message); + } + + public async Task SendClientInvoiceReminderNotificationAsync( + OrganizationClient client, + Invoice invoice, + string? linkOverride = null) + { + var message = _builder.BuildClientInvoiceReminder(client, invoice); + if (!string.IsNullOrWhiteSpace(linkOverride)) + { + message.Link = linkOverride; + } await SendNotificationAsync(message); } diff --git a/JobFlow.Business/Onboarding/OnboardingCatalog.cs b/JobFlow.Business/Onboarding/OnboardingCatalog.cs index 53594d1..00b4cf5 100644 --- a/JobFlow.Business/Onboarding/OnboardingCatalog.cs +++ b/JobFlow.Business/Onboarding/OnboardingCatalog.cs @@ -11,8 +11,36 @@ Func IsApplicable public static class OnboardingCatalog { + private static readonly Dictionary PaidFastOrder = new() + { + { OnboardingStepKeys.ChooseTrack, 5 }, + { OnboardingStepKeys.ChooseIndustryPreset, 8 }, + { OnboardingStepKeys.ConnectStripe, 10 }, + { OnboardingStepKeys.CreateCustomer, 20 }, + { OnboardingStepKeys.CreateJob, 30 }, + { OnboardingStepKeys.CreateInvoice, 40 }, + { OnboardingStepKeys.SendInvoice, 50 }, + { OnboardingStepKeys.ReceivePayment, 60 }, + { OnboardingStepKeys.ScheduleJob, 70 } + }; + + private static readonly Dictionary OrganizedFirstOrder = new() + { + { OnboardingStepKeys.ChooseTrack, 5 }, + { OnboardingStepKeys.ChooseIndustryPreset, 8 }, + { OnboardingStepKeys.CreateCustomer, 10 }, + { OnboardingStepKeys.CreateJob, 20 }, + { OnboardingStepKeys.ScheduleJob, 30 }, + { OnboardingStepKeys.CreateInvoice, 40 }, + { OnboardingStepKeys.SendInvoice, 50 }, + { OnboardingStepKeys.ConnectStripe, 60 }, + { OnboardingStepKeys.ReceivePayment, 70 } + }; + public static readonly IReadOnlyList Steps = [ + new(OnboardingStepKeys.ChooseTrack, "Choose your onboarding path", 5, _ => true), + new(OnboardingStepKeys.ChooseIndustryPreset, "Select your industry quick-start", 8, _ => true), new(OnboardingStepKeys.CreateCustomer, "Create your first customer", 10, _ => true), new(OnboardingStepKeys.CreateJob, "Create your first job", 20, _ => true), new(OnboardingStepKeys.ScheduleJob, "Schedule the job", 30, _ => true), @@ -34,6 +62,25 @@ public static bool IsKnown(string key) public static IEnumerable ApplicableSteps(Organization org) { - return Steps.Where(s => s.IsApplicable(org)).OrderBy(s => s.Order); + var orderMap = GetOrderMap(org.OnboardingTrack); + return Steps + .Where(s => s.IsApplicable(org)) + .Select(step => + { + var order = orderMap.TryGetValue(step.Key, out var mapped) + ? mapped + : step.Order; + + return step with { Order = order }; + }) + .OrderBy(s => s.Order); + } + + private static Dictionary GetOrderMap(string? trackKey) + { + var normalized = OnboardingTrackKeys.Normalize(trackKey); + return normalized == OnboardingTrackKeys.GetOrganizedFirst + ? OrganizedFirstOrder + : PaidFastOrder; } } \ No newline at end of file diff --git a/JobFlow.Business/Onboarding/OnboardingPresetKeys.cs b/JobFlow.Business/Onboarding/OnboardingPresetKeys.cs new file mode 100644 index 0000000..8abed5b --- /dev/null +++ b/JobFlow.Business/Onboarding/OnboardingPresetKeys.cs @@ -0,0 +1,20 @@ +namespace JobFlow.Business.Onboarding; + +public static class OnboardingPresetKeys +{ + public const string HomeServices = "home_services"; + public const string Construction = "construction"; + public const string CreativeDesign = "creative_design"; + public const string TechRepair = "tech_repair"; + public const string Consulting = "consulting"; + + public static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Trim().ToLowerInvariant().Replace(' ', '_'); + } +} diff --git a/JobFlow.Business/Onboarding/OnboardingQuickStartCatalog.cs b/JobFlow.Business/Onboarding/OnboardingQuickStartCatalog.cs new file mode 100644 index 0000000..a2e93e8 --- /dev/null +++ b/JobFlow.Business/Onboarding/OnboardingQuickStartCatalog.cs @@ -0,0 +1,270 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Onboarding; + +public record OnboardingQuickStartTrackDefinition( + string Key, + string Title, + string Description); + +public record OnboardingQuickStartPresetDefinition( + string Key, + string Title, + string Description, + IReadOnlyList DefaultServices, + IReadOnlyList SuggestedStatuses); + +public record OnboardingQuickStartServiceSeed( + string Name, + string Description, + string Unit, + decimal Price); + +public record WorkflowStatusSeed( + string StatusKey, + string Label, + int SortOrder); + +public static class OnboardingQuickStartCatalog +{ + public static readonly IReadOnlyList Tracks = + [ + new( + OnboardingTrackKeys.GetPaidFast, + "Get paid fast", + "Prioritize invoices and payments so you can collect revenue quickly." + ), + new( + OnboardingTrackKeys.GetOrganizedFirst, + "Get organized first", + "Set up customers, jobs, and schedules before you focus on billing." + ) + ]; + + public static readonly IReadOnlyList Presets = + [ + new( + OnboardingPresetKeys.HomeServices, + "Home services", + "HVAC, plumbing, electrical, cleaning, and recurring maintenance teams.", + [ + new OnboardingQuickStartServiceSeed( + "Diagnostic visit", + "On-site evaluation and troubleshooting.", + "visit", + 89m + ), + new OnboardingQuickStartServiceSeed( + "Standard repair", + "Most common repair or fix.", + "job", + 250m + ), + new OnboardingQuickStartServiceSeed( + "Maintenance plan", + "Monthly service agreement.", + "month", + 49m + ) + ], + [ + new WorkflowStatusSeed("Draft", "New Request", 0), + new WorkflowStatusSeed("Approved", "Booked", 1), + new WorkflowStatusSeed("InProgress", "In Service", 2), + new WorkflowStatusSeed("Completed", "Wrapped", 3), + new WorkflowStatusSeed("Cancelled", "Cancelled", 4), + new WorkflowStatusSeed("Failed", "Reschedule", 5) + ] + ), + new( + OnboardingPresetKeys.Construction, + "Construction / contracting", + "General contracting, remodels, site work, and specialty trades.", + [ + new OnboardingQuickStartServiceSeed( + "Site walkthrough", + "On-site scope review and measurements.", + "visit", + 150m + ), + new OnboardingQuickStartServiceSeed( + "Labor - crew", + "Field labor billed hourly.", + "hour", + 125m + ), + new OnboardingQuickStartServiceSeed( + "Materials allowance", + "Estimated materials and supplies.", + "lot", + 500m + ) + ], + [ + new WorkflowStatusSeed("Draft", "Lead", 0), + new WorkflowStatusSeed("Approved", "Estimate Approved", 1), + new WorkflowStatusSeed("InProgress", "In Progress", 2), + new WorkflowStatusSeed("Completed", "Final Walkthrough", 3), + new WorkflowStatusSeed("Cancelled", "Cancelled", 4), + new WorkflowStatusSeed("Failed", "Blocked", 5) + ] + ), + new( + OnboardingPresetKeys.CreativeDesign, + "Creative / design", + "Branding, design studios, production, and creative agencies.", + [ + new OnboardingQuickStartServiceSeed( + "Discovery session", + "Initial briefing and creative alignment.", + "hour", + 150m + ), + new OnboardingQuickStartServiceSeed( + "Design concept", + "Concept development and visuals.", + "package", + 800m + ), + new OnboardingQuickStartServiceSeed( + "Production sprint", + "Execution and revisions.", + "hour", + 120m + ) + ], + [ + new WorkflowStatusSeed("Draft", "Intake", 0), + new WorkflowStatusSeed("Approved", "Concept Approved", 1), + new WorkflowStatusSeed("InProgress", "Production", 2), + new WorkflowStatusSeed("Completed", "Delivered", 3), + new WorkflowStatusSeed("Cancelled", "Cancelled", 4), + new WorkflowStatusSeed("Failed", "Paused", 5) + ] + ), + new( + OnboardingPresetKeys.TechRepair, + "Tech repair", + "Device repair, IT support, and on-site troubleshooting.", + [ + new OnboardingQuickStartServiceSeed( + "Device diagnostics", + "Hardware and software inspection.", + "device", + 65m + ), + new OnboardingQuickStartServiceSeed( + "Repair labor", + "Repair time and skill.", + "hour", + 110m + ), + new OnboardingQuickStartServiceSeed( + "Parts replacement", + "Common replacement parts.", + "part", + 75m + ) + ], + [ + new WorkflowStatusSeed("Draft", "Intake", 0), + new WorkflowStatusSeed("Approved", "Authorized", 1), + new WorkflowStatusSeed("InProgress", "In Repair", 2), + new WorkflowStatusSeed("Completed", "Ready for Pickup", 3), + new WorkflowStatusSeed("Cancelled", "Cancelled", 4), + new WorkflowStatusSeed("Failed", "Unrepairable", 5) + ] + ), + new( + OnboardingPresetKeys.Consulting, + "Consulting", + "Consultants, coaching, and professional service providers.", + [ + new OnboardingQuickStartServiceSeed( + "Strategy call", + "Initial call and planning.", + "hour", + 200m + ), + new OnboardingQuickStartServiceSeed( + "Monthly retainer", + "Ongoing advisory support.", + "month", + 1200m + ), + new OnboardingQuickStartServiceSeed( + "Workshop", + "Facilitated working session.", + "session", + 950m + ) + ], + [ + new WorkflowStatusSeed("Draft", "Inquiry", 0), + new WorkflowStatusSeed("Approved", "Engaged", 1), + new WorkflowStatusSeed("InProgress", "In Session", 2), + new WorkflowStatusSeed("Completed", "Delivered", 3), + new WorkflowStatusSeed("Cancelled", "Cancelled", 4), + new WorkflowStatusSeed("Failed", "On Hold", 5) + ] + ) + ]; + + public static OnboardingQuickStartTrackDefinition GetTrackOrDefault(string? key) + { + var normalized = OnboardingTrackKeys.Normalize(key); + return Tracks.FirstOrDefault(t => t.Key == normalized) + ?? Tracks.First(t => t.Key == OnboardingTrackKeys.GetPaidFast); + } + + public static bool IsKnownTrack(string? key) + { + var normalized = OnboardingTrackKeys.Normalize(key); + return Tracks.Any(t => t.Key == normalized); + } + + public static bool IsKnownPreset(string? key) + { + var normalized = OnboardingPresetKeys.Normalize(key); + return Presets.Any(p => p.Key == normalized); + } + + public static OnboardingQuickStartPresetDefinition? TryGetPreset(string? key) + { + var normalized = OnboardingPresetKeys.Normalize(key); + return Presets.FirstOrDefault(p => p.Key == normalized); + } + + public static List BuildTrackDtos() + { + return Tracks + .Select(track => new OnboardingQuickStartTrackDto + { + Key = track.Key, + Title = track.Title, + Description = track.Description + }) + .ToList(); + } + + public static List BuildPresetDtos() + { + return Presets + .Select(preset => new OnboardingQuickStartPresetDto + { + Key = preset.Key, + Title = preset.Title, + Description = preset.Description, + DefaultServices = preset.DefaultServices + .Select(service => new OnboardingQuickStartServiceDto + { + Name = service.Name, + Description = service.Description, + Unit = service.Unit, + Price = service.Price + }) + .ToList() + }) + .ToList(); + } +} diff --git a/JobFlow.Business/Onboarding/OnboardingStepKeys.cs b/JobFlow.Business/Onboarding/OnboardingStepKeys.cs index cfdab61..5d5a29b 100644 --- a/JobFlow.Business/Onboarding/OnboardingStepKeys.cs +++ b/JobFlow.Business/Onboarding/OnboardingStepKeys.cs @@ -2,6 +2,8 @@ public static class OnboardingStepKeys { + public const string ChooseTrack = "choose_track"; + public const string ChooseIndustryPreset = "choose_industry_preset"; public const string CreateCustomer = "create_customer"; public const string CreateJob = "create_job"; public const string ScheduleJob = "schedule_job"; diff --git a/JobFlow.Business/Onboarding/OnboardingTrackKeys.cs b/JobFlow.Business/Onboarding/OnboardingTrackKeys.cs new file mode 100644 index 0000000..619b82c --- /dev/null +++ b/JobFlow.Business/Onboarding/OnboardingTrackKeys.cs @@ -0,0 +1,17 @@ +namespace JobFlow.Business.Onboarding; + +public static class OnboardingTrackKeys +{ + public const string GetPaidFast = "get_paid_fast"; + public const string GetOrganizedFirst = "get_organized_first"; + + public static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Trim().ToLowerInvariant().Replace(' ', '_'); + } +} diff --git a/JobFlow.Business/Services/InvoiceService.cs b/JobFlow.Business/Services/InvoiceService.cs index 209d4f2..fd758a5 100644 --- a/JobFlow.Business/Services/InvoiceService.cs +++ b/JobFlow.Business/Services/InvoiceService.cs @@ -14,16 +14,35 @@ namespace JobFlow.Business.Services; public class InvoiceService : IInvoiceService { private readonly IRepository invoices; + private readonly IRepository estimates; + private readonly IRepository clients; private readonly ILogger logger; private readonly IOnboardingService _onboardingService; + private readonly IInvoiceNumberGenerator _numberGenerator; + private readonly INotificationService _notifications; + private readonly IOrganizationClientPortalService _clientPortal; + private readonly IInvoiceRealtimeNotifier? _realtimeNotifier; private readonly IUnitOfWork unitOfWork; - public InvoiceService(ILogger logger, IUnitOfWork unitOfWork, IOnboardingService onboardingService) + public InvoiceService( + ILogger logger, + IUnitOfWork unitOfWork, + IOnboardingService onboardingService, + IInvoiceNumberGenerator numberGenerator, + INotificationService notifications, + IOrganizationClientPortalService clientPortal, + IInvoiceRealtimeNotifier? realtimeNotifier = null) { this.logger = logger; this.unitOfWork = unitOfWork; invoices = unitOfWork.RepositoryOf(); + estimates = unitOfWork.RepositoryOf(); + clients = unitOfWork.RepositoryOf(); _onboardingService = onboardingService; + _numberGenerator = numberGenerator; + _notifications = notifications; + _clientPortal = clientPortal; + _realtimeNotifier = realtimeNotifier; } public async Task> GetInvoiceByIdAsync(Guid id) @@ -120,11 +139,52 @@ public async Task MarkInvoiceSentAsync(Guid invoiceId) if (invoice == null) return; + if (invoice.Status != InvoiceStatus.Paid) + { + invoice.Status = InvoiceStatus.Sent; + invoices.Update(invoice); + await unitOfWork.SaveChangesAsync(); + } + await _onboardingService.MarkStepCompleteAsync( invoice.OrganizationId, OnboardingStepKeys.SendInvoice ); } + + public async Task SendInvoiceToClientAsync(Guid invoiceId) + { + var invoiceResult = await GetInvoiceByIdAsync(invoiceId); + if (!invoiceResult.IsSuccess) + return Result.Failure(invoiceResult.Error); + + await SendInvoiceToClientAsync(invoiceResult.Value); + return Result.Success(); + } + + public async Task SendInvoiceForJobAsync(Guid organizationId, Job job) + { + var invoice = await invoices.Query() + .Include(i => i.OrganizationClient) + .ThenInclude(c => c.Organization) + .Include(i => i.LineItems) + .FirstOrDefaultAsync(i => i.OrganizationId == organizationId && i.JobId == job.Id); + + if (invoice == null) + { + var createResult = await CreateInvoiceFromEstimateAsync(organizationId, job); + if (createResult.IsFailure) + return Result.Failure(createResult.Error); + + invoice = createResult.Value; + } + + if (invoice.Status == InvoiceStatus.Paid) + return Result.Success(); + + await SendInvoiceToClientAsync(invoice); + return Result.Success(); + } public async Task> MarkPaidAsync( Guid invoiceId, PaymentProvider provider, @@ -149,7 +209,90 @@ public async Task> MarkPaidAsync( invoice.ExternalPaymentId = externalPaymentId; await unitOfWork.SaveChangesAsync(); + + if (_realtimeNotifier != null) + { + await _realtimeNotifier.NotifyInvoicePaidAsync(invoice); + } + return Result.Success(invoice); } + private async Task SendInvoiceToClientAsync(Invoice invoice) + { + var client = invoice.OrganizationClient; + if (client == null) + return; + + string? linkOverride = null; + var email = client.EmailAddress; + if (!string.IsNullOrWhiteSpace(email)) + { + var returnUrl = $"/client-hub/invoices/{invoice.Id}"; + var linkResult = await _clientPortal.CreateMagicLinkAsync( + invoice.OrganizationId, + invoice.OrganizationClientId, + email, + returnUrl); + + if (linkResult.IsSuccess) + { + linkOverride = linkResult.Value; + } + } + + await _notifications.SendClientInvoiceCreatedNotificationAsync(client, invoice, linkOverride); + await MarkInvoiceSentAsync(invoice.Id); + } + + private async Task> CreateInvoiceFromEstimateAsync(Guid organizationId, Job job) + { + var estimate = await estimates.Query() + .Include(e => e.LineItems) + .OrderByDescending(e => e.UpdatedAt ?? e.CreatedAt) + .FirstOrDefaultAsync(e => + e.OrganizationId == organizationId && + e.OrganizationClientId == job.OrganizationClientId && + e.Status == EstimateStatus.Accepted); + + if (estimate == null) + return Result.Failure(EstimateErrors.NotFound); + + var client = await clients.Query() + .Include(c => c.Organization) + .FirstOrDefaultAsync(c => c.Id == job.OrganizationClientId); + + if (client == null) + return Result.Failure(EstimateErrors.ClientNotFound); + + var invoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + OrganizationClientId = job.OrganizationClientId, + JobId = job.Id, + InvoiceNumber = await _numberGenerator.GenerateAsync(organizationId), + InvoiceDate = DateTime.UtcNow, + DueDate = DateTime.UtcNow.AddDays(14), + Status = InvoiceStatus.Draft, + OrganizationClient = client, + LineItems = estimate.LineItems.Select(li => new InvoiceLineItem + { + Id = Guid.NewGuid(), + Description = string.IsNullOrWhiteSpace(li.Description) ? li.Name : li.Description, + Quantity = (int)Math.Round(li.Quantity, MidpointRounding.AwayFromZero), + UnitPrice = li.UnitPrice + }).ToList() + }; + + var result = await UpsertInvoiceAsync(invoice); + if (!result.IsSuccess) + return Result.Failure(result.Error); + + var hydrated = await GetInvoiceByIdAsync(result.Value.Id); + return hydrated.IsSuccess + ? hydrated + : Result.Failure(hydrated.Error); + } + } \ No newline at end of file diff --git a/JobFlow.Business/Services/InvoicingSettingsService.cs b/JobFlow.Business/Services/InvoicingSettingsService.cs new file mode 100644 index 0000000..24fc031 --- /dev/null +++ b/JobFlow.Business/Services/InvoicingSettingsService.cs @@ -0,0 +1,70 @@ +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class InvoicingSettingsService : IInvoicingSettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IRepository _settings; + + public InvoicingSettingsService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _settings = unitOfWork.RepositoryOf(); + } + + public async Task> GetInvoicingSettingsAsync(Guid organizationId) + { + var settings = await _settings.Query() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId); + + if (settings == null) + { + return Result.Success(new InvoicingSettingsDto + { + DefaultWorkflow = InvoicingWorkflow.SendInvoice + }); + } + + return Result.Success(Map(settings)); + } + + public async Task> UpsertInvoicingSettingsAsync( + Guid organizationId, + InvoicingSettingsUpsertRequestDto dto) + { + var settings = await _settings.Query() + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId); + + if (settings == null) + { + settings = new OrganizationInvoicingSettings + { + OrganizationId = organizationId + }; + _settings.Add(settings); + } + + settings.DefaultWorkflow = dto.DefaultWorkflow; + + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(Map(settings)); + } + + private static InvoicingSettingsDto Map(OrganizationInvoicingSettings settings) + { + return new InvoicingSettingsDto + { + DefaultWorkflow = settings.DefaultWorkflow + }; + } +} \ No newline at end of file diff --git a/JobFlow.Business/Services/JobService.cs b/JobFlow.Business/Services/JobService.cs index 8b851cc..00fcf4d 100644 --- a/JobFlow.Business/Services/JobService.cs +++ b/JobFlow.Business/Services/JobService.cs @@ -18,6 +18,8 @@ public class JobService : IJobService private readonly IRepository jobs; private readonly ILogger logger; private readonly IOnboardingService onboardingService; + private readonly IInvoicingSettingsService _invoicingSettings; + private readonly IInvoiceService _invoiceService; private readonly IUnitOfWork unitOfWork; private readonly IMapper _mapper; @@ -25,12 +27,16 @@ public JobService( ILogger logger, IUnitOfWork unitOfWork, IOnboardingService onboardingService, + IInvoicingSettingsService invoicingSettings, + IInvoiceService invoiceService, IMapper mapper) { this.logger = logger; this.unitOfWork = unitOfWork; this.onboardingService = onboardingService; jobs = unitOfWork.RepositoryOf(); + _invoicingSettings = invoicingSettings; + _invoiceService = invoiceService; _mapper = mapper; } @@ -80,6 +86,7 @@ public async Task>> GetJobsAsync(Guid organizationId) Title = e.Title, Comments = e.Comments, LifecycleStatus = e.LifecycleStatus, + InvoicingWorkflow = e.InvoicingWorkflow, Assignments = e.Assignments.Select(a => new AssignmentDto { ScheduledStart = a.ScheduledStart, @@ -138,6 +145,7 @@ public async Task> UpsertJobAsync(Job model, Guid organizationId) existingModel.Comments = model.Comments; existingModel.Latitude = model.Latitude; existingModel.Longitude = model.Longitude; + existingModel.InvoicingWorkflow = model.InvoicingWorkflow; jobs.Update(existingModel); } @@ -186,6 +194,41 @@ public async Task> UpdateJobStatusAsync(Guid organizationId, Guid jo jobs.Update(job); await unitOfWork.SaveChangesAsync(); + if (status == JobLifecycleStatus.Completed) + { + await HandleJobCompletedAsync(organizationId, job); + } + return Result.Success(job); } + + private async Task HandleJobCompletedAsync(Guid organizationId, Job job) + { + var workflow = job.InvoicingWorkflow; + if (workflow == null) + { + var settingsResult = await _invoicingSettings.GetInvoicingSettingsAsync(organizationId); + if (settingsResult.IsSuccess) + { + workflow = settingsResult.Value.DefaultWorkflow; + } + } + + if (workflow == null) + { + workflow = InvoicingWorkflow.SendInvoice; + } + + if (workflow == InvoicingWorkflow.InPerson) + return; + + var sendResult = await _invoiceService.SendInvoiceForJobAsync(organizationId, job); + if (sendResult.IsFailure) + { + logger.LogWarning( + "Invoice auto-send failed for completed job {JobId}: {Error}", + job.Id, + sendResult.Error.Description); + } + } } diff --git a/JobFlow.Business/Services/OnboardingService.cs b/JobFlow.Business/Services/OnboardingService.cs index fa4c650..5a19134 100644 --- a/JobFlow.Business/Services/OnboardingService.cs +++ b/JobFlow.Business/Services/OnboardingService.cs @@ -4,6 +4,7 @@ using JobFlow.Business.Onboarding; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; +using JobFlow.Domain.Enums; using JobFlow.Domain.Models; using Microsoft.EntityFrameworkCore; @@ -13,14 +14,18 @@ namespace JobFlow.Business.Services; public class OnboardingService : IOnboardingService { private readonly IRepository orgRepo; + private readonly IRepository priceBookItems; private readonly IRepository stepRepo; + private readonly IWorkflowSettingsService workflowSettings; private readonly IUnitOfWork uow; - public OnboardingService(IUnitOfWork uow) + public OnboardingService(IUnitOfWork uow, IWorkflowSettingsService workflowSettings) { this.uow = uow; + this.workflowSettings = workflowSettings; orgRepo = uow.RepositoryOf(); stepRepo = uow.RepositoryOf(); + priceBookItems = uow.RepositoryOf(); } public async Task>> GetChecklistAsync(Guid orgId) @@ -79,4 +84,152 @@ public async Task MarkStepCompleteAsync(Guid orgId, string stepKey) await uow.SaveChangesAsync(); return Result.Success(); } + + public async Task> MarkOrganizationCompleteIfEligibleAsync(Guid organizationId) + { + var org = await orgRepo.GetByIdAsync(organizationId); + if (org == null) + return Result.Failure(OnboardingErrors.OrganizationNotFound); + + var progress = await stepRepo.Query() + .Where(x => x.OrganizationId == organizationId) + .ToListAsync(); + + var applicableSteps = OnboardingCatalog.ApplicableSteps(org).ToList(); + if (applicableSteps.Count == 0) + return Result.Success(false); + + var allCompleted = applicableSteps.All(step => + progress.Any(p => p.StepName == step.Key && p.IsCompleted)); + + if (!allCompleted) + return Result.Success(false); + + if (!org.OnBoardingComplete) + { + org.OnBoardingComplete = true; + await uow.SaveChangesAsync(); + } + + return Result.Success(true); + } + + public async Task> GetQuickStartStateAsync(Guid organizationId) + { + var org = await orgRepo.GetByIdAsync(organizationId); + if (org == null) + return Result.Failure(OnboardingErrors.OrganizationNotFound); + + var state = new OnboardingQuickStartStateDto + { + SelectedTrackKey = org.OnboardingTrack, + SelectedPresetKey = org.OnboardingPresetKey, + IsPresetApplied = org.OnboardingPresetAppliedAt.HasValue, + Tracks = OnboardingQuickStartCatalog.BuildTrackDtos(), + Presets = OnboardingQuickStartCatalog.BuildPresetDtos() + }; + + return Result.Success(state); + } + + public async Task> ApplyQuickStartAsync( + Guid organizationId, + OnboardingQuickStartApplyRequestDto request) + { + if (request == null) + { + return Result.Failure( + Error.Validation("Onboarding.QuickStart.Invalid", "Quick-start selection is required.")); + } + + var normalizedTrack = OnboardingTrackKeys.Normalize(request.TrackKey); + var normalizedPreset = OnboardingPresetKeys.Normalize(request.PresetKey); + + if (!OnboardingQuickStartCatalog.IsKnownTrack(normalizedTrack)) + { + return Result.Failure( + Error.Validation("Onboarding.QuickStart.Track", "Unknown onboarding track.")); + } + + if (!OnboardingQuickStartCatalog.IsKnownPreset(normalizedPreset)) + { + return Result.Failure( + Error.Validation("Onboarding.QuickStart.Preset", "Unknown industry preset.")); + } + + var org = await orgRepo.GetByIdAsync(organizationId); + if (org == null) + return Result.Failure(OnboardingErrors.OrganizationNotFound); + + var preset = OnboardingQuickStartCatalog.TryGetPreset(normalizedPreset); + if (preset == null) + { + return Result.Failure( + Error.Validation("Onboarding.QuickStart.Preset", "Unknown industry preset.")); + } + + org.OnboardingTrack = normalizedTrack; + org.OnboardingTrackSelectedAt ??= DateTimeOffset.UtcNow; + org.OnboardingPresetKey = normalizedPreset; + org.OnboardingPresetAppliedAt = DateTimeOffset.UtcNow; + + await uow.SaveChangesAsync(); + + await SeedPriceBookAsync(organizationId, preset); + + var statusRequest = preset.SuggestedStatuses + .OrderBy(s => s.SortOrder) + .Select(s => new WorkflowStatusUpsertRequestDto + { + StatusKey = s.StatusKey, + Label = s.Label, + SortOrder = s.SortOrder + }) + .ToList(); + + var statusResult = await workflowSettings.UpsertJobLifecycleStatusesAsync( + organizationId, + statusRequest); + + if (statusResult.IsFailure) + { + return Result.Failure(statusResult.Error); + } + + await MarkStepCompleteAsync(organizationId, OnboardingStepKeys.ChooseTrack); + await MarkStepCompleteAsync(organizationId, OnboardingStepKeys.ChooseIndustryPreset); + + return await GetQuickStartStateAsync(organizationId); + } + + private async Task SeedPriceBookAsync(Guid organizationId, OnboardingQuickStartPresetDefinition preset) + { + var existingNames = await priceBookItems.Query() + .Where(x => x.OrganizationId == organizationId) + .Select(x => x.Name) + .ToListAsync(); + + foreach (var service in preset.DefaultServices) + { + if (existingNames.Any(name => + string.Equals(name, service.Name, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + await priceBookItems.AddAsync(new PriceBookItem + { + OrganizationId = organizationId, + Name = service.Name, + Description = service.Description, + Unit = service.Unit, + Price = service.Price, + Cost = 0m, + PricePerUnit = service.Price, + ItemType = PriceBookItemType.Service + }); + } + + await uow.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/OrganizationClientPortalService.cs b/JobFlow.Business/Services/OrganizationClientPortalService.cs index 85c24d6..78ca95d 100644 --- a/JobFlow.Business/Services/OrganizationClientPortalService.cs +++ b/JobFlow.Business/Services/OrganizationClientPortalService.cs @@ -37,45 +37,61 @@ public OrganizationClientPortalService( _sessions = unitOfWork.RepositoryOf(); } - public async Task SendMagicLinkAsync(Guid organizationId, Guid organizationClientId, string emailAddress) + public async Task SendMagicLinkAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl = null) { - if (organizationId == Guid.Empty || organizationClientId == Guid.Empty) - return Result.Failure(Error.Failure("OrganizationClientPortal", "Organization and client are required.")); + var result = await CreateMagicLinkInternalAsync( + organizationId, + organizationClientId, + emailAddress, + returnUrl); - if (string.IsNullOrWhiteSpace(emailAddress)) - return Result.Failure(Error.Failure("OrganizationClientPortal", "Email is required.")); + if (!result.IsSuccess) + return Result.Failure(result.Error); - var client = await _clients.Query() - .Include(x => x.Organization) - .FirstOrDefaultAsync(x => x.Id == organizationClientId && x.OrganizationId == organizationId); + await _notifications.SendOrganizationClientPortalMagicLinkAsync(result.Value.Client, result.Value.Url); - if (client is null) - return Result.Failure(Error.NotFound("OrganizationClientPortal", "Client not found.")); - - if (!string.Equals(client.EmailAddress, emailAddress, StringComparison.OrdinalIgnoreCase)) - return Result.Failure(Error.Failure("OrganizationClientPortal", "Email does not match client record.")); + return Result.Success(); + } - var token = OrganizationClientPortalSession.GenerateToken(); - var tokenHash = HashToken(token); + public async Task> CreateMagicLinkAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl = null) + { + var result = await CreateMagicLinkInternalAsync( + organizationId, + organizationClientId, + emailAddress, + returnUrl); + + return result.IsSuccess + ? Result.Success(result.Value.Url) + : Result.Failure(result.Error); + } - var session = new OrganizationClientPortalSession - { - Id = Guid.NewGuid(), - OrganizationId = organizationId, - OrganizationClientId = organizationClientId, - EmailAddress = emailAddress, - TokenHash = tokenHash, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30) - }; + public async Task> SendMagicLinkWithUrlAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl = null) + { + var result = await CreateMagicLinkInternalAsync( + organizationId, + organizationClientId, + emailAddress, + returnUrl); - await _sessions.AddAsync(session); - await _unitOfWork.SaveChangesAsync(); - var url = $"{_frontend.BaseUrl}/client-hub/auth?token={token}"; + if (!result.IsSuccess) + return Result.Failure(result.Error); - await _notifications.SendOrganizationClientPortalMagicLinkAsync(client, url); + await _notifications.SendOrganizationClientPortalMagicLinkAsync(result.Value.Client, result.Value.Url); - return Result.Success(); + return Result.Success(result.Value.Url); } public async Task> RedeemMagicLinkAsync(string token) @@ -108,6 +124,66 @@ public async Task> RedeemMagicLinkAsync(string token) return Result.Success(client); } + private async Task> CreateMagicLinkInternalAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl) + { + if (organizationId == Guid.Empty || organizationClientId == Guid.Empty) + return Result.Failure<(OrganizationClient, string)>( + Error.Failure("OrganizationClientPortal", "Organization and client are required.")); + + if (string.IsNullOrWhiteSpace(emailAddress)) + return Result.Failure<(OrganizationClient, string)>( + Error.Failure("OrganizationClientPortal", "Email is required.")); + + var client = await _clients.Query() + .Include(x => x.Organization) + .FirstOrDefaultAsync(x => x.Id == organizationClientId && x.OrganizationId == organizationId); + + if (client is null) + return Result.Failure<(OrganizationClient, string)>( + Error.NotFound("OrganizationClientPortal", "Client not found.")); + + if (!string.Equals(client.EmailAddress, emailAddress, StringComparison.OrdinalIgnoreCase)) + return Result.Failure<(OrganizationClient, string)>( + Error.Failure("OrganizationClientPortal", "Email does not match client record.")); + + var token = OrganizationClientPortalSession.GenerateToken(); + var tokenHash = HashToken(token); + + var session = new OrganizationClientPortalSession + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + OrganizationClientId = organizationClientId, + EmailAddress = emailAddress, + TokenHash = tokenHash, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30) + }; + + await _sessions.AddAsync(session); + await _unitOfWork.SaveChangesAsync(); + + var url = BuildMagicLinkUrl(token, returnUrl); + + return Result.Success((client, url)); + } + + private string BuildMagicLinkUrl(string token, string? returnUrl) + { + var url = $"{_frontend.BaseUrl}/client-hub/auth?token={token}"; + if (!string.IsNullOrWhiteSpace(returnUrl)) + { + var encodedReturnUrl = Uri.EscapeDataString(returnUrl); + url = $"{url}&returnUrl={encodedReturnUrl}"; + } + + return url; + } + private static string HashToken(string token) { var bytes = Encoding.UTF8.GetBytes(token); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceRealtimeNotifier.cs b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceRealtimeNotifier.cs new file mode 100644 index 0000000..747e1f8 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceRealtimeNotifier.cs @@ -0,0 +1,8 @@ +using JobFlow.Domain.Models; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IInvoiceRealtimeNotifier +{ + Task NotifyInvoicePaidAsync(Invoice invoice); +} \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs index b79324e..3139adc 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs @@ -12,6 +12,8 @@ public interface IInvoiceService Task DeleteInvoiceAsync(Guid id); Task MarkInvoiceSentAsync(Guid invoiceId); Task IsPaidAsync(Guid invoiceId); + Task SendInvoiceToClientAsync(Guid invoiceId); + Task SendInvoiceForJobAsync(Guid organizationId, Job job); Task> MarkPaidAsync( Guid invoiceId, diff --git a/JobFlow.Business/Services/ServiceInterfaces/IInvoicingSettingsService.cs b/JobFlow.Business/Services/ServiceInterfaces/IInvoicingSettingsService.cs new file mode 100644 index 0000000..25eebd4 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IInvoicingSettingsService.cs @@ -0,0 +1,11 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IInvoicingSettingsService +{ + Task> GetInvoicingSettingsAsync(Guid organizationId); + Task> UpsertInvoicingSettingsAsync( + Guid organizationId, + InvoicingSettingsUpsertRequestDto dto); +} \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 0c551c6..edf29e0 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -20,7 +20,8 @@ Task SendClientJobRescheduledNotificationAsync( DateTimeOffset? previousEnd, DateTimeOffset newStart, DateTimeOffset? newEnd); - Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice); + Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice, string? linkOverride = null); + Task SendClientInvoiceReminderNotificationAsync(OrganizationClient client, Invoice invoice, string? linkOverride = null); Task SendClientPaymentReceivedNotificationAsync(OrganizationClient client, Invoice invoice); Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOnboardingService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOnboardingService.cs index f1e7cc7..788a172 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOnboardingService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOnboardingService.cs @@ -6,4 +6,9 @@ public interface IOnboardingService { Task>> GetChecklistAsync(Guid organizationId); Task MarkStepCompleteAsync(Guid organizationId, string stepKey); + Task> MarkOrganizationCompleteIfEligibleAsync(Guid organizationId); + Task> GetQuickStartStateAsync(Guid organizationId); + Task> ApplyQuickStartAsync( + Guid organizationId, + OnboardingQuickStartApplyRequestDto request); } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs index 1934285..013dad6 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs @@ -5,7 +5,19 @@ namespace JobFlow.Business.Services.ServiceInterfaces; public interface IOrganizationClientPortalService { - Task SendMagicLinkAsync(Guid organizationId, Guid organizationClientId, string emailAddress); + Task SendMagicLinkAsync(Guid organizationId, Guid organizationClientId, string emailAddress, string? returnUrl = null); + + Task> SendMagicLinkWithUrlAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl = null); + + Task> CreateMagicLinkAsync( + Guid organizationId, + Guid organizationClientId, + string emailAddress, + string? returnUrl = null); /// /// Validates the token and returns the OrganizationClient if valid. diff --git a/JobFlow.Domain/Enums/InvoicingWorkflow.cs b/JobFlow.Domain/Enums/InvoicingWorkflow.cs new file mode 100644 index 0000000..5225bda --- /dev/null +++ b/JobFlow.Domain/Enums/InvoicingWorkflow.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Domain.Enums; + +public enum InvoicingWorkflow +{ + SendInvoice = 0, + InPerson = 1 +} \ No newline at end of file diff --git a/JobFlow.Domain/Models/Invoice.cs b/JobFlow.Domain/Models/Invoice.cs index bbe443b..0675f36 100644 --- a/JobFlow.Domain/Models/Invoice.cs +++ b/JobFlow.Domain/Models/Invoice.cs @@ -7,6 +7,7 @@ public class Invoice : Entity public string InvoiceNumber { get; set; } public Guid OrganizationId { get; set; } public Guid OrganizationClientId { get; set; } + public Guid? JobId { get; set; } public Guid? OrderId { get; set; } public DateTime InvoiceDate { get; set; } public DateTime DueDate { get; set; } @@ -19,6 +20,7 @@ public class Invoice : Entity public string? ExternalPaymentId { get; set; } public DateTimeOffset? PaidAt { get; set; } public virtual OrganizationClient OrganizationClient { get; set; } + public virtual Job? Job { get; set; } public virtual Order Order { get; set; } public virtual ICollection Payments { get; set; } diff --git a/JobFlow.Domain/Models/InvoiceLineItem.cs b/JobFlow.Domain/Models/InvoiceLineItem.cs index 06edf18..43b99b4 100644 --- a/JobFlow.Domain/Models/InvoiceLineItem.cs +++ b/JobFlow.Domain/Models/InvoiceLineItem.cs @@ -7,5 +7,6 @@ public class InvoiceLineItem : Entity public int Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal LineTotal => UnitPrice * Quantity; + [System.Text.Json.Serialization.JsonIgnore] public virtual Invoice Invoice { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Job.cs b/JobFlow.Domain/Models/Job.cs index 3c91650..5324c59 100644 --- a/JobFlow.Domain/Models/Job.cs +++ b/JobFlow.Domain/Models/Job.cs @@ -5,6 +5,7 @@ namespace JobFlow.Domain.Models; public class Job : Entity { public JobLifecycleStatus LifecycleStatus { get; set; } + public InvoicingWorkflow? InvoicingWorkflow { get; set; } public string? Title { get; set; } public string? Comments { get; set; } diff --git a/JobFlow.Domain/Models/Organization.cs b/JobFlow.Domain/Models/Organization.cs index 1a060d9..1dcc124 100644 --- a/JobFlow.Domain/Models/Organization.cs +++ b/JobFlow.Domain/Models/Organization.cs @@ -19,6 +19,10 @@ public class Organization : Entity public bool OnBoardingComplete { get; set; } public string? StripeConnectAccountId { get; set; } public bool IsStripeConnected { get; set; } = false; + public string? OnboardingTrack { get; set; } + public string? OnboardingPresetKey { get; set; } + public DateTimeOffset? OnboardingTrackSelectedAt { get; set; } + public DateTimeOffset? OnboardingPresetAppliedAt { get; set; } public bool CanAcceptPayments => PaymentProvider == PaymentProvider.Stripe && diff --git a/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs b/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs new file mode 100644 index 0000000..9ba5816 --- /dev/null +++ b/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs @@ -0,0 +1,9 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class OrganizationInvoicingSettings : Entity +{ + public Guid OrganizationId { get; set; } + public InvoicingWorkflow DefaultWorkflow { get; set; } = InvoicingWorkflow.SendInvoice; +} \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs index 644dda1..29cef08 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs @@ -24,5 +24,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(i => i.Status) .IsRequired(); + + builder.HasIndex(i => i.JobId); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs index 4ac4559..1d8f5ee 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs @@ -21,6 +21,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(j => j.LifecycleStatus) .HasConversion() .IsRequired(); + + builder.Property(j => j.InvoicingWorkflow) + .HasConversion(); // ✅ Relationship with OrganizationClient builder.HasOne(j => j.OrganizationClient) diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs new file mode 100644 index 0000000..1f0f00e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs @@ -0,0 +1,18 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class OrganizationInvoicingSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.OrganizationId).IsUnique(); + + builder.Property(x => x.DefaultWorkflow) + .HasConversion() + .HasDefaultValue(JobFlow.Domain.Enums.InvoicingWorkflow.SendInvoice); + } +} \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.Designer.cs new file mode 100644 index 0000000..3c96ffc --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.Designer.cs @@ -0,0 +1,2555 @@ +// +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("20260321010243_AddOnboardingQuickStartFields")] + partial class AddOnboardingQuickStartFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .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("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") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastSequence") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("InvoiceSequence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OnboardingPresetAppliedAt") + .HasColumnType("datetimeoffset"); + + b.Property("OnboardingPresetKey") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrack") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrackSelectedAt") + .HasColumnType("datetimeoffset"); + + 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.OrganizationScheduleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AutoNotifyReschedule") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultWindowMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(120); + + b.Property("EnforceTravelBuffer") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("TravelBufferMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationScheduleSettings"); + }); + + 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.OrganizationWorkflowStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StatusKey") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Category", "StatusKey") + .IsUnique(); + + b.ToTable("OrganizationWorkflowStatus"); + }); + + 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("AssignmentAssignees") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("Invoices") + .HasForeignKey("OrderId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentAssignees"); + + 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/20260321010243_AddOnboardingQuickStartFields.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.cs new file mode 100644 index 0000000..5b5212c --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddOnboardingQuickStartFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OnboardingPresetAppliedAt", + table: "Organization", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "OnboardingPresetKey", + table: "Organization", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "OnboardingTrack", + table: "Organization", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "OnboardingTrackSelectedAt", + table: "Organization", + type: "datetimeoffset", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OnboardingPresetAppliedAt", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "OnboardingPresetKey", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "OnboardingTrack", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "OnboardingTrackSelectedAt", + table: "Organization"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.Designer.cs new file mode 100644 index 0000000..49bb314 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.Designer.cs @@ -0,0 +1,2609 @@ +// +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("20260321031057_AddInvoicingWorkflowSettings")] + partial class AddInvoicingWorkflowSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .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("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("JobId") + .HasColumnType("uniqueidentifier"); + + 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("JobId"); + + 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") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem", (string)null); + }); + + 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("InvoicingWorkflow") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OnboardingPresetAppliedAt") + .HasColumnType("datetimeoffset"); + + b.Property("OnboardingPresetKey") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrack") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrackSelectedAt") + .HasColumnType("datetimeoffset"); + + 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.OrganizationInvoicingSettings", 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("DefaultWorkflow") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationInvoicingSettings"); + }); + + 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.OrganizationScheduleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AutoNotifyReschedule") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultWindowMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(120); + + b.Property("EnforceTravelBuffer") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("TravelBufferMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationScheduleSettings"); + }); + + 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.OrganizationWorkflowStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StatusKey") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Category", "StatusKey") + .IsUnique(); + + b.ToTable("OrganizationWorkflowStatus"); + }); + + 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("AssignmentAssignees") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + + 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("Job"); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentAssignees"); + + 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/20260321031057_AddInvoicingWorkflowSettings.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.cs new file mode 100644 index 0000000..c3113c8 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInvoicingWorkflowSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InvoicingWorkflow", + table: "Job", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "JobId", + table: "Invoice", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "OrganizationInvoicingSettings", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + DefaultWorkflow = table.Column(type: "int", nullable: false, defaultValue: 0), + 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_OrganizationInvoicingSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Invoice_JobId", + table: "Invoice", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationInvoicingSettings_OrganizationId", + table: "OrganizationInvoicingSettings", + column: "OrganizationId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Invoice_Job_JobId", + table: "Invoice", + column: "JobId", + principalTable: "Job", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Invoice_Job_JobId", + table: "Invoice"); + + migrationBuilder.DropTable( + name: "OrganizationInvoicingSettings"); + + migrationBuilder.DropIndex( + name: "IX_Invoice_JobId", + table: "Invoice"); + + migrationBuilder.DropColumn( + name: "InvoicingWorkflow", + table: "Job"); + + migrationBuilder.DropColumn( + name: "JobId", + table: "Invoice"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 01c9084..7547b40 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -852,6 +852,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("bit"); + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + b.Property("OrderId") .HasColumnType("uniqueidentifier"); @@ -882,6 +885,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("JobId"); + b.HasIndex("OrderId"); b.HasIndex("OrganizationClientId"); @@ -991,6 +996,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); + b.Property("InvoicingWorkflow") + .HasColumnType("int"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1291,6 +1299,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OnBoardingComplete") .HasColumnType("bit"); + b.Property("OnboardingPresetAppliedAt") + .HasColumnType("datetimeoffset"); + + b.Property("OnboardingPresetKey") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrack") + .HasColumnType("nvarchar(max)"); + + b.Property("OnboardingTrackSelectedAt") + .HasColumnType("datetimeoffset"); + b.Property("OrganizationName") .HasColumnType("nvarchar(max)"); @@ -1442,6 +1462,46 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OrganizationClientPortalSession"); }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationInvoicingSettings", 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("DefaultWorkflow") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationInvoicingSettings"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => { b.Property("Id") @@ -2224,6 +2284,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + b.HasOne("JobFlow.Domain.Models.Order", "Order") .WithMany("Invoices") .HasForeignKey("OrderId"); @@ -2234,6 +2298,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("Job"); + b.Navigation("Order"); b.Navigation("OrganizationClient"); diff --git a/JobFlow.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/JobFlow.Infrastructure/Extensions/ServiceCollectionExtensions.cs index d0bf99e..cf2c92f 100644 --- a/JobFlow.Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/JobFlow.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -15,6 +15,12 @@ public static IServiceCollection AddJobFlowHttpClients(this IServiceCollection s client.DefaultRequestHeaders.Add("api-key", brevoSettings.ApiKey); }); + services.AddHttpClient(JobFlowNamedClient.OpenMeteo, client => + { + client.BaseAddress = new Uri("https://api.open-meteo.com/"); + client.Timeout = TimeSpan.FromSeconds(20); + }); + return services; } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs index 832581e..264a9ad 100644 --- a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs +++ b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs @@ -2,6 +2,8 @@ using JobFlow.Business.DI; using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Infrastructure.HttpClients; +using Microsoft.Extensions.Logging; namespace JobFlow.Infrastructure.ExternalServices.Weather; @@ -9,10 +11,12 @@ namespace JobFlow.Infrastructure.ExternalServices.Weather; public class OpenMeteoWeatherService : IWeatherService { private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; - public OpenMeteoWeatherService(IHttpClientFactory httpClientFactory) + public OpenMeteoWeatherService(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; + _logger = logger; } public async Task GetForecastAsync( @@ -20,32 +24,60 @@ public async Task GetForecastAsync( { days = Math.Clamp(days, 1, 7); - var url = $"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m,wind_speed_10m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days={days}"; - var client = _httpClientFactory.CreateClient("OpenMeteo"); + var url = FormattableString.Invariant($"v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m,wind_speed_10m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days={days}"); + var client = _httpClientFactory.CreateClient(JobFlowNamedClient.OpenMeteo); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { - using var response = await client.GetAsync(url, linkedCts.Token); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: linkedCts.Token); + var attempts = 3; + for (var attempt = 1; attempt <= attempts; attempt++) + { + using var response = await client.GetAsync(url, linkedCts.Token); - var root = doc.RootElement; - var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; + if (response.IsSuccessStatusCode) + { + await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: linkedCts.Token); - var current = ParseCurrent(root.GetProperty("current")); - var daily = ParseDaily(root.GetProperty("daily")); + var root = doc.RootElement; + var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; - return new WeatherForecastDto - { - Timezone = timezone, - Current = current, - Daily = daily, - RiskAlerts = BuildRiskAlerts(daily) - }; + var current = ParseCurrent(root.GetProperty("current")); + var daily = ParseDaily(root.GetProperty("daily")); + + return new WeatherForecastDto + { + Timezone = timezone, + Current = current, + Daily = daily, + RiskAlerts = BuildRiskAlerts(daily) + }; + } + + if (IsTransientStatusCode(response.StatusCode) && attempt < attempts) + { + var retryDelay = GetRetryDelay(attempt); + _logger.LogWarning( + "Transient OpenMeteo failure (status: {StatusCode}) on attempt {Attempt}/{Attempts}. Retrying in {DelayMs}ms.", + (int)response.StatusCode, + attempt, + attempts, + retryDelay.TotalMilliseconds); + + await Task.Delay(retryDelay, linkedCts.Token); + continue; + } + + throw new HttpRequestException( + $"OpenMeteo returned status {(int)response.StatusCode} ({response.ReasonPhrase}).", + null, + response.StatusCode); + } + + throw new HttpRequestException("OpenMeteo request failed after all retry attempts."); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -55,6 +87,25 @@ public async Task GetForecastAsync( { throw new TimeoutException("OpenMeteo request timed out."); } + catch (TaskCanceledException ex) + { + throw new TimeoutException("OpenMeteo request was canceled before completion.", ex); + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.GatewayTimeout) + { + throw new TimeoutException("OpenMeteo gateway timed out.", ex); + } + } + + private static bool IsTransientStatusCode(System.Net.HttpStatusCode statusCode) + { + var code = (int)statusCode; + return code == 408 || code == 429 || code >= 500; + } + + private static TimeSpan GetRetryDelay(int attempt) + { + return TimeSpan.FromMilliseconds(250 * Math.Pow(2, attempt - 1)); } private static WeatherCurrentDto ParseCurrent(JsonElement current) diff --git a/JobFlow.Infrastructure/HttpClients/JobFlowNamedClient.cs b/JobFlow.Infrastructure/HttpClients/JobFlowNamedClient.cs index df72cf1..6499657 100644 --- a/JobFlow.Infrastructure/HttpClients/JobFlowNamedClient.cs +++ b/JobFlow.Infrastructure/HttpClients/JobFlowNamedClient.cs @@ -3,4 +3,5 @@ public static class JobFlowNamedClient { public const string Brevo = "Brevo"; + public const string OpenMeteo = "OpenMeteo"; } \ No newline at end of file diff --git a/JobFlow.Infrastructure/Middleware/ErrorHandlingMiddleware.cs b/JobFlow.Infrastructure/Middleware/ErrorHandlingMiddleware.cs index 5591a39..0117e20 100644 --- a/JobFlow.Infrastructure/Middleware/ErrorHandlingMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/ErrorHandlingMiddleware.cs @@ -1,24 +1,18 @@ using Microsoft.AspNetCore.Http; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Stripe; -using Twilio.Exceptions; -using System.Text.Json.Serialization; +using System.Net; namespace JobFlow.Infrastructure.Middleware; public class ErrorHandlingMiddleware { - private readonly IHostEnvironment _env; private readonly ILogger _logger; private readonly RequestDelegate _next; - public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; - _env = env; } public async Task Invoke(HttpContext context) @@ -27,6 +21,10 @@ public async Task Invoke(HttpContext context) { await _next(context); } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + _logger.LogInformation("Request was canceled by the client."); + } catch (Exception ex) { _logger.LogError(ex, "An unhandled exception occurred."); @@ -44,14 +42,42 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception context.Response.Clear(); context.Response.ContentType = "application/json"; - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsJsonAsync(new ApiError { Message = "An unexpected error occurred.", Code = "GENERAL_ERROR" }); + + var apiError = new ApiError { Message = "An unexpected error occurred.", Code = "GENERAL_ERROR" }; + + if (exception is TimeoutException) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + apiError = new ApiError + { + Message = "A required upstream service timed out. Please retry shortly.", + Code = "UPSTREAM_TIMEOUT" + }; + } + else if (exception is HttpRequestException httpEx + && (httpEx.StatusCode == HttpStatusCode.GatewayTimeout + || httpEx.StatusCode == HttpStatusCode.BadGateway + || httpEx.StatusCode == HttpStatusCode.ServiceUnavailable)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + apiError = new ApiError + { + Message = "A required upstream service is currently unavailable. Please retry shortly.", + Code = "UPSTREAM_UNAVAILABLE" + }; + } + else + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + } + + await context.Response.WriteAsJsonAsync(apiError); } } public class ApiError { - public string Message { get; set; } - public string Code { get; set; } - public string Details { get; set; } + public string Message { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string Details { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index 31af277..bd4dce4 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -32,7 +32,8 @@ public async Task Invoke(HttpContext context, IUserService userService) path.StartsWith("/api/organizations/retrieve") || path.StartsWith("/api/organization/types") || path.StartsWith("/api/auth/") || - path.StartsWith("/api/client-hub-auth"))) + path.StartsWith("/api/client-hub-auth") || + path.StartsWith("/api/client-hub"))) { await _next(context); return; @@ -99,6 +100,9 @@ public async Task Invoke(HttpContext context, IUserService userService) if (!userResult.IsSuccess) { + if (context.Response.HasStarted) + return; + context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsync("User is not linked to an organization."); return; @@ -111,6 +115,9 @@ public async Task Invoke(HttpContext context, IUserService userService) } catch { + if (context.Response.HasStarted) + return; + context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; }