From d0834775e73b0f6696373bf69675b04d3a25654f Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 17 Mar 2026 15:44:17 -0400 Subject: [PATCH 01/26] chore(GitActions): Add Linter --- .github/workflows/lint.yml | 30 +++++++++++++++++++ JobFlow.API/.github/workflows/lint.yml | 30 +++++++++++++++++++ JobFlow.API/Controllers/AuthController.cs | 2 +- JobFlow.API/Controllers/InvoiceComtroller.cs | 2 +- JobFlow.API/Controllers/JobController.cs | 6 ++-- JobFlow.API/Controllers/PaymentController.cs | 2 +- .../Mappings/InvoiceMappingExtensions.cs | 4 +-- JobFlow.API/Mappings/MapsterConfig.cs | 4 +-- 8 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 JobFlow.API/.github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a20f7e9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +name: Lint + +on: + pull_request: + push: + branches: + - main + - master + - dev + - feature/** + +jobs: + dotnet-format: + name: dotnet format + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore ./JobFlow.API/JobFlow.API.csproj + + - name: Verify formatting + run: dotnet format ./JobFlow.API/JobFlow.API.csproj --verify-no-changes --verbosity minimal diff --git a/JobFlow.API/.github/workflows/lint.yml b/JobFlow.API/.github/workflows/lint.yml new file mode 100644 index 0000000..b6e8709 --- /dev/null +++ b/JobFlow.API/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +name: Lint + +on: + pull_request: + push: + branches: + - main + - master + - develop + - feature/** + +jobs: + dotnet-format: + name: dotnet format + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore ./JobFlow.API/JobFlow.API.csproj + + - name: Verify formatting + run: dotnet format ./JobFlow.API/JobFlow.API.csproj --verify-no-changes --verbosity minimal diff --git a/JobFlow.API/Controllers/AuthController.cs b/JobFlow.API/Controllers/AuthController.cs index ee51f64..7341444 100644 --- a/JobFlow.API/Controllers/AuthController.cs +++ b/JobFlow.API/Controllers/AuthController.cs @@ -210,7 +210,7 @@ public async Task DeleteAccount(string uid) return BadRequest(new { Message = "Failed to delete user.", Error = ex.Message }); } } - + } // ============================================================ diff --git a/JobFlow.API/Controllers/InvoiceComtroller.cs b/JobFlow.API/Controllers/InvoiceComtroller.cs index 46ffa25..4a770dd 100644 --- a/JobFlow.API/Controllers/InvoiceComtroller.cs +++ b/JobFlow.API/Controllers/InvoiceComtroller.cs @@ -72,7 +72,7 @@ public async Task Upsert( var invoiceNumber = await numberGenerator.GenerateAsync(organizationId); var jobInfo = await this._jobService.GetJobByIdAsync(request.JobId, organizationId); - + request.OrganizationClientId = jobInfo.Value.OrganizationClientId; var invoice = request.ToInvoice(invoiceNumber); invoice.OrganizationId = organizationId; diff --git a/JobFlow.API/Controllers/JobController.cs b/JobFlow.API/Controllers/JobController.cs index f8fad23..f557d95 100644 --- a/JobFlow.API/Controllers/JobController.cs +++ b/JobFlow.API/Controllers/JobController.cs @@ -63,7 +63,7 @@ public async Task UpsertJob([FromBody] JobDto model) return Unauthorized("Organization context missing."); var mappedJob = _mapper.Map(model); - + var result = await _jobService.UpsertJobAsync(mappedJob, orgId); if (result.IsFailure) @@ -85,7 +85,7 @@ public async Task DeleteJob(Guid id) return NoContent(); } - + [HttpGet("all")] public async Task GetJobs() { @@ -97,6 +97,6 @@ public async Task GetJobs() return Ok(result.Value); } - + } \ No newline at end of file diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index b08de7c..9befecb 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -311,7 +311,7 @@ public async Task SetDefaultPaymentMethod([FromBody] SetDefaultPa return result.IsSuccess ? Ok() : BadRequest(result.Error); } - + [HttpPost("webhook")] public async Task HandleStripeWebhook() diff --git a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs index 23ad349..1272436 100644 --- a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs +++ b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs @@ -45,8 +45,8 @@ public static InvoiceDto ToDto(this Invoice invoice) AmountPaid = invoice.AmountPaid, BalanceDue = invoice.BalanceDue, Status = invoice.Status, - ExternalPaymentId = invoice.ExternalPaymentId, - + ExternalPaymentId = invoice.ExternalPaymentId, + LineItems = invoice.LineItems.Select(li => li.ToDto()).ToList() }; } diff --git a/JobFlow.API/Mappings/MapsterConfig.cs b/JobFlow.API/Mappings/MapsterConfig.cs index dfed639..8073cba 100644 --- a/JobFlow.API/Mappings/MapsterConfig.cs +++ b/JobFlow.API/Mappings/MapsterConfig.cs @@ -40,10 +40,10 @@ public void Register(TypeAdapterConfig config) //InvoiceLineItem → DTO config.NewConfig(); - + //Job → DTO config.NewConfig(); - + config.NewConfig(); //DTO → Job config.NewConfig(); From fefbb0b6ffc5ffbad71dca4b1de2e51abfc618e2 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 17 Mar 2026 16:35:13 -0400 Subject: [PATCH 02/26] chore(GitActions): Modify Git Workflow --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++ .github/workflows/lint.yml | 30 --------- .github/workflows/master_jobflow-api.yml | 2 +- JobFlow.API/.github/workflows/lint.yml | 30 --------- JobFlow.API/JobFlow.API.csproj | 4 ++ 5 files changed, 87 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 JobFlow.API/.github/workflows/lint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62d4322 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + - dev + - feature/** + +jobs: + dotnet-format: + name: dotnet format + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore ./JobFlow.API/JobFlow.API.csproj + + - name: Verify formatting + run: dotnet format ./JobFlow.API/JobFlow.API.csproj --verify-no-changes --verbosity minimal + + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 + continue-on-error: true + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore ./JobFlow.API/JobFlow.API.csproj + + - name: Build + run: dotnet build ./JobFlow.API/JobFlow.API.csproj -c Release --no-restore + + test: + name: Test + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore ./JobFlow.API/JobFlow.API.csproj + + - name: Test + run: dotnet test ./JobFlow.API/JobFlow.API.csproj -c Release --no-build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index a20f7e9..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint - -on: - pull_request: - push: - branches: - - main - - master - - dev - - feature/** - -jobs: - dotnet-format: - name: dotnet format - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Restore - run: dotnet restore ./JobFlow.API/JobFlow.API.csproj - - - name: Verify formatting - run: dotnet format ./JobFlow.API/JobFlow.API.csproj --verify-no-changes --verbosity minimal diff --git a/.github/workflows/master_jobflow-api.yml b/.github/workflows/master_jobflow-api.yml index 83ad6e7..65aa19c 100644 --- a/.github/workflows/master_jobflow-api.yml +++ b/.github/workflows/master_jobflow-api.yml @@ -21,7 +21,7 @@ jobs: - name: Set up .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '10.0.x' - name: Build with dotnet run: dotnet build JobFlow.API/JobFlow.API.csproj --configuration Release diff --git a/JobFlow.API/.github/workflows/lint.yml b/JobFlow.API/.github/workflows/lint.yml deleted file mode 100644 index b6e8709..0000000 --- a/JobFlow.API/.github/workflows/lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint - -on: - pull_request: - push: - branches: - - main - - master - - develop - - feature/** - -jobs: - dotnet-format: - name: dotnet format - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Restore - run: dotnet restore ./JobFlow.API/JobFlow.API.csproj - - - name: Verify formatting - run: dotnet format ./JobFlow.API/JobFlow.API.csproj --verify-no-changes --verbosity minimal diff --git a/JobFlow.API/JobFlow.API.csproj b/JobFlow.API/JobFlow.API.csproj index 9fc8fd1..001b617 100644 --- a/JobFlow.API/JobFlow.API.csproj +++ b/JobFlow.API/JobFlow.API.csproj @@ -47,4 +47,8 @@ + + + + From 220c9ee86f098fd10a18098accc08d1c421b1013 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 17 Mar 2026 22:42:05 -0400 Subject: [PATCH 03/26] feat(ClientHub): Initial Client Hub Implementation --- .../Controllers/ClientHubAuthController.cs | 133 ++ .../Controllers/ClientHubController.cs | 122 + .../OrganizationClientController.cs | 21 + .../OrganizationClientPortalController.cs | 95 + JobFlow.API/Program.cs | 22 + .../Builders/INotificationMessageBuilder.cs | 2 + .../Builders/NotificationMessageBuilder.cs | 22 + .../Notifications/NotificationService.cs | 6 + .../OrganizationClientPortalService.cs | 117 + .../Services/OrganizationClientService.cs | 55 +- .../ServiceInterfaces/INotificationService.cs | 1 + .../IOrganizationClientPortalService.cs | 14 + .../IOrganizationClientService.cs | 2 + .../Models/OrganizationClientPortalSession.cs | 28 + ...izationClientPortalSessionConfiguration.cs | 27 + ...ganizationClientPortalSessions.Designer.cs | 2083 +++++++++++++++++ ...216_AddOrganizationClientPortalSessions.cs | 60 + .../JobFlowDbContextModelSnapshot.cs | 61 + .../Middleware/FirebaseAuthMiddleware.cs | 20 + 19 files changed, 2890 insertions(+), 1 deletion(-) create mode 100644 JobFlow.API/Controllers/ClientHubAuthController.cs create mode 100644 JobFlow.API/Controllers/ClientHubController.cs create mode 100644 JobFlow.API/Controllers/OrganizationClientPortalController.cs create mode 100644 JobFlow.Business/Services/OrganizationClientPortalService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs create mode 100644 JobFlow.Domain/Models/OrganizationClientPortalSession.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/OrganizationClientPortalSessionConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.cs diff --git a/JobFlow.API/Controllers/ClientHubAuthController.cs b/JobFlow.API/Controllers/ClientHubAuthController.cs new file mode 100644 index 0000000..0321ebd --- /dev/null +++ b/JobFlow.API/Controllers/ClientHubAuthController.cs @@ -0,0 +1,133 @@ +using JobFlow.Business.Extensions; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/client-hub-auth")] +public class ClientHubAuthController : ControllerBase +{ + private readonly IOrganizationClientPortalService _portal; + private readonly IOrganizationClientService _clients; + private readonly IConfiguration _configuration; + + public ClientHubAuthController( + IOrganizationClientPortalService portal, + IOrganizationClientService clients, + IConfiguration configuration) + { + _portal = portal; + _clients = clients; + _configuration = configuration; + } + + [HttpPost("magic-link/request")] + [AllowAnonymous] + public async Task RequestMagicLink([FromBody] ClientHubMagicLinkRequest request) + { + if (string.IsNullOrWhiteSpace(request.EmailAddress)) + return Results.BadRequest("EmailAddress is required."); + + // Anonymous endpoint: resolve OrganizationClients by email. + // If not found, return 200 to avoid account enumeration. + var matchesResult = await _clients.GetOrganizationClientsByEmailAsync(request.EmailAddress); + if (!matchesResult.IsSuccess) + return Results.Ok(); + + var matches = matchesResult.Value; + + // If caller didn't disambiguate and there are multiple matches, return options. + if (!request.OrganizationClientId.HasValue && matches.Count > 1) + { + return Results.Ok(new + { + requiresOrganizationSelection = true, + clients = matches.Select(c => new + { + id = c.Id, + organizationId = c.OrganizationId, + organizationName = c.Organization?.OrganizationName, + firstName = c.FirstName, + lastName = c.LastName, + emailAddress = c.EmailAddress + }) + }); + } + + var target = request.OrganizationClientId.HasValue + ? matches.FirstOrDefault(c => c.Id == request.OrganizationClientId.Value) + : matches.FirstOrDefault(); + + if (target is null) + return Results.Ok(); + + var result = await _portal.SendMagicLinkAsync(target.OrganizationId, target.Id, target.EmailAddress ?? request.EmailAddress); + return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); + } + + [HttpPost("magic-link/redeem")] + [AllowAnonymous] + public async Task RedeemMagicLink([FromBody] ClientHubRedeemMagicLinkRequest request) + { + var result = await _portal.RedeemMagicLinkAsync(request.Token); + if (!result.IsSuccess) + return result.ToProblemDetails(); + + var client = result.Value; + var (accessToken, expiresAt) = IssueClientPortalJwt(client.OrganizationId, client.Id); + + return Results.Ok(new + { + accessToken, + tokenType = "Bearer", + expiresAt, + client = new + { + id = client.Id, + firstName = client.FirstName, + lastName = client.LastName, + emailAddress = client.EmailAddress + } + }); + } + + private (string accessToken, DateTimeOffset expiresAt) IssueClientPortalJwt(Guid organizationId, Guid organizationClientId) + { + var signingKey = _configuration["Auth:ClientPortal:SigningKey"]; + if (string.IsNullOrWhiteSpace(signingKey)) + throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); + + var expiresAt = DateTimeOffset.UtcNow.AddHours(8); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, organizationClientId.ToString()), + new(ClaimTypes.Role, UserRoles.OrganizationClient), + new("organizationId", organizationId.ToString()) + }; + + var creds = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + SecurityAlgorithms.HmacSha256); + + var jwt = new JwtSecurityToken( + issuer: "JobFlow.ClientPortal", + audience: "JobFlow.ClientPortal", + claims: claims, + notBefore: DateTime.UtcNow, + expires: expiresAt.UtcDateTime, + signingCredentials: creds); + + return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt); + } +} + +public record ClientHubMagicLinkRequest(string EmailAddress, Guid? OrganizationClientId = null); +public record ClientHubRedeemMagicLinkRequest(string Token); diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs new file mode 100644 index 0000000..68e9dd9 --- /dev/null +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -0,0 +1,122 @@ +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/client-hub")] +[Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")] +public class ClientHubController : ControllerBase +{ + private readonly IEstimateService _estimates; + private readonly IInvoiceService _invoices; + private readonly IOrganizationClientService _clients; + + public ClientHubController( + IEstimateService estimates, + IInvoiceService invoices, + IOrganizationClientService clients) + { + _estimates = estimates; + _invoices = invoices; + _clients = clients; + } + + [HttpGet("me")] + public async Task Me() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + return Results.Ok(clientResult.Value); + } + + [HttpPut("me")] + public async Task UpdateMe([FromBody] UpdateOrganizationClientRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + var client = clientResult.Value; + if (client.OrganizationId != organizationId) + return Results.Unauthorized(); + + client.FirstName = request.FirstName; + client.LastName = request.LastName; + client.EmailAddress = request.EmailAddress; + client.PhoneNumber = request.PhoneNumber; + client.Address1 = request.Address1; + client.Address2 = request.Address2; + client.City = request.City; + client.State = request.State; + client.ZipCode = request.ZipCode; + + var upsert = await _clients.UpsertClient(client); + return upsert.IsSuccess ? Results.Ok(upsert.Value) : upsert.ToProblemDetails(); + } + + [HttpGet("estimates")] + public async Task GetMyEstimates() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var orgEstimates = await _estimates.GetByOrganizationAsync(organizationId); + if (!orgEstimates.IsSuccess) + return orgEstimates.ToProblemDetails(); + + var mine = orgEstimates.Value.Where(x => x.OrganizationClientId == orgClientId); + return Results.Ok(mine); + } + + [HttpGet("estimates/{id:guid}")] + public async Task GetMyEstimateById(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimates.GetByIdAsync(id); + if (!result.IsSuccess) + return result.ToProblemDetails(); + + var estimate = result.Value; + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != orgClientId) + return Results.NotFound(); + + return Results.Ok(estimate); + } + + [HttpGet("invoices")] + public async Task GetMyInvoices() + { + var orgClientId = HttpContext.GetUserId(); + var result = await _invoices.GetInvoicesByClientAsync(orgClientId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } +} + +public record UpdateOrganizationClientRequest( + string? FirstName, + string? LastName, + string? EmailAddress, + string? PhoneNumber, + string? Address1, + string? Address2, + string? City, + string? State, + string? ZipCode); diff --git a/JobFlow.API/Controllers/OrganizationClientController.cs b/JobFlow.API/Controllers/OrganizationClientController.cs index f40b5e7..dc75830 100644 --- a/JobFlow.API/Controllers/OrganizationClientController.cs +++ b/JobFlow.API/Controllers/OrganizationClientController.cs @@ -15,13 +15,16 @@ namespace JobFlow.API.Controllers; public class OrganizationClientController : ControllerBase { private readonly IOrganizationClientService organizationClientService; + private readonly IOrganizationClientPortalService _clientPortal; private readonly IMapper _mapper; public OrganizationClientController( IOrganizationClientService organizationClientService, + IOrganizationClientPortalService clientPortal, IMapper mapper) { this.organizationClientService = organizationClientService; + _clientPortal = clientPortal; _mapper = mapper; } @@ -78,4 +81,22 @@ public async Task UpsertMultipleClients(IEnumerable var result = await organizationClientService.UpsertMultipleClients(modelList); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + + [HttpPost("{organizationClientId:guid}/send-client-hub-link")] + public async Task SendClientHubLink(Guid organizationClientId) + { + var organizationId = HttpContext.GetOrganizationId(); + var clientResult = await organizationClientService.GetClientById(organizationClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + 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(); + } } \ No newline at end of file diff --git a/JobFlow.API/Controllers/OrganizationClientPortalController.cs b/JobFlow.API/Controllers/OrganizationClientPortalController.cs new file mode 100644 index 0000000..c9a9a8b --- /dev/null +++ b/JobFlow.API/Controllers/OrganizationClientPortalController.cs @@ -0,0 +1,95 @@ +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/client-portal")] +public class OrganizationClientPortalController : ControllerBase +{ + private readonly IOrganizationClientPortalService _portal; + private readonly IConfiguration _configuration; + + public OrganizationClientPortalController(IOrganizationClientPortalService portal, IConfiguration configuration) + { + _portal = portal; + _configuration = configuration; + } + + [HttpPost("send-link/{organizationClientId:guid}")] + [Authorize(Policy = "OrganizationEmployeeOnly")] + public async Task SendLink(Guid organizationClientId, [FromBody] SendMagicLinkRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _portal.SendMagicLinkAsync(organizationId, organizationClientId, request.EmailAddress); + return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); + } + + [HttpPost("redeem")] + [AllowAnonymous] + public async Task Redeem([FromBody] RedeemMagicLinkRequest request) + { + var result = await _portal.RedeemMagicLinkAsync(request.Token); + + if (!result.IsSuccess) + return result.ToProblemDetails(); + + var client = result.Value; + var (accessToken, expiresAt) = IssueClientPortalJwt(client.OrganizationId, client.Id); + + return Results.Ok(new + { + accessToken, + tokenType = "Bearer", + expiresAt, + client = new + { + id = client.Id, + firstName = client.FirstName, + lastName = client.LastName, + emailAddress = client.EmailAddress + } + }); + } + + private (string token, DateTimeOffset expiresAt) IssueClientPortalJwt(Guid organizationId, Guid organizationClientId) + { + var signingKey = _configuration["Auth:ClientPortal:SigningKey"]; + if (string.IsNullOrWhiteSpace(signingKey)) + throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); + + var expiresAt = DateTimeOffset.UtcNow.AddHours(8); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, organizationClientId.ToString()), + new(ClaimTypes.Role, UserRoles.OrganizationClient), + new("organizationId", organizationId.ToString()) + }; + + var creds = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + SecurityAlgorithms.HmacSha256); + + var jwt = new JwtSecurityToken( + issuer: "JobFlow.ClientPortal", + audience: "JobFlow.ClientPortal", + claims: claims, + notBefore: DateTime.UtcNow, + expires: expiresAt.UtcDateTime, + signingCredentials: creds); + + return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt); + } +} + +public record SendMagicLinkRequest(string EmailAddress); +public record RedeemMagicLinkRequest(string Token); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index adc8be5..991b272 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -30,6 +30,10 @@ using QuestPDF.Infrastructure; using Stripe; using System.Text.Json; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Text; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); var env = builder.Environment; @@ -110,6 +114,24 @@ ValidAudience = firebaseProjectId, ValidateLifetime = true }; + }) + .AddJwtBearer("ClientPortalJwt", options => + { + var signingKey = builder.Configuration["Auth:ClientPortal:SigningKey"]; + if (string.IsNullOrWhiteSpace(signingKey)) + throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = "JobFlow.ClientPortal", + ValidateAudience = true, + ValidAudience = "JobFlow.ClientPortal", + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + ClockSkew = TimeSpan.FromMinutes(1) + }; }); builder.Services.AddAuthorization(); diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index 00adbd9..2040211 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -21,4 +21,6 @@ public interface INotificationMessageBuilder NotificationMessage BuildEmployeeInvite(EmployeeInvite invite); NotificationMessage BuildClientEstimateSent(OrganizationClient client, Estimate estimate); + + NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink); } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index af5c1c8..303f413 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -203,4 +203,26 @@ Your estimate is ready. TemplateId = EmailTemplate.OrganizationWelcome }; } + + public NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink) + { + return new NotificationMessage + { + Name = client.ClientFullName(), + Email = client.EmailAddress, + Phone = client.PhoneNumber, + Subject = $"Your {client.Organization?.OrganizationName ?? "JobFlow"} Client Portal Link", + Body = $""" + Hello {client.ClientFullName()}, + + Use this link to access your client portal: + {magicLink} + + This link will expire soon. + """, + Sms = "Client portal link: ", + Link = magicLink, + TemplateId = EmailTemplate.OrganizationWelcome + }; + } } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 622ca70..9bf56a8 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -97,6 +97,12 @@ public async Task SendEmployeeInviteNotificationAsync(EmployeeInvite invite) await SendNotificationAsync(message); } + public async Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink) + { + var message = _builder.BuildOrganizationClientPortalMagicLink(client, magicLink); + await SendNotificationAsync(message); + } + public async Task SendOrganizationSubscriptionPaymentFailedNotificationAsync(Organization org) { var message = _builder.BuildOrganizationSubscriptionFailed(org); diff --git a/JobFlow.Business/Services/OrganizationClientPortalService.cs b/JobFlow.Business/Services/OrganizationClientPortalService.cs new file mode 100644 index 0000000..85c24d6 --- /dev/null +++ b/JobFlow.Business/Services/OrganizationClientPortalService.cs @@ -0,0 +1,117 @@ +using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; +using JobFlow.Business.DI; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using MapsterMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class OrganizationClientPortalService : IOrganizationClientPortalService +{ + private readonly ILogger _logger; + private readonly IFrontendSettings _frontend; + private readonly IRepository _clients; + private readonly IRepository _sessions; + private readonly INotificationService _notifications; + private readonly IUnitOfWork _unitOfWork; + + public OrganizationClientPortalService( + ILogger logger, + IUnitOfWork unitOfWork, + INotificationService notifications, + IFrontendSettings frontend) + { + _logger = logger; + _unitOfWork = unitOfWork; + _notifications = notifications; + _frontend = frontend; + + _clients = unitOfWork.RepositoryOf(); + _sessions = unitOfWork.RepositoryOf(); + } + + public async Task SendMagicLinkAsync(Guid organizationId, Guid organizationClientId, string emailAddress) + { + if (organizationId == Guid.Empty || organizationClientId == Guid.Empty) + return Result.Failure(Error.Failure("OrganizationClientPortal", "Organization and client are required.")); + + if (string.IsNullOrWhiteSpace(emailAddress)) + return Result.Failure(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(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.")); + + 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 = $"{_frontend.BaseUrl}/client-hub/auth?token={token}"; + + await _notifications.SendOrganizationClientPortalMagicLinkAsync(client, url); + + return Result.Success(); + } + + public async Task> RedeemMagicLinkAsync(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return Result.Failure(Error.Failure("OrganizationClientPortal", "Token is required.")); + + var tokenHash = HashToken(token); + + var session = await _sessions.Query() + .FirstOrDefaultAsync(x => x.TokenHash == tokenHash); + + if (session is null) + return Result.Failure(Error.Failure("OrganizationClientPortal", "Invalid or expired link.")); + + if (session.RedeemedAt.HasValue || session.ExpiresAt <= DateTimeOffset.UtcNow) + return Result.Failure(Error.Failure("OrganizationClientPortal", "Invalid or expired link.")); + + session.RedeemedAt = DateTimeOffset.UtcNow; + _sessions.Update(session); + await _unitOfWork.SaveChangesAsync(); + + var client = await _clients.Query() + .Include(x => x.Organization) + .FirstOrDefaultAsync(x => x.Id == session.OrganizationClientId && x.OrganizationId == session.OrganizationId); + + if (client is null) + return Result.Failure(Error.NotFound("OrganizationClientPortal", "Client not found.")); + + return Result.Success(client); + } + + private static string HashToken(string token) + { + var bytes = Encoding.UTF8.GetBytes(token); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/JobFlow.Business/Services/OrganizationClientService.cs b/JobFlow.Business/Services/OrganizationClientService.cs index ac1b7df..693646d 100644 --- a/JobFlow.Business/Services/OrganizationClientService.cs +++ b/JobFlow.Business/Services/OrganizationClientService.cs @@ -16,14 +16,21 @@ public class OrganizationClientService : IOrganizationClientService private readonly ILogger logger; private readonly IRepository organizationClient; private readonly IOnboardingService onboardingService; + private readonly IOrganizationClientPortalService _clientPortal; private readonly IUnitOfWork unitOfWork; private readonly IMapper _mapper; - public OrganizationClientService(ILogger logger, IUnitOfWork unitOfWork, IOnboardingService onboardingService, IMapper mapper) + public OrganizationClientService( + ILogger logger, + IUnitOfWork unitOfWork, + IOnboardingService onboardingService, + IOrganizationClientPortalService clientPortal, + IMapper mapper) { this.logger = logger; this.unitOfWork = unitOfWork; this.onboardingService = onboardingService; + _clientPortal = clientPortal; organizationClient = this.unitOfWork.RepositoryOf(); _mapper = mapper; } @@ -66,6 +73,40 @@ public async Task> GetClientById(Guid clientId) return Result.Success(client); } + public async Task> GetOrganizationClientByEmailAsync(string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + return Result.Failure(OrganizationClientErrors.NoClientFound); + + var normalized = emailAddress.Trim().ToLowerInvariant(); + + var match = await organizationClient.Query() + .AsNoTracking() + .FirstOrDefaultAsync(c => c.EmailAddress != null && c.EmailAddress.ToLower() == normalized); + + return match is null + ? Result.Failure(OrganizationClientErrors.NoClientFound) + : Result.Success(match); + } + + public async Task>> GetOrganizationClientsByEmailAsync(string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + return Result.Failure>(OrganizationClientErrors.NoClientFound); + + var normalized = emailAddress.Trim().ToLowerInvariant(); + + var matches = await organizationClient.Query() + .AsNoTracking() + .Include(c => c.Organization) + .Where(c => c.EmailAddress != null && c.EmailAddress.ToLower() == normalized) + .ToListAsync(); + + return matches.Count == 0 + ? Result.Failure>(OrganizationClientErrors.NoClientFound) + : Result.Success>(matches); + } + public async Task> UpsertClient(OrganizationClient model) { var exists = await organizationClient.Query() @@ -88,6 +129,18 @@ await onboardingService.MarkStepCompleteAsync( model.OrganizationId, OnboardingStepKeys.CreateCustomer ); + + if (!string.IsNullOrWhiteSpace(model.EmailAddress)) + { + try + { + await _clientPortal.SendMagicLinkAsync(model.OrganizationId, model.Id, model.EmailAddress); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send client portal magic link for OrganizationClient {ClientId}", model.Id); + } + } } return Result.Success(model); } diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 2542e54..7256143 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -18,6 +18,7 @@ public interface INotificationService Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job); Task SendClientEstimateSentNotificationAsync(OrganizationClient client, Estimate estimate); + Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink); // Employee notifications Task SendEmployeeInviteNotificationAsync(EmployeeInvite invite); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs new file mode 100644 index 0000000..1934285 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientPortalService.cs @@ -0,0 +1,14 @@ +using JobFlow.Domain; +using JobFlow.Domain.Models; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IOrganizationClientPortalService +{ + Task SendMagicLinkAsync(Guid organizationId, Guid organizationClientId, string emailAddress); + + /// + /// Validates the token and returns the OrganizationClient if valid. + /// + Task> RedeemMagicLinkAsync(string token); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs index 14bd37b..7f22e19 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs @@ -8,6 +8,8 @@ public interface IOrganizationClientService Task> GetClientById(Guid clientId); Task>> GetAllClients(); Task>> GetAllClientsByOrganizationId(Guid organizationId); + Task> GetOrganizationClientByEmailAsync(string emailAddress); + Task>> GetOrganizationClientsByEmailAsync(string emailAddress); Task> UpsertClient(OrganizationClient model); Task>> UpsertMultipleClients(IEnumerable modelList); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/OrganizationClientPortalSession.cs b/JobFlow.Domain/Models/OrganizationClientPortalSession.cs new file mode 100644 index 0000000..ec0d4e6 --- /dev/null +++ b/JobFlow.Domain/Models/OrganizationClientPortalSession.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; + +namespace JobFlow.Domain.Models; + +public class OrganizationClientPortalSession : Entity +{ + public Guid OrganizationId { get; set; } + public Guid OrganizationClientId { get; set; } + + public string EmailAddress { get; set; } = string.Empty; + + public string TokenHash { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? RedeemedAt { get; set; } + + public OrganizationClient? OrganizationClient { get; set; } + + public bool IsExpired => ExpiresAt <= DateTimeOffset.UtcNow; + public bool IsRedeemed => RedeemedAt.HasValue; + + public static string GenerateToken() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationClientPortalSessionConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationClientPortalSessionConfiguration.cs new file mode 100644 index 0000000..699f082 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationClientPortalSessionConfiguration.cs @@ -0,0 +1,27 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class OrganizationClientPortalSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.OrganizationId).IsRequired(); + builder.Property(x => x.OrganizationClientId).IsRequired(); + builder.Property(x => x.EmailAddress).HasMaxLength(320).IsRequired(); + builder.Property(x => x.TokenHash).HasMaxLength(128).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.TokenHash).IsUnique(); + + builder.HasOne(x => x.OrganizationClient) + .WithMany() + .HasForeignKey(x => x.OrganizationClientId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.Designer.cs new file mode 100644 index 0000000..ca1cf90 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.Designer.cs @@ -0,0 +1,2083 @@ +// +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("20260317214216_AddOrganizationClientPortalSessions")] + partial class AddOrganizationClientPortalSessions + { + /// + 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("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("EventType") + .HasColumnType("int"); + + 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("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + 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("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("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("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("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + 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("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + 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.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("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("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("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("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("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("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("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("EmployeeId") + .HasColumnType("uniqueidentifier"); + + 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("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("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("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("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + 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("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + 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("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("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("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + 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("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + 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("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + 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("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("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("Invoices") + .HasForeignKey("OrderId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentOrders"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Navigation("LineItems"); + }); + + 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/20260317214216_AddOrganizationClientPortalSessions.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.cs new file mode 100644 index 0000000..3c5e139 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260317214216_AddOrganizationClientPortalSessions.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddOrganizationClientPortalSessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationClientPortalSession", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationClientId = table.Column(type: "uniqueidentifier", nullable: false), + EmailAddress = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: false), + TokenHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + RedeemedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationClientPortalSession", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationClientPortalSession_OrganizationClient_OrganizationClientId", + column: x => x.OrganizationClientId, + principalTable: "OrganizationClient", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationClientPortalSession_OrganizationClientId", + table: "OrganizationClientPortalSession", + column: "OrganizationClientId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationClientPortalSession_TokenHash", + table: "OrganizationClientPortalSession", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationClientPortalSession"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index c036724..94d1450 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1141,6 +1141,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + 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") @@ -1841,6 +1891,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index b3619ed..7ee2d4f 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -37,6 +37,26 @@ public async Task Invoke(HttpContext context, IUserService userService) var token = authHeader.Substring("Bearer ".Length); + // If this is a locally-issued Client Portal JWT, do not attempt Firebase verification. + // The JwtBearer handler for the ClientPortalJwt scheme will validate and populate claims. + try + { + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + if (handler.CanReadToken(token)) + { + var jwt = handler.ReadJwtToken(token); + if (string.Equals(jwt.Issuer, "JobFlow.ClientPortal", StringComparison.Ordinal)) + { + await _next(context); + return; + } + } + } + catch + { + // ignore and fall back to Firebase verification + } + try { var decodedToken = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(token); From f6f149f50ae9023839e6850faacfc277966b5099 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Wed, 18 Mar 2026 01:14:47 -0400 Subject: [PATCH 04/26] chore(refinement): Tighten up api code --- .../Controllers/ClientHubController.cs | 20 +++ .../OrganizationClientController.cs | 12 +- .../ModelErrors/EstimateErrors.cs | 3 + JobFlow.Business/Services/EstimateService.cs | 39 +++++ .../Services/OrganizationClientService.cs | 41 ++++- .../ServiceInterfaces/IEstimateService.cs | 3 + .../IOrganizationClientService.cs | 2 + JobFlow.Domain/Models/Entity.cs | 4 +- JobFlow.Domain/Models/ISoftDeletable.cs | 7 + .../JobFlowDbContext.cs | 20 +++ .../20260318000000_AddSoftDeleteColumns.cs | 146 ++++++++++++++++ .../JobFlowDbContextModelSnapshot.cs | 159 ++++++++++++++++++ .../Repository.cs | 48 +++++- 13 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 JobFlow.Domain/Models/ISoftDeletable.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260318000000_AddSoftDeleteColumns.cs diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 68e9dd9..597bba3 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -101,6 +101,26 @@ public async Task GetMyEstimateById(Guid id) return Results.Ok(estimate); } + [HttpPost("estimates/{id:guid}/accept")] + public async Task AcceptEstimate(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimates.AcceptAsync(id, organizationId, orgClientId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("estimates/{id:guid}/decline")] + public async Task DeclineEstimate(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimates.DeclineAsync(id, organizationId, orgClientId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + [HttpGet("invoices")] public async Task GetMyInvoices() { diff --git a/JobFlow.API/Controllers/OrganizationClientController.cs b/JobFlow.API/Controllers/OrganizationClientController.cs index dc75830..4dfcd79 100644 --- a/JobFlow.API/Controllers/OrganizationClientController.cs +++ b/JobFlow.API/Controllers/OrganizationClientController.cs @@ -51,7 +51,8 @@ public async Task GetAllClientsByOrganizationId() [Route("delete")] public async Task DeleteClient(Guid clientId) { - var result = await organizationClientService.DeleteClient(clientId); + var organizationId = HttpContext.GetOrganizationId(); + var result = await organizationClientService.DeleteClient(clientId, organizationId); return result.IsSuccess ? Results.Ok(result) : result.ToProblemDetails(); } @@ -99,4 +100,13 @@ public async Task SendClientHubLink(Guid organizationClientId) var result = await _clientPortal.SendMagicLinkAsync(organizationId, organizationClientId, clientResult.Value.EmailAddress); return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); } + + [HttpPost] + [Route("restore")] + public async Task RestoreClient(Guid clientId) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await organizationClientService.RestoreClient(clientId, organizationId); + return result.IsSuccess ? Results.Ok(result) : result.ToProblemDetails(); + } } \ No newline at end of file diff --git a/JobFlow.Business/ModelErrors/EstimateErrors.cs b/JobFlow.Business/ModelErrors/EstimateErrors.cs index 8542083..c41a284 100644 --- a/JobFlow.Business/ModelErrors/EstimateErrors.cs +++ b/JobFlow.Business/ModelErrors/EstimateErrors.cs @@ -16,4 +16,7 @@ public static class EstimateErrors public static readonly Error PublicLinkExpired = Error.NotFound("Estimate.PublicLinkExpired", "The estimate link has expired."); + + public static readonly Error CannotRespondInCurrentStatus = + Error.Conflict("Estimate.CannotRespondInCurrentStatus", "Only sent estimates can be accepted or declined."); } \ No newline at end of file diff --git a/JobFlow.Business/Services/EstimateService.cs b/JobFlow.Business/Services/EstimateService.cs index 7300348..13f1558 100644 --- a/JobFlow.Business/Services/EstimateService.cs +++ b/JobFlow.Business/Services/EstimateService.cs @@ -221,6 +221,45 @@ public async Task> GetPublicPdfAsync(string token) return Result.Success(pdf); } + public Task> AcceptAsync(Guid id, Guid organizationId, Guid organizationClientId) + { + return RespondAsync(id, organizationId, organizationClientId, EstimateStatus.Accepted); + } + + public Task> DeclineAsync(Guid id, Guid organizationId, Guid organizationClientId) + { + return RespondAsync(id, organizationId, organizationClientId, EstimateStatus.Declined); + } + + private async Task> RespondAsync( + Guid id, + Guid organizationId, + Guid organizationClientId, + EstimateStatus newStatus) + { + var estimate = await estimates.Query() + .Include(x => x.OrganizationClient) + .Include(x => x.LineItems) + .FirstOrDefaultAsync(x => x.Id == id); + + if (estimate == null) + return Result.Failure(EstimateErrors.NotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure(EstimateErrors.NotFound); + + if (estimate.Status != EstimateStatus.Sent) + return Result.Failure(EstimateErrors.CannotRespondInCurrentStatus); + + estimate.Status = newStatus; + estimate.UpdatedAt = DateTimeOffset.UtcNow; + + estimates.Update(estimate); + await unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(estimate)); + } + private static void RecalculateTotals(Estimate estimate) { estimate.Subtotal = Math.Round(estimate.LineItems.Sum(x => x.Total), 2); diff --git a/JobFlow.Business/Services/OrganizationClientService.cs b/JobFlow.Business/Services/OrganizationClientService.cs index 693646d..808b130 100644 --- a/JobFlow.Business/Services/OrganizationClientService.cs +++ b/JobFlow.Business/Services/OrganizationClientService.cs @@ -37,8 +37,27 @@ public OrganizationClientService( public async Task DeleteClient(Guid clientId) { - var clientToDelete = await organizationClient.Query().FirstOrDefaultAsync(client => client.Id == clientId); - if (clientToDelete == null) return Result.Failure(OrganizationClientErrors.NoClientFound); + var clientToDelete = await organizationClient.Query() + .FirstOrDefaultAsync(client => client.Id == clientId); + + if (clientToDelete == null) + return Result.Failure(OrganizationClientErrors.NoClientFound); + + var clientName = clientToDelete.ClientFullName(); + organizationClient.Remove(clientToDelete); + await unitOfWork.SaveChangesAsync(); + + return Result.Success($"{clientName} was successfully removed."); + } + + public async Task DeleteClient(Guid clientId, Guid organizationId) + { + var clientToDelete = await organizationClient.Query() + .FirstOrDefaultAsync(client => client.Id == clientId && client.OrganizationId == organizationId); + + if (clientToDelete == null) + return Result.Failure(OrganizationClientErrors.NoClientFound); + var clientName = clientToDelete.ClientFullName(); organizationClient.Remove(clientToDelete); await unitOfWork.SaveChangesAsync(); @@ -160,4 +179,22 @@ public async Task>> UpsertMultipleClients await unitOfWork.SaveChangesAsync(); return Result.Success>(modelList); } + + public async Task RestoreClient(Guid clientId, Guid organizationId) + { + var clientToRestore = await organizationClient.Query() + .IgnoreQueryFilters() + .FirstOrDefaultAsync(client => client.Id == clientId && client.OrganizationId == organizationId); + + if (clientToRestore == null) + return Result.Failure(OrganizationClientErrors.NoClientFound); + + clientToRestore.IsActive = true; + clientToRestore.DeactivatedAtUtc = null; + + organizationClient.Update(clientToRestore); + await unitOfWork.SaveChangesAsync(); + + return Result.Success($"{clientToRestore.ClientFullName()} was successfully restored."); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEstimateService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEstimateService.cs index 43e1150..740657e 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IEstimateService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IEstimateService.cs @@ -16,4 +16,7 @@ public interface IEstimateService Task> GetByPublicTokenAsync(string token); Task> GetPublicPdfAsync(string token); + + Task> AcceptAsync(Guid id, Guid organizationId, Guid organizationClientId); + Task> DeclineAsync(Guid id, Guid organizationId, Guid organizationClientId); } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs index 7f22e19..8b339e2 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationClientService.cs @@ -5,6 +5,7 @@ namespace JobFlow.Business.Services.ServiceInterfaces; public interface IOrganizationClientService { Task DeleteClient(Guid clientId); + Task DeleteClient(Guid clientId, Guid organizationId); Task> GetClientById(Guid clientId); Task>> GetAllClients(); Task>> GetAllClientsByOrganizationId(Guid organizationId); @@ -12,4 +13,5 @@ public interface IOrganizationClientService Task>> GetOrganizationClientsByEmailAsync(string emailAddress); Task> UpsertClient(OrganizationClient model); Task>> UpsertMultipleClients(IEnumerable modelList); + Task RestoreClient(Guid clientId, Guid organizationId); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Entity.cs b/JobFlow.Domain/Models/Entity.cs index 80ba5b9..85699ab 100644 --- a/JobFlow.Domain/Models/Entity.cs +++ b/JobFlow.Domain/Models/Entity.cs @@ -1,10 +1,12 @@ namespace JobFlow.Domain.Models; -public abstract class Entity +public abstract class Entity : ISoftDeletable { public Guid Id { get; set; } public string? CreatedBy { get; set; } public string? UpdatedBy { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } + public bool IsActive { get; set; } = true; + public DateTime? DeactivatedAtUtc { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/ISoftDeletable.cs b/JobFlow.Domain/Models/ISoftDeletable.cs new file mode 100644 index 0000000..c113ce9 --- /dev/null +++ b/JobFlow.Domain/Models/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Domain.Models; + +public interface ISoftDeletable +{ + bool IsActive { get; set; } + DateTime? DeactivatedAtUtc { get; set; } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 5787db8..91b2988 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -1,4 +1,6 @@ using JobFlow.Domain.Models; +using System.Linq.Expressions; +using JobFlow.Domain.Models; using Microsoft.EntityFrameworkCore; namespace JobFlow.Infrastructure.Persistence; @@ -22,5 +24,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Automatically applies all IEntityTypeConfiguration implementations in the assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(JobFlowDbContext).Assembly); + + ApplySoftDeleteQueryFilters(modelBuilder); + } + + private static void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) + continue; + + var parameter = Expression.Parameter(entityType.ClrType, "entity"); + var isActiveProperty = Expression.Property(parameter, nameof(ISoftDeletable.IsActive)); + var isActiveFilter = Expression.Equal(isActiveProperty, Expression.Constant(true)); + var lambda = Expression.Lambda(isActiveFilter, parameter); + + modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda); + } } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260318000000_AddSoftDeleteColumns.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260318000000_AddSoftDeleteColumns.cs new file mode 100644 index 0000000..604ad68 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260318000000_AddSoftDeleteColumns.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using JobFlow.Infrastructure.Persistence; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations; + +/// +[DbContext(typeof(JobFlowDbContext))] +[Migration("20260318000000_AddSoftDeleteColumns")] +public partial class AddSoftDeleteColumns : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DECLARE @schema sysname; + DECLARE @table sysname; + DECLARE @sql nvarchar(max); + + DECLARE cur CURSOR FAST_FORWARD FOR + SELECT DISTINCT c.TABLE_SCHEMA, c.TABLE_NAME + FROM INFORMATION_SCHEMA.COLUMNS c + WHERE c.COLUMN_NAME = 'CreatedAt' + AND c.TABLE_NAME <> '__EFMigrationsHistory'; + + OPEN cur; + FETCH NEXT FROM cur INTO @schema, @table; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @sql = N''; + + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + AND TABLE_NAME = @table + AND COLUMN_NAME = 'IsActive' + ) + BEGIN + SET @sql += N'ALTER TABLE [' + @schema + N'].[' + @table + N'] ADD [IsActive] bit NOT NULL CONSTRAINT [DF_' + @table + N'_IsActive] DEFAULT(1);'; + END + + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + AND TABLE_NAME = @table + AND COLUMN_NAME = 'DeactivatedAtUtc' + ) + BEGIN + SET @sql += N'ALTER TABLE [' + @schema + N'].[' + @table + N'] ADD [DeactivatedAtUtc] datetime2 NULL;'; + END + + IF LEN(@sql) > 0 + BEGIN + EXEC sp_executesql @sql; + END + + FETCH NEXT FROM cur INTO @schema, @table; + END + + CLOSE cur; + DEALLOCATE cur; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DECLARE @schema sysname; + DECLARE @table sysname; + DECLARE @constraintName sysname; + DECLARE @sql nvarchar(max); + + DECLARE cur CURSOR FAST_FORWARD FOR + SELECT DISTINCT c.TABLE_SCHEMA, c.TABLE_NAME + FROM INFORMATION_SCHEMA.COLUMNS c + WHERE c.COLUMN_NAME = 'CreatedAt' + AND c.TABLE_NAME <> '__EFMigrationsHistory'; + + OPEN cur; + FETCH NEXT FROM cur INTO @schema, @table; + + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @sql = N''; + + SELECT @constraintName = dc.name + FROM sys.default_constraints dc + INNER JOIN sys.columns col + ON col.default_object_id = dc.object_id + INNER JOIN sys.tables t + ON t.object_id = col.object_id + INNER JOIN sys.schemas s + ON s.schema_id = t.schema_id + WHERE s.name = @schema + AND t.name = @table + AND col.name = 'IsActive'; + + IF @constraintName IS NOT NULL + BEGIN + SET @sql += N'ALTER TABLE [' + @schema + N'].[' + @table + N'] DROP CONSTRAINT [' + @constraintName + N'];'; + END + + IF EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + AND TABLE_NAME = @table + AND COLUMN_NAME = 'IsActive' + ) + BEGIN + SET @sql += N'ALTER TABLE [' + @schema + N'].[' + @table + N'] DROP COLUMN [IsActive];'; + END + + IF EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + AND TABLE_NAME = @table + AND COLUMN_NAME = 'DeactivatedAtUtc' + ) + BEGIN + SET @sql += N'ALTER TABLE [' + @schema + N'].[' + @table + N'] DROP COLUMN [DeactivatedAtUtc];'; + END + + IF LEN(@sql) > 0 + BEGIN + EXEC sp_executesql @sql; + END + + SET @constraintName = NULL; + FETCH NEXT FROM cur INTO @schema, @table; + END + + CLOSE cur; + DEALLOCATE cur; + """); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 94d1450..86b31eb 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -46,6 +46,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("JobId") .HasColumnType("uniqueidentifier"); @@ -128,9 +134,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); @@ -195,6 +207,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Title") .HasColumnType("nvarchar(max)"); @@ -243,9 +261,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); @@ -295,6 +319,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Email") .HasMaxLength(255) .HasColumnType("nvarchar(255)"); @@ -378,6 +405,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Email") .IsRequired() .HasMaxLength(255) @@ -395,6 +425,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(128) .HasColumnType("uniqueidentifier"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("LastName") .IsRequired() .HasMaxLength(30) @@ -481,6 +514,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Description") .HasColumnType("nvarchar(max)"); @@ -489,6 +525,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("nvarchar(50)"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Notes") .HasColumnType("nvarchar(max)"); @@ -555,12 +594,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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) @@ -608,10 +653,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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) @@ -657,6 +708,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("DueDate") .HasColumnType("datetime2"); @@ -671,6 +725,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("nvarchar(50)"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("OrderId") .HasColumnType("uniqueidentifier"); @@ -720,6 +777,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Description") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -727,6 +787,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("InvoiceId") .HasColumnType("uniqueidentifier"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Quantity") .HasColumnType("int"); @@ -758,6 +821,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("LastSequence") .HasColumnType("int"); @@ -794,6 +863,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Latitude") .HasColumnType("float"); @@ -838,6 +913,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DaysOfWeekMask") .HasColumnType("int"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Duration") .HasColumnType("time"); @@ -891,9 +969,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); @@ -945,6 +1029,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("IsRead") .HasColumnType("bit"); @@ -981,6 +1071,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Notes") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -1033,6 +1129,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("DefaultTaxRate") .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); @@ -1046,6 +1145,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HasFreeAccount") .HasColumnType("bit"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("IsStripeConnected") .HasColumnType("bit"); @@ -1107,12 +1209,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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)"); @@ -1153,6 +1261,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("EmailAddress") .IsRequired() .HasMaxLength(320) @@ -1161,6 +1272,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExpiresAt") .HasColumnType("datetimeoffset"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("OrganizationClientId") .HasColumnType("uniqueidentifier"); @@ -1206,6 +1320,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("IsCompleted") .HasColumnType("bit"); @@ -1243,6 +1363,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1278,6 +1401,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("TypeName") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -1315,6 +1444,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CustomerId") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("EntityId") .HasColumnType("uniqueidentifier"); @@ -1328,6 +1460,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("InvoiceId") .HasColumnType("uniqueidentifier"); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("PaidAt") .HasColumnType("datetime2"); @@ -1376,10 +1511,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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) @@ -1421,6 +1562,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + b.Property("Description") .HasMaxLength(1000) .HasColumnType("nvarchar(1000)"); @@ -1433,6 +1577,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("decimal(18,4)") .HasDefaultValue(1.0m); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("IsTaxable") .HasColumnType("bit"); @@ -1496,6 +1643,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .HasColumnType("nvarchar(max)"); + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("PaymentProfileId") .HasColumnType("uniqueidentifier"); @@ -1568,12 +1721,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); diff --git a/JobFlow.Infrastructure.Persistence/Repository.cs b/JobFlow.Infrastructure.Persistence/Repository.cs index d8469f6..27c77a5 100644 --- a/JobFlow.Infrastructure.Persistence/Repository.cs +++ b/JobFlow.Infrastructure.Persistence/Repository.cs @@ -1,6 +1,8 @@ -using System.Linq.Expressions; -using JobFlow.Domain; +using JobFlow.Domain; +using JobFlow.Domain.Models; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using ISoftDeletable = JobFlow.Domain.Models.ISoftDeletable; namespace JobFlow.Infrastructure.Persistence; @@ -73,22 +75,64 @@ public Task UpdateRangeAsync(IEnumerable items) // 🔹 Delete public void Remove(T item) { + if (item is ISoftDeletable softDeletable) + { + softDeletable.IsActive = false; + softDeletable.DeactivatedAtUtc = DateTime.UtcNow; + _dbSet.Update(item); + return; + } + _dbSet.Remove(item); } public Task RemoveAsync(T item) { + if (item is ISoftDeletable softDeletable) + { + softDeletable.IsActive = false; + softDeletable.DeactivatedAtUtc = DateTime.UtcNow; + _dbSet.Update(item); + return Task.CompletedTask; + } + _dbSet.Remove(item); return Task.CompletedTask; } public void RemoveRange(IEnumerable items) { + if (typeof(ISoftDeletable).IsAssignableFrom(typeof(T))) + { + foreach (var item in items) + { + if (item is not ISoftDeletable softDeletable) continue; + softDeletable.IsActive = false; + softDeletable.DeactivatedAtUtc = DateTime.UtcNow; + } + + _dbSet.UpdateRange(items); + return; + } + _dbSet.RemoveRange(items); } public Task RemoveRangeAsync(IEnumerable items) { + if (typeof(ISoftDeletable).IsAssignableFrom(typeof(T))) + { + foreach (var item in items) + { + if (item is not ISoftDeletable softDeletable) continue; + softDeletable.IsActive = false; + softDeletable.DeactivatedAtUtc = DateTime.UtcNow; + } + + _dbSet.UpdateRange(items); + return Task.CompletedTask; + } + _dbSet.RemoveRange(items); return Task.CompletedTask; } From 153d023d39deeb1754dfb1ef68836b1d8023d3cf Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Wed, 18 Mar 2026 17:52:44 -0400 Subject: [PATCH 05/26] feat(reCAPTCHA): Replace reCAPTCHA with Turnstile --- JobFlow.API/Controllers/AuthController.cs | 1 + JobFlow.API/Controllers/EmailController.cs | 46 +++++-- JobFlow.API/JobFlow.API.csproj | 3 + .../Services/EstimateService.cs | 0 .../Turnstile/CaptchaVerificationResult.cs | 9 ++ .../Turnstile/ICaptchaVerificationService.cs | 10 ++ .../Turnstile/TurnstileOptions.cs | 7 ++ .../Turnstile/TurnstileVerificationService.cs | 114 ++++++++++++++++++ JobFlow.API/Program.cs | 11 +- JobFlow.API/appsettings.Development.json | 4 + JobFlow.API/appsettings.json | 3 + JobFlow.Business/Services/EstimateService.cs | 10 +- .../Middleware/FirebaseAuthMiddleware.cs | 14 ++- 13 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 JobFlow.API/JobFlow.Business/Services/EstimateService.cs create mode 100644 JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs create mode 100644 JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs create mode 100644 JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs create mode 100644 JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs diff --git a/JobFlow.API/Controllers/AuthController.cs b/JobFlow.API/Controllers/AuthController.cs index 7341444..9140a19 100644 --- a/JobFlow.API/Controllers/AuthController.cs +++ b/JobFlow.API/Controllers/AuthController.cs @@ -29,6 +29,7 @@ public AuthController( // ============================================================ [HttpPost] [Route("login-with-firebase")] + [AllowAnonymous] public async Task LoginWithFirebase([FromBody] TokenDto model) { try diff --git a/JobFlow.API/Controllers/EmailController.cs b/JobFlow.API/Controllers/EmailController.cs index d85170f..86da4df 100644 --- a/JobFlow.API/Controllers/EmailController.cs +++ b/JobFlow.API/Controllers/EmailController.cs @@ -1,11 +1,13 @@ using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; -using JobFlow.Infrastructure.ExternalServices.ReCAPTCHA; +using JobFlow.Infrastructure.ExternalServices.Turnstile; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; [ApiController] +[AllowAnonymous] [Route("api/email/")] public class EmailController : ControllerBase { @@ -14,10 +16,25 @@ public class EmailController : ControllerBase public async Task SubscribeToNewsletter( [FromBody] NewsletterSubscriptionRequest request, [FromServices] IBrevoService brevoService, - [FromServices] IReCAPTCHAService reCAPTCHAService) + [FromServices] ICaptchaVerificationService captchaService, + CancellationToken cancellationToken) { - var isHuman = await reCAPTCHAService.VerifyTokenAsync(request.CaptchaToken); - if (!isHuman) return BadRequest("reCaptcha validation failed."); + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var verification = await captchaService.VerifyAsync( + request.CaptchaToken, + "newsletter-subscribe", + remoteIp, + cancellationToken); + + if (!verification.IsValid) + { + return BadRequest(new + { + message = "Turnstile validation failed.", + errors = verification.ErrorCodes + }); + } var success = await brevoService.AddContactAsync(request.Email, request.ListId); return success @@ -30,10 +47,25 @@ public async Task SubscribeToNewsletter( public async Task SendContactForm( [FromBody] ContactFormRequest request, [FromServices] IBrevoService brevoService, - [FromServices] IReCAPTCHAService reCAPTCHAService) + [FromServices] ICaptchaVerificationService captchaService, + CancellationToken cancellationToken) { - var isHuman = await reCAPTCHAService.VerifyTokenAsync(request.CaptchaToken); - if (!isHuman) return BadRequest("reCaptcha validation failed."); + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var verification = await captchaService.VerifyAsync( + request.CaptchaToken, + "contact-sales", + remoteIp, + cancellationToken); + + if (!verification.IsValid) + { + return BadRequest(new + { + message = "Turnstile validation failed.", + errors = verification.ErrorCodes + }); + } var success = await brevoService.SendContactEmailAsync(request); return success ? Ok(new { message = "Contact form submitted." }) diff --git a/JobFlow.API/JobFlow.API.csproj b/JobFlow.API/JobFlow.API.csproj index 001b617..2661e8f 100644 --- a/JobFlow.API/JobFlow.API.csproj +++ b/JobFlow.API/JobFlow.API.csproj @@ -49,6 +49,9 @@ + + + diff --git a/JobFlow.API/JobFlow.Business/Services/EstimateService.cs b/JobFlow.API/JobFlow.Business/Services/EstimateService.cs new file mode 100644 index 0000000..e69de29 diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs new file mode 100644 index 0000000..932445e --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class CaptchaVerificationResult +{ + public bool IsValid { get; init; } + public string[] ErrorCodes { get; init; } = Array.Empty(); + public string? Action { get; init; } + public string? Hostname { get; init; } +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs new file mode 100644 index 0000000..9bad494 --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public interface ICaptchaVerificationService +{ + Task VerifyAsync( + string token, + string expectedAction, + string? remoteIp, + CancellationToken cancellationToken = default); +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs new file mode 100644 index 0000000..1c19b13 --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class TurnstileOptions +{ + public string SecretKey { get; set; } = string.Empty; + public string ExpectedHostname { get; set; } = string.Empty; +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs new file mode 100644 index 0000000..b9a065c --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs @@ -0,0 +1,114 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class TurnstileVerificationService : ICaptchaVerificationService +{ + private const string VerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + private readonly HttpClient _httpClient; + private readonly TurnstileOptions _options; + + public TurnstileVerificationService(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task VerifyAsync( + string token, + string expectedAction, + string? remoteIp, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(_options.SecretKey)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["missing-input"] + }; + } + + var form = new Dictionary + { + ["secret"] = _options.SecretKey, + ["response"] = token + }; + + if (!string.IsNullOrWhiteSpace(remoteIp)) + form["remoteip"] = remoteIp; + + using var content = new FormUrlEncodedContent(form); + using var response = await _httpClient.PostAsync(VerifyUrl, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["verification-http-failure"] + }; + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (payload is null || !payload.Success) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = payload?.ErrorCodes ?? Array.Empty(), + Action = payload?.Action, + Hostname = payload?.Hostname + }; + } + + if (!string.Equals(payload.Action, expectedAction, StringComparison.Ordinal)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["action-mismatch"], + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + if (!string.IsNullOrWhiteSpace(_options.ExpectedHostname) && + !string.Equals(payload.Hostname, _options.ExpectedHostname, StringComparison.OrdinalIgnoreCase)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["hostname-mismatch"], + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + return new CaptchaVerificationResult + { + IsValid = true, + ErrorCodes = payload.ErrorCodes ?? Array.Empty(), + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + private sealed class TurnstileVerifyResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("error-codes")] + public string[]? ErrorCodes { get; set; } + + [JsonPropertyName("action")] + public string? Action { get; set; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } + } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 991b272..30e4a2b 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -270,10 +270,12 @@ options.ApiKey = builder.Configuration["BrevoSettings-ApiKey"] ?? ""; }); -builder.Services.Configure(options => -{ - options.SecretKey = builder.Configuration["reCAPTCHA-Api"] ?? ""; -}); +builder.Services.Configure( + builder.Configuration.GetSection("Turnstile")); + +builder.Services.AddHttpClient< + JobFlow.Infrastructure.ExternalServices.Turnstile.ICaptchaVerificationService, + JobFlow.Infrastructure.ExternalServices.Turnstile.TurnstileVerificationService>(); builder.Services.Configure(options => { @@ -291,7 +293,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); -builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); // ============================================================ diff --git a/JobFlow.API/appsettings.Development.json b/JobFlow.API/appsettings.Development.json index 69f099b..86f227f 100644 --- a/JobFlow.API/appsettings.Development.json +++ b/JobFlow.API/appsettings.Development.json @@ -18,5 +18,9 @@ }, "Backend": { "BaseUrl": "https://localhost:44398" + }, + "Turnstile": { + "SecretKey": "YOUR_TURNSTILE_SECRET", + "ExpectedHostname": "localhost" } } diff --git a/JobFlow.API/appsettings.json b/JobFlow.API/appsettings.json index f558e70..a9c4094 100644 --- a/JobFlow.API/appsettings.json +++ b/JobFlow.API/appsettings.json @@ -13,6 +13,9 @@ }, "Backend": { "BaseUrl": "https://api.gojobflow.com" + }, + "Turnstile": { + "ExpectedHostname": "gojobflow.com" } } diff --git a/JobFlow.Business/Services/EstimateService.cs b/JobFlow.Business/Services/EstimateService.cs index 13f1558..7508443 100644 --- a/JobFlow.Business/Services/EstimateService.cs +++ b/JobFlow.Business/Services/EstimateService.cs @@ -16,15 +16,21 @@ public class EstimateService : IEstimateService private readonly IUnitOfWork unitOfWork; private readonly INotificationService notificationService; private readonly IPdfGenerator pdfGenerator; + private readonly IOrganizationClientPortalService clientPortalService; private readonly IRepository estimates; private readonly IRepository clients; - public EstimateService(IUnitOfWork unitOfWork, INotificationService notificationService, IPdfGenerator pdfGenerator) + public EstimateService( + IUnitOfWork unitOfWork, + INotificationService notificationService, + IPdfGenerator pdfGenerator, + IOrganizationClientPortalService clientPortalService) { this.unitOfWork = unitOfWork; this.notificationService = notificationService; this.pdfGenerator = pdfGenerator; + this.clientPortalService = clientPortalService; estimates = unitOfWork.RepositoryOf(); clients = unitOfWork.RepositoryOf(); @@ -173,7 +179,7 @@ public async Task> SendAsync(Guid id, SendEstimateRequest re estimates.Update(estimate); await unitOfWork.SaveChangesAsync(); - await notificationService.SendClientEstimateSentNotificationAsync(client, estimate); + await clientPortalService.SendMagicLinkAsync(estimate.OrganizationId, client.Id, client.EmailAddress ?? string.Empty); var full = await estimates.Query() .Include(x => x.LineItems) diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index 7ee2d4f..c4de7d2 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using FirebaseAdmin.Auth; using JobFlow.Business.Services.ServiceInterfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; namespace JobFlow.Infrastructure.Middleware; @@ -16,13 +17,22 @@ public FirebaseAuthMiddleware(RequestDelegate next) public async Task Invoke(HttpContext context, IUserService userService) { + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() is not null) + { + await _next(context); + return; + } + var path = context.Request.Path.Value?.ToLower(); - // Skip onboarding endpoints + // Skip endpoints that must be reachable without a Firebase bearer token. if (path != null && (path.StartsWith("/api/organizations/register") || path.StartsWith("/api/organizations/retrieve") || - path.StartsWith("/api/organization/types"))) + path.StartsWith("/api/organization/types") || + path.StartsWith("/api/auth/") || + path.StartsWith("/api/client-hub-auth"))) { await _next(context); return; From 40d47bb903db0c640c9f758b136ee0462b8130c8 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 19 Mar 2026 09:00:34 -0400 Subject: [PATCH 06/26] feat(estimate): Add Estimate Revisions --- .../Controllers/ClientHubController.cs | 93 +- JobFlow.API/Hubs/ChatHub.cs | 1 + JobFlow.API/Hubs/NotifierHub.cs | 23 + JobFlow.API/Program.cs | 10 + JobFlow.API/appsettings.Development.json | 4 - JobFlow.API/appsettings.json | 4 - .../ModelErrors/EstimateRevisionErrors.cs | 46 + .../Models/DTOs/EstimateRevisionDtos.cs | 43 + .../Builders/INotificationMessageBuilder.cs | 1 + .../Builders/NotificationMessageBuilder.cs | 23 + .../Notifications/NotificationService.cs | 10 + .../Services/EstimateRevisionService.cs | 210 ++ .../IEstimateRevisionService.cs | 24 + .../ServiceInterfaces/INotificationService.cs | 1 + .../CreateEstimateRevisionRequestValidator.cs | 56 + .../Enums/EstimateRevisionStatus.cs | 10 + JobFlow.Domain/Enums/EstimateStatus.cs | 3 +- JobFlow.Domain/Models/Estimate.cs | 1 + .../Models/EstimateRevisionAttachment.cs | 12 + .../Models/EstimateRevisionRequest.cs | 21 + .../Configurations/EstimateConfiguration.cs | 5 + ...EstimateRevisionAttachmentConfiguration.cs | 19 + .../EstimateRevisionRequestConfiguration.cs | 30 + .../JobFlowDbContext.cs | 2 + ...5731_AddEstimateRevisionTables.Designer.cs | 2397 +++++++++++++++++ ...0260319025731_AddEstimateRevisionTables.cs | 108 + .../JobFlowDbContextModelSnapshot.cs | 155 ++ .../IReCAPTCHASettings.cs | 6 - .../ConfigurationModels/ReCAPTCHASettings.cs | 8 - .../ReCAPTCHA/ReCAPTCHAService.cs | 51 - .../Middleware/FirebaseAuthMiddleware.cs | 19 +- 31 files changed, 3318 insertions(+), 78 deletions(-) create mode 100644 JobFlow.API/Hubs/NotifierHub.cs create mode 100644 JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs create mode 100644 JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs create mode 100644 JobFlow.Business/Services/EstimateRevisionService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs create mode 100644 JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs create mode 100644 JobFlow.Domain/Enums/EstimateRevisionStatus.cs create mode 100644 JobFlow.Domain/Models/EstimateRevisionAttachment.cs create mode 100644 JobFlow.Domain/Models/EstimateRevisionRequest.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs delete mode 100644 JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs delete mode 100644 JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs delete mode 100644 JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 597bba3..7492c0c 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -1,9 +1,12 @@ using JobFlow.API.Extensions; +using JobFlow.API.Hubs; using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace JobFlow.API.Controllers; @@ -13,17 +16,23 @@ namespace JobFlow.API.Controllers; public class ClientHubController : ControllerBase { private readonly IEstimateService _estimates; + private readonly IEstimateRevisionService _estimateRevisions; private readonly IInvoiceService _invoices; private readonly IOrganizationClientService _clients; + private readonly IHubContext _hubContext; public ClientHubController( IEstimateService estimates, + IEstimateRevisionService estimateRevisions, IInvoiceService invoices, - IOrganizationClientService clients) + IOrganizationClientService clients, + IHubContext hubContext) { _estimates = estimates; + _estimateRevisions = estimateRevisions; _invoices = invoices; _clients = clients; + _hubContext = hubContext; } [HttpGet("me")] @@ -121,6 +130,84 @@ public async Task DeclineEstimate(Guid id) return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + [HttpPost("estimates/{id:guid}/revision-requests")] + [RequestSizeLimit(55_000_000)] + public async Task RequestEstimateRevision(Guid id, [FromForm] CreateEstimateRevisionFormRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var attachments = new List(); + + if (request.Attachments is not null) + { + foreach (var file in request.Attachments) + { + if (file.Length <= 0) + continue; + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream); + + attachments.Add(new EstimateRevisionAttachmentUpload( + file.FileName, + file.ContentType, + stream.ToArray(), + file.Length)); + } + } + + var result = await _estimateRevisions.CreateAsync( + id, + organizationId, + orgClientId, + new CreateEstimateRevisionRequest(request.Message ?? string.Empty, attachments)); + + if (!result.IsSuccess) + return result.ToProblemDetails(); + + await _hubContext.Clients.Group($"org:{organizationId}:dashboard") + .SendAsync("EstimateRevisionRequested", new + { + estimateId = id, + revisionRequestId = result.Value.Id, + revisionNumber = result.Value.RevisionNumber, + requestedAt = result.Value.RequestedAt, + message = result.Value.RequestMessage + }); + + return Results.Ok(result.Value); + } + + [HttpGet("estimates/{id:guid}/revision-requests")] + public async Task GetEstimateRevisionRequests(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimateRevisions.GetByEstimateAsync(id, organizationId, orgClientId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("estimates/{estimateId:guid}/revision-requests/{revisionRequestId:guid}/attachments/{attachmentId:guid}")] + public async Task DownloadEstimateRevisionAttachment(Guid estimateId, Guid revisionRequestId, Guid attachmentId) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _estimateRevisions.GetAttachmentAsync( + estimateId, + revisionRequestId, + attachmentId, + organizationId, + orgClientId); + + if (!result.IsSuccess) + return result.ToProblemDetails(); + + return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName); + } + [HttpGet("invoices")] public async Task GetMyInvoices() { @@ -140,3 +227,7 @@ public record UpdateOrganizationClientRequest( string? City, string? State, string? ZipCode); + +public record CreateEstimateRevisionFormRequest( + string? Message, + List? Attachments); diff --git a/JobFlow.API/Hubs/ChatHub.cs b/JobFlow.API/Hubs/ChatHub.cs index 7b31c34..303a653 100644 --- a/JobFlow.API/Hubs/ChatHub.cs +++ b/JobFlow.API/Hubs/ChatHub.cs @@ -25,4 +25,5 @@ public async Task LeaveConversation(Guid conversationId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString()); } + } \ No newline at end of file diff --git a/JobFlow.API/Hubs/NotifierHub.cs b/JobFlow.API/Hubs/NotifierHub.cs new file mode 100644 index 0000000..a8b404e --- /dev/null +++ b/JobFlow.API/Hubs/NotifierHub.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace JobFlow.API.Hubs; + +[Authorize] +public class NotifierHub : Hub +{ + public async Task JoinOrganizationDashboard() + { + var organizationId = Context.User?.FindFirst("organizationId")?.Value; + if (Guid.TryParse(organizationId, out var orgId)) + await Groups.AddToGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard"); + } + + public async Task LeaveOrganizationDashboard() + { + var organizationId = Context.User?.FindFirst("organizationId")?.Value; + if (Guid.TryParse(organizationId, out var orgId)) + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard"); + } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 30e4a2b..6bef1be 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -147,6 +147,8 @@ o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); +builder.Services.AddValidatorsFromAssemblyContaining(); + // ============================================================ // SIGNALR // ============================================================ @@ -342,6 +344,13 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.Database.MigrateAsync(); +} + StripeConfiguration.ApiKey = builder.Configuration["StripeSettings-ApiKey"]; if (app.Environment.IsDevelopment()) @@ -367,5 +376,6 @@ app.MapControllers(); app.MapHub("/hubs/chat"); +app.MapHub("/hubs/notifier"); app.Run(); \ No newline at end of file diff --git a/JobFlow.API/appsettings.Development.json b/JobFlow.API/appsettings.Development.json index 86f227f..69f099b 100644 --- a/JobFlow.API/appsettings.Development.json +++ b/JobFlow.API/appsettings.Development.json @@ -18,9 +18,5 @@ }, "Backend": { "BaseUrl": "https://localhost:44398" - }, - "Turnstile": { - "SecretKey": "YOUR_TURNSTILE_SECRET", - "ExpectedHostname": "localhost" } } diff --git a/JobFlow.API/appsettings.json b/JobFlow.API/appsettings.json index a9c4094..b220748 100644 --- a/JobFlow.API/appsettings.json +++ b/JobFlow.API/appsettings.json @@ -7,15 +7,11 @@ }, "AllowedHosts": "*", "KeyVaultUri": "https://katharix-vault.vault.azure.net/", - "WebhookKey": "whsec_449239427e6f306629fdd3cf4a2d4e8157b1817c8ae85de887bd76380a12bf9a", "Frontend": { "BaseUrl": "https://gojobflow.com" }, "Backend": { "BaseUrl": "https://api.gojobflow.com" - }, - "Turnstile": { - "ExpectedHostname": "gojobflow.com" } } diff --git a/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs b/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs new file mode 100644 index 0000000..2c3371c --- /dev/null +++ b/JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs @@ -0,0 +1,46 @@ +namespace JobFlow.Business.ModelErrors; + +public static class EstimateRevisionErrors +{ + public static readonly Error EstimateNotFound = + Error.NotFound("EstimateRevision.EstimateNotFound", "The estimate was not found."); + + public static readonly Error UnauthorizedEstimateAccess = + Error.NotFound("EstimateRevision.UnauthorizedEstimateAccess", "The estimate was not found."); + + public static readonly Error InvalidEstimateStatus = + Error.Conflict("EstimateRevision.InvalidEstimateStatus", "Revisions can only be requested for sent or accepted estimates."); + + public static readonly Error OpenRevisionAlreadyExists = + Error.Conflict("EstimateRevision.OpenRevisionAlreadyExists", "A revision request is already open for this estimate."); + + public static readonly Error MessageRequired = + Error.Validation("EstimateRevision.MessageRequired", "A revision message is required."); + + public static readonly Error MessageTooLong = + Error.Validation("EstimateRevision.MessageTooLong", "Revision message must be 2000 characters or less."); + + public static readonly Error TooManyAttachments = + Error.Validation("EstimateRevision.TooManyAttachments", "A maximum of 5 attachments are allowed per revision request."); + + public static readonly Error AttachmentTooLarge = + Error.Validation("EstimateRevision.AttachmentTooLarge", "Each attachment must be 10 MB or less."); + + public static readonly Error InvalidAttachmentContentType = + Error.Validation("EstimateRevision.InvalidAttachmentContentType", "One or more attachments use an unsupported file type."); + + public static readonly Error InvalidAttachment = + Error.Validation("EstimateRevision.InvalidAttachment", "One or more attachments are invalid."); + + public static readonly Error RevisionRequestNotFound = + Error.NotFound("EstimateRevision.RevisionRequestNotFound", "Revision request was not found."); + + public static readonly Error AttachmentNotFound = + Error.NotFound("EstimateRevision.AttachmentNotFound", "Attachment was not found."); + + public static readonly Error OrganizationNotFound = + Error.NotFound("EstimateRevision.OrganizationNotFound", "Organization was not found."); + + public static readonly Error ClientNotFound = + Error.NotFound("EstimateRevision.ClientNotFound", "Organization client was not found."); +} diff --git a/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs b/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs new file mode 100644 index 0000000..37da905 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs @@ -0,0 +1,43 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public sealed record EstimateRevisionAttachmentUpload( + string FileName, + string ContentType, + byte[] Content, + long SizeBytes +); + +public sealed record CreateEstimateRevisionRequest( + string Message, + IReadOnlyList Attachments +); + +public sealed record EstimateRevisionAttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes +); + +public sealed record EstimateRevisionRequestDto( + Guid Id, + Guid EstimateId, + Guid OrganizationId, + Guid OrganizationClientId, + int RevisionNumber, + EstimateRevisionStatus Status, + string RequestMessage, + string? OrganizationResponseMessage, + DateTimeOffset RequestedAt, + DateTimeOffset? ReviewedAt, + DateTimeOffset? ResolvedAt, + IReadOnlyList Attachments +); + +public sealed record EstimateRevisionAttachmentDownloadDto( + string FileName, + string ContentType, + byte[] Content +); diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index 2040211..f44ec29 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -21,6 +21,7 @@ public interface INotificationMessageBuilder NotificationMessage BuildEmployeeInvite(EmployeeInvite invite); NotificationMessage BuildClientEstimateSent(OrganizationClient client, Estimate estimate); + NotificationMessage BuildOrganizationEstimateRevisionRequested(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink); } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 303f413..1eb3ae6 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -204,6 +204,29 @@ Your estimate is ready. }; } + public NotificationMessage BuildOrganizationEstimateRevisionRequested( + Organization organization, + OrganizationClient client, + Estimate estimate, + string revisionMessage) + { + return new NotificationMessage + { + Name = organization.OrganizationName, + Email = organization.EmailAddress, + Phone = organization.PhoneNumber, + Subject = $"Estimate Revision Requested: {estimate.EstimateNumber}", + Body = $""" + Client {client.ClientFullName()} requested estimate revisions. + + Estimate: {estimate.EstimateNumber} + Message: {revisionMessage} + """, + Sms = $"Estimate revision requested for {estimate.EstimateNumber}.", + TemplateId = EmailTemplate.Default + }; + } + public NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink) { return new NotificationMessage diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 9bf56a8..6312bf7 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -109,6 +109,16 @@ public async Task SendOrganizationSubscriptionPaymentFailedNotificationAsync(Org await SendNotificationAsync(message); } + public async Task SendOrganizationEstimateRevisionRequestedNotificationAsync( + Organization organization, + OrganizationClient client, + Estimate estimate, + string revisionMessage) + { + var message = _builder.BuildOrganizationEstimateRevisionRequested(organization, client, estimate, revisionMessage); + await SendNotificationAsync(message); + } + /// /// Shared helper for sending email and SMS. /// diff --git a/JobFlow.Business/Services/EstimateRevisionService.cs b/JobFlow.Business/Services/EstimateRevisionService.cs new file mode 100644 index 0000000..46cc904 --- /dev/null +++ b/JobFlow.Business/Services/EstimateRevisionService.cs @@ -0,0 +1,210 @@ +using FluentValidation; +using JobFlow.Business.DI; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class EstimateRevisionService : IEstimateRevisionService +{ + private static readonly EstimateRevisionStatus[] OpenStatuses = + [ + EstimateRevisionStatus.Requested, + EstimateRevisionStatus.InReview + ]; + + private readonly IUnitOfWork _unitOfWork; + private readonly INotificationService _notificationService; + private readonly IValidator _validator; + private readonly IRepository _estimates; + private readonly IRepository _revisionRequests; + private readonly IRepository _attachments; + private readonly IRepository _organizations; + private readonly IRepository _clients; + + public EstimateRevisionService( + IUnitOfWork unitOfWork, + INotificationService notificationService, + IValidator validator) + { + _unitOfWork = unitOfWork; + _notificationService = notificationService; + _validator = validator; + + _estimates = unitOfWork.RepositoryOf(); + _revisionRequests = unitOfWork.RepositoryOf(); + _attachments = unitOfWork.RepositoryOf(); + _organizations = unitOfWork.RepositoryOf(); + _clients = unitOfWork.RepositoryOf(); + } + + public async Task> CreateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId, + CreateEstimateRevisionRequest request) + { + var validationResult = await _validator.ValidateAsync(request); + if (!validationResult.IsValid) + return Result.Failure( + Error.Validation("EstimateRevision.ValidationFailed", string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)))); + + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + if (estimate.Status is not (EstimateStatus.Sent or EstimateStatus.Accepted)) + return Result.Failure(EstimateRevisionErrors.InvalidEstimateStatus); + + var hasOpenRevision = await _revisionRequests.Query() + .AnyAsync(x => x.EstimateId == estimateId && OpenStatuses.Contains(x.Status)); + + if (hasOpenRevision) + return Result.Failure(EstimateRevisionErrors.OpenRevisionAlreadyExists); + + var nextRevisionNumber = (await _revisionRequests.Query() + .Where(x => x.EstimateId == estimateId) + .Select(x => (int?)x.RevisionNumber) + .MaxAsync() ?? 0) + 1; + + var revisionRequest = new EstimateRevisionRequest + { + EstimateId = estimateId, + OrganizationId = organizationId, + OrganizationClientId = organizationClientId, + RevisionNumber = nextRevisionNumber, + Status = EstimateRevisionStatus.Requested, + RequestMessage = request.Message.Trim(), + RequestedAt = DateTimeOffset.UtcNow + }; + + if (request.Attachments.Count > 0) + { + foreach (var attachment in request.Attachments) + { + revisionRequest.Attachments.Add(new EstimateRevisionAttachment + { + FileName = attachment.FileName, + ContentType = attachment.ContentType, + FileSizeBytes = attachment.SizeBytes, + FileData = attachment.Content + }); + } + } + + await _revisionRequests.AddAsync(revisionRequest); + + estimate.Status = EstimateStatus.RevisionRequested; + estimate.UpdatedAt = DateTimeOffset.UtcNow; + _estimates.Update(estimate); + + await _unitOfWork.SaveChangesAsync(); + + var organization = await _organizations.Query() + .FirstOrDefaultAsync(x => x.Id == organizationId); + if (organization is null) + return Result.Failure(EstimateRevisionErrors.OrganizationNotFound); + + var client = await _clients.Query() + .FirstOrDefaultAsync(x => x.Id == organizationClientId && x.OrganizationId == organizationId); + if (client is null) + return Result.Failure(EstimateRevisionErrors.ClientNotFound); + + await _notificationService.SendOrganizationEstimateRevisionRequestedNotificationAsync( + organization, + client, + estimate, + revisionRequest.RequestMessage); + + return Result.Success(ToDto(revisionRequest)); + } + + public async Task>> GetByEstimateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId) + { + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure>(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure>(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + var revisions = await _revisionRequests.Query() + .Where(x => x.EstimateId == estimateId) + .Include(x => x.Attachments) + .OrderByDescending(x => x.RequestedAt) + .ToListAsync(); + + return Result.Success>(revisions.Select(ToDto).ToList()); + } + + public async Task> GetAttachmentAsync( + Guid estimateId, + Guid revisionRequestId, + Guid attachmentId, + Guid organizationId, + Guid organizationClientId) + { + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId); + + if (estimate is null) + return Result.Failure(EstimateRevisionErrors.EstimateNotFound); + + if (estimate.OrganizationId != organizationId || estimate.OrganizationClientId != organizationClientId) + return Result.Failure(EstimateRevisionErrors.UnauthorizedEstimateAccess); + + var revision = await _revisionRequests.Query() + .FirstOrDefaultAsync(x => x.Id == revisionRequestId && x.EstimateId == estimateId); + + if (revision is null) + return Result.Failure(EstimateRevisionErrors.RevisionRequestNotFound); + + var attachment = await _attachments.Query() + .FirstOrDefaultAsync(x => x.Id == attachmentId && x.EstimateRevisionRequestId == revisionRequestId); + + if (attachment is null) + return Result.Failure(EstimateRevisionErrors.AttachmentNotFound); + + return Result.Success(new EstimateRevisionAttachmentDownloadDto( + attachment.FileName, + attachment.ContentType, + attachment.FileData)); + } + + private static EstimateRevisionRequestDto ToDto(EstimateRevisionRequest request) + { + return new EstimateRevisionRequestDto( + request.Id, + request.EstimateId, + request.OrganizationId, + request.OrganizationClientId, + request.RevisionNumber, + request.Status, + request.RequestMessage, + request.OrganizationResponseMessage, + request.RequestedAt, + request.ReviewedAt, + request.ResolvedAt, + request.Attachments.Select(x => new EstimateRevisionAttachmentDto( + x.Id, + x.FileName, + x.ContentType, + x.FileSizeBytes)).ToList()); + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs new file mode 100644 index 0000000..3be714a --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IEstimateRevisionService.cs @@ -0,0 +1,24 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IEstimateRevisionService +{ + Task> CreateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId, + CreateEstimateRevisionRequest request); + + Task>> GetByEstimateAsync( + Guid estimateId, + Guid organizationId, + Guid organizationClientId); + + Task> GetAttachmentAsync( + Guid estimateId, + Guid revisionRequestId, + Guid attachmentId, + Guid organizationId, + Guid organizationClientId); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 7256143..4fa9a3a 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -18,6 +18,7 @@ public interface INotificationService Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job); Task SendClientEstimateSentNotificationAsync(OrganizationClient client, Estimate estimate); + Task SendOrganizationEstimateRevisionRequestedNotificationAsync(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink); // Employee notifications diff --git a/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs b/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs new file mode 100644 index 0000000..f85e0b4 --- /dev/null +++ b/JobFlow.Business/Validators/CreateEstimateRevisionRequestValidator.cs @@ -0,0 +1,56 @@ +using FluentValidation; +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Validators; + +public class CreateEstimateRevisionRequestValidator : AbstractValidator +{ + private static readonly HashSet AllowedContentTypes = + [ + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain" + ]; + + public CreateEstimateRevisionRequestValidator() + { + RuleFor(x => x.Message) + .NotEmpty() + .MaximumLength(2000); + + RuleFor(x => x.Attachments) + .NotNull(); + + RuleFor(x => x.Attachments.Count) + .LessThanOrEqualTo(5); + + RuleForEach(x => x.Attachments).ChildRules(attachment => + { + attachment.RuleFor(x => x.FileName) + .NotEmpty() + .MaximumLength(260); + + attachment.RuleFor(x => x.ContentType) + .NotEmpty() + .Must(contentType => AllowedContentTypes.Contains(contentType)) + .WithMessage("Unsupported attachment content type."); + + attachment.RuleFor(x => x.Content) + .NotNull() + .Must(content => content.Length > 0) + .WithMessage("Attachment content is required."); + + attachment.RuleFor(x => x.SizeBytes) + .GreaterThan(0) + .LessThanOrEqualTo(10 * 1024 * 1024); + + attachment.RuleFor(x => x) + .Must(x => x.Content.Length == x.SizeBytes) + .WithMessage("Attachment size does not match payload size."); + }); + } +} diff --git a/JobFlow.Domain/Enums/EstimateRevisionStatus.cs b/JobFlow.Domain/Enums/EstimateRevisionStatus.cs new file mode 100644 index 0000000..bc9df98 --- /dev/null +++ b/JobFlow.Domain/Enums/EstimateRevisionStatus.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Enums; + +public enum EstimateRevisionStatus +{ + Requested = 0, + InReview = 1, + Resolved = 2, + Rejected = 3, + Cancelled = 4 +} diff --git a/JobFlow.Domain/Enums/EstimateStatus.cs b/JobFlow.Domain/Enums/EstimateStatus.cs index 62cfd69..88b511c 100644 --- a/JobFlow.Domain/Enums/EstimateStatus.cs +++ b/JobFlow.Domain/Enums/EstimateStatus.cs @@ -7,5 +7,6 @@ public enum EstimateStatus Accepted = 2, Declined = 3, Cancelled = 4, - Expired = 5 + Expired = 5, + RevisionRequested = 6 } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Estimate.cs b/JobFlow.Domain/Models/Estimate.cs index f12f7e7..39f9de1 100644 --- a/JobFlow.Domain/Models/Estimate.cs +++ b/JobFlow.Domain/Models/Estimate.cs @@ -29,4 +29,5 @@ public class Estimate : Entity public OrganizationClient? OrganizationClient { get; set; } public ICollection LineItems { get; set; } = new List(); + public ICollection RevisionRequests { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EstimateRevisionAttachment.cs b/JobFlow.Domain/Models/EstimateRevisionAttachment.cs new file mode 100644 index 0000000..a35b06d --- /dev/null +++ b/JobFlow.Domain/Models/EstimateRevisionAttachment.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Domain.Models; + +public class EstimateRevisionAttachment : Entity +{ + public Guid EstimateRevisionRequestId { get; set; } + public string FileName { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public byte[] FileData { get; set; } = Array.Empty(); + + public EstimateRevisionRequest RevisionRequest { get; set; } = null!; +} diff --git a/JobFlow.Domain/Models/EstimateRevisionRequest.cs b/JobFlow.Domain/Models/EstimateRevisionRequest.cs new file mode 100644 index 0000000..6d485f5 --- /dev/null +++ b/JobFlow.Domain/Models/EstimateRevisionRequest.cs @@ -0,0 +1,21 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class EstimateRevisionRequest : Entity +{ + public Guid EstimateId { get; set; } + public Guid OrganizationId { get; set; } + public Guid OrganizationClientId { get; set; } + public int RevisionNumber { get; set; } + public EstimateRevisionStatus Status { get; set; } = EstimateRevisionStatus.Requested; + public string RequestMessage { get; set; } = string.Empty; + public string? OrganizationResponseMessage { get; set; } + public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ReviewedAt { get; set; } + public DateTimeOffset? ResolvedAt { get; set; } + + public Estimate Estimate { get; set; } = null!; + public OrganizationClient OrganizationClient { get; set; } = null!; + public ICollection Attachments { get; set; } = new List(); +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs index e126ce5..5548924 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateConfiguration.cs @@ -25,5 +25,10 @@ public void Configure(EntityTypeBuilder builder) .WithOne(x => x.Estimate!) .HasForeignKey(x => x.EstimateId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.RevisionRequests) + .WithOne(x => x.Estimate) + .HasForeignKey(x => x.EstimateId) + .OnDelete(DeleteBehavior.Cascade); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs new file mode 100644 index 0000000..5d70572 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionAttachmentConfiguration.cs @@ -0,0 +1,19 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class EstimateRevisionAttachmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EstimateRevisionAttachments"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.FileName).HasMaxLength(260).IsRequired(); + builder.Property(x => x.ContentType).HasMaxLength(200).IsRequired(); + builder.Property(x => x.FileSizeBytes).IsRequired(); + builder.Property(x => x.FileData).HasColumnType("varbinary(max)").IsRequired(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs new file mode 100644 index 0000000..a48ba2a --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EstimateRevisionRequestConfiguration.cs @@ -0,0 +1,30 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class EstimateRevisionRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EstimateRevisionRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RevisionNumber).IsRequired(); + builder.Property(x => x.RequestMessage).HasMaxLength(4000).IsRequired(); + builder.Property(x => x.OrganizationResponseMessage).HasMaxLength(4000); + + builder.HasIndex(x => new { x.EstimateId, x.RevisionNumber }).IsUnique(); + + builder.HasOne(x => x.OrganizationClient) + .WithMany() + .HasForeignKey(x => x.OrganizationClientId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(x => x.Attachments) + .WithOne(x => x.RevisionRequest) + .HasForeignKey(x => x.EstimateRevisionRequestId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 91b2988..4e85da1 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -13,6 +13,8 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet Estimates { get; set; } public DbSet EstimateLineItems { get; set; } + public DbSet EstimateRevisionRequests { get; set; } + public DbSet EstimateRevisionAttachments { get; set; } public DbSet InvoiceSequences { get; set; } public DbSet Organizations { get; set; } public DbSet OrganizationTypes { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs new file mode 100644 index 0000000..b377fc0 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.Designer.cs @@ -0,0 +1,2397 @@ +// +using System; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(JobFlowDbContext))] + [Migration("20260319025731_AddEstimateRevisionTables")] + partial class AddEstimateRevisionTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalPaymentId") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaidAt") + .HasColumnType("datetimeoffset"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastSequence") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("InvoiceSequence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationClient", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("OrganizationClientPortalSession"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationService", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeInvoiceId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("PaymentHistory"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookCategories", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsTaxable") + .HasColumnType("bit"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanceledAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PaymentProfileId"); + + b.ToTable("SubscriptionRecord", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("Invoices") + .HasForeignKey("OrderId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentOrders"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Navigation("LineItems"); + + b.Navigation("RevisionRequests"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Navigation("LineItems"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Navigation("Assignments"); + + b.Navigation("JobTrackings"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Navigation("AssignmentOrders"); + + b.Navigation("Invoices"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Navigation("EmployeeRoles"); + + b.Navigation("Employees"); + + b.Navigation("OnboardingSteps"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Navigation("Jobs"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Navigation("Employees"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs new file mode 100644 index 0000000..012e291 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319025731_AddEstimateRevisionTables.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEstimateRevisionTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EstimateRevisionRequests", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EstimateId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationClientId = table.Column(type: "uniqueidentifier", nullable: false), + RevisionNumber = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + RequestMessage = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: false), + OrganizationResponseMessage = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + RequestedAt = table.Column(type: "datetimeoffset", nullable: false), + ReviewedAt = table.Column(type: "datetimeoffset", nullable: true), + ResolvedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EstimateRevisionRequests", x => x.Id); + table.ForeignKey( + name: "FK_EstimateRevisionRequests_Estimates_EstimateId", + column: x => x.EstimateId, + principalTable: "Estimates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_EstimateRevisionRequests_OrganizationClient_OrganizationClientId", + column: x => x.OrganizationClientId, + principalTable: "OrganizationClient", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "EstimateRevisionAttachments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EstimateRevisionRequestId = table.Column(type: "uniqueidentifier", nullable: false), + FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + ContentType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + FileData = table.Column(type: "varbinary(max)", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EstimateRevisionAttachments", x => x.Id); + table.ForeignKey( + name: "FK_EstimateRevisionAttachments_EstimateRevisionRequests_EstimateRevisionRequestId", + column: x => x.EstimateRevisionRequestId, + principalTable: "EstimateRevisionRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionAttachments_EstimateRevisionRequestId", + table: "EstimateRevisionAttachments", + column: "EstimateRevisionRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionRequests_EstimateId_RevisionNumber", + table: "EstimateRevisionRequests", + columns: new[] { "EstimateId", "RevisionNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EstimateRevisionRequests_OrganizationClientId", + table: "EstimateRevisionRequests", + column: "OrganizationClientId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EstimateRevisionAttachments"); + + migrationBuilder.DropTable( + name: "EstimateRevisionRequests"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 86b31eb..12703af 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -633,6 +633,124 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EstimateLineItems", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => { b.Property("Id") @@ -1929,6 +2047,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Estimate"); }); + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => { b.HasOne("JobFlow.Domain.Models.Order", "Order") @@ -2176,6 +2324,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => { b.Navigation("LineItems"); + + b.Navigation("RevisionRequests"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Navigation("Attachments"); }); modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => diff --git a/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs b/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs deleted file mode 100644 index 6cabe0f..0000000 --- a/JobFlow.Infrastructure/ConfigurationInterfaces/IReCAPTCHASettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; - -public interface IReCAPTCHASettings -{ - string SecretKey { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs b/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs deleted file mode 100644 index 9abe6ba..0000000 --- a/JobFlow.Infrastructure/ConfigurationModels/ReCAPTCHASettings.cs +++ /dev/null @@ -1,8 +0,0 @@ -using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; - -namespace JobFlow.Infrastructure.ExternalServices.ConfigurationModels; - -public class ReCAPTCHASettings : IReCAPTCHASettings -{ - public string SecretKey { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs b/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs deleted file mode 100644 index 949ea15..0000000 --- a/JobFlow.Infrastructure/ExternalServices/ReCAPTCHA/ReCAPTCHAService.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using JobFlow.Business.DI; -using JobFlow.Infrastructure.ExternalServices.ConfigurationModels; -using Microsoft.Extensions.Options; - -namespace JobFlow.Infrastructure.ExternalServices.ReCAPTCHA; - -public interface IReCAPTCHAService -{ - Task VerifyTokenAsync(string token); -} - -[ScopedService] -public class ReCAPTCHAService : IReCAPTCHAService -{ - private readonly HttpClient _httpClient; - private readonly ReCAPTCHASettings _settings; - - public ReCAPTCHAService(IOptions settings, IHttpClientFactory httpClientFactory) - { - _settings = settings.Value; - _httpClient = httpClientFactory.CreateClient(); - } - - public async Task VerifyTokenAsync(string token) - { - var values = new Dictionary - { - { "secret", _settings.SecretKey }, - { "response", token } - }; - - var content = new FormUrlEncodedContent(values); - - var response = await _httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content); - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json); - - return result?.Success ?? false; - } -} - -public class RecaptchaResponse -{ - [JsonPropertyName("success")] public bool Success { get; set; } - - [JsonPropertyName("score")] public float Score { get; set; } - - [JsonPropertyName("action")] public string? Action { get; set; } -} \ No newline at end of file diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index c4de7d2..31af277 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -38,15 +38,28 @@ public async Task Invoke(HttpContext context, IUserService userService) return; } + string? token = null; + var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) + if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ")) + { + token = authHeader.Substring("Bearer ".Length); + } + + if (string.IsNullOrWhiteSpace(token) + && path is not null + && path.StartsWith("/hubs/") + && context.Request.Query.TryGetValue("access_token", out var accessToken)) + { + token = accessToken.FirstOrDefault(); + } + + if (string.IsNullOrWhiteSpace(token)) { await _next(context); return; } - var token = authHeader.Substring("Bearer ".Length); - // If this is a locally-issued Client Portal JWT, do not attempt Firebase verification. // The JwtBearer handler for the ClientPortalJwt scheme will validate and populate claims. try From 5806c8aa0380ed9b3152c6716a91660f93a33b2e Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 19 Mar 2026 12:20:35 -0400 Subject: [PATCH 07/26] chore(cleanup): Code Clean Up --- JobFlow.API/Controllers/ChatController.cs | 364 ++++++++++++++++++ .../Controllers/EmployeeInviteController.cs | 16 + .../Controllers/InviteRedirectController.cs | 2 + JobFlow.API/Controllers/JobController.cs | 18 +- .../Controllers/OrganizationTypeController.cs | 3 + JobFlow.API/Controllers/PaymentController.cs | 4 + JobFlow.API/Hubs/ChatHub.cs | 141 ++++++- JobFlow.API/JobFlow.API.csproj | 3 - JobFlow.API/Models/ChatDtos.cs | 25 ++ JobFlow.API/Program.cs | 4 + .../Models/DTOs/JobRecurrenceUpsertRequest.cs | 34 ++ .../Services/EmployeeInviteService.cs | 49 ++- .../Services/JobRecurrenceService.cs | 129 +++++++ .../IEmployeeInviteService.cs | 2 + .../IJobRecurrenceService.cs | 9 + .../JobFlow.Infrastructure.csproj | 2 - 16 files changed, 796 insertions(+), 9 deletions(-) create mode 100644 JobFlow.API/Controllers/ChatController.cs create mode 100644 JobFlow.API/Models/ChatDtos.cs create mode 100644 JobFlow.Business/Models/DTOs/JobRecurrenceUpsertRequest.cs create mode 100644 JobFlow.Business/Services/JobRecurrenceService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IJobRecurrenceService.cs diff --git a/JobFlow.API/Controllers/ChatController.cs b/JobFlow.API/Controllers/ChatController.cs new file mode 100644 index 0000000..173acea --- /dev/null +++ b/JobFlow.API/Controllers/ChatController.cs @@ -0,0 +1,364 @@ +using System.Security.Claims; +using JobFlow.API.Extensions; +using JobFlow.API.Models; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/chat")] +public class ChatController : ControllerBase +{ + private readonly IUserService _userService; + private readonly IUnitOfWork _unitOfWork; + + public ChatController(IUserService userService, IUnitOfWork unitOfWork) + { + _userService = userService; + _unitOfWork = unitOfWork; + } + + [HttpGet("conversations")] + public async Task GetConversations() + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + var conversations = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .Include(c => c.Messages) + .Where(c => c.Participants.Any(p => p.UserId == currentUser.Id)) + .ToListAsync(); + + var participantIds = conversations + .SelectMany(c => c.Participants) + .Select(p => p.UserId) + .Distinct() + .ToList(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue && participantIds.Contains(e.UserId.Value)) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => participantIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + var results = conversations + .Select(conversation => MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)) + .OrderByDescending(c => c.LastMessage?.SentAt ?? DateTime.MinValue) + .ToList(); + + return Ok(results); + } + + [HttpGet("messages/{conversationId:guid}")] + public async Task GetMessages( + Guid conversationId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 200) pageSize = 200; + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return Forbid(); + + var messageQuery = _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversationId) + .OrderByDescending(m => m.SentAt); + + var paged = await messageQuery + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var senderIds = paged.Select(m => m.SenderId).Distinct().ToList(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue && senderIds.Contains(e.UserId.Value)) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => senderIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + var messages = paged + .OrderBy(m => m.SentAt) + .Select(message => MapMessage(message, currentUser.Id, employeeLookup, userLookup)) + .ToList(); + + return Ok(messages); + } + + [HttpPost("messages")] + public async Task CreateMessage([FromBody] CreateMessageRequest request) + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + if (request.ConversationId == Guid.Empty) + return BadRequest("ConversationId is required."); + + if (string.IsNullOrWhiteSpace(request.Content)) + return BadRequest("Message content is required."); + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == request.ConversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return Forbid(); + + var message = new Message + { + Id = Guid.NewGuid(), + ConversationId = request.ConversationId, + SenderId = currentUser.Id, + Content = request.Content.Trim(), + AttachmentUrl = string.IsNullOrWhiteSpace(request.AttachmentUrl) ? null : request.AttachmentUrl, + SentAt = DateTime.UtcNow, + IsRead = false + }; + + await _unitOfWork.RepositoryOf().AddAsync(message); + await _unitOfWork.SaveChangesAsync(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue && e.UserId.Value == currentUser.Id) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = new Dictionary + { + { currentUser.Id, currentUser } + }; + + var dto = MapMessage(message, currentUser.Id, employeeLookup, userLookup); + return Ok(dto); + } + + [HttpPost("conversations/{conversationId:guid}/read")] + public async Task MarkConversationRead(Guid conversationId) + { + var (currentUser, _, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return Forbid(); + + var messages = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversationId && m.SenderId != currentUser.Id && !m.IsRead) + .ToListAsync(); + + if (messages.Count == 0) + return Ok(); + + foreach (var message in messages) + { + message.IsRead = true; + } + + await _unitOfWork.SaveChangesAsync(); + return Ok(); + } + + [HttpPost("conversations")] + public async Task CreateConversation([FromBody] CreateConversationRequest request) + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + if (request.ParticipantIds is null || request.ParticipantIds.Count == 0) + return BadRequest("At least one participant is required."); + + var participantGuids = request.ParticipantIds + .Select(id => Guid.TryParse(id, out var guid) ? guid : Guid.Empty) + .Where(guid => guid != Guid.Empty) + .Distinct() + .ToList(); + + if (participantGuids.Count == 0) + return BadRequest("Participant ids must be valid GUIDs."); + + if (!participantGuids.Contains(currentUser.Id)) + participantGuids.Insert(0, currentUser.Id); + + var users = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => participantGuids.Contains(u.Id) && u.OrganizationId == organizationId) + .ToListAsync(); + + if (users.Count != participantGuids.Count) + return BadRequest("All participants must belong to the current organization."); + + if (participantGuids.Count == 2) + { + var existing = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => c.Participants.Count == 2 + && c.Participants.All(p => participantGuids.Contains(p.UserId))); + + if (existing is not null) + { + var employeeLookupExisting = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue && participantGuids.Contains(e.UserId.Value)) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookupExisting = users.ToDictionary(u => u.Id); + return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting)); + } + } + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + Title = null + }; + + foreach (var user in users) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = user.Id + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue && participantGuids.Contains(e.UserId.Value)) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = users.ToDictionary(u => u.Id); + + return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)); + } + + private static ChatConversationDto MapConversation( + Conversation conversation, + Guid currentUserId, + IDictionary employeeLookup, + IDictionary userLookup) + { + var otherParticipant = conversation.Participants + .Select(p => p.UserId) + .FirstOrDefault(id => id != currentUserId); + + var (name, role, avatar) = ResolveParticipantDisplay(otherParticipant, employeeLookup, userLookup); + + var lastMessage = conversation.Messages + .OrderByDescending(m => m.SentAt) + .FirstOrDefault(); + + var lastMessageDto = lastMessage is null + ? null + : MapMessage(lastMessage, currentUserId, employeeLookup, userLookup); + + var unreadCount = conversation.Messages.Count(m => m.SenderId != currentUserId && !m.IsRead); + + return new ChatConversationDto( + conversation.Id, + name ?? conversation.Title ?? "Conversation", + avatar, + role ?? "Team member", + "online", + unreadCount, + lastMessageDto); + } + + private static ChatMessageDto MapMessage( + Message message, + Guid currentUserId, + IDictionary employeeLookup, + IDictionary userLookup) + { + var (name, _, avatar) = ResolveParticipantDisplay(message.SenderId, employeeLookup, userLookup); + + return new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + name, + avatar, + message.SenderId == currentUserId); + } + + private static (string? name, string? role, string? avatarUrl) ResolveParticipantDisplay( + Guid userId, + IDictionary employeeLookup, + IDictionary userLookup) + { + if (employeeLookup.TryGetValue(userId, out var employee)) + { + return ( + employee.FullName, + employee.Role?.Name, + employee.ProfilePictureUrl + ); + } + + if (userLookup.TryGetValue(userId, out var user)) + { + return (user.Email, null, null); + } + + return (null, null, null); + } + + private async Task<(User? user, Guid organizationId, IActionResult? errorResult)> ResolveCurrentUserAsync() + { + var firebaseUid = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(firebaseUid)) + return (null, Guid.Empty, Unauthorized()); + + var userResult = await _userService.GetUserByFirebaseUid(firebaseUid); + if (!userResult.IsSuccess) + return (null, Guid.Empty, Forbid()); + + var organizationId = HttpContext.GetOrganizationId(); + return (userResult.Value, organizationId, null); + } +} diff --git a/JobFlow.API/Controllers/EmployeeInviteController.cs b/JobFlow.API/Controllers/EmployeeInviteController.cs index ca86ead..abcb3e9 100644 --- a/JobFlow.API/Controllers/EmployeeInviteController.cs +++ b/JobFlow.API/Controllers/EmployeeInviteController.cs @@ -43,6 +43,22 @@ public async Task AcceptInvite(Guid token) return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + [HttpGet("organization")] + public async Task GetByOrganization() + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _inviteService.GetByOrganizationAsync(organizationId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("revoke/{inviteId:guid}")] + public async Task RevokeInvite(Guid inviteId) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _inviteService.RevokeAsync(inviteId, organizationId); + return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); + } + [HttpGet("{code}")] public async Task GetInviteByCode(string code) { diff --git a/JobFlow.API/Controllers/InviteRedirectController.cs b/JobFlow.API/Controllers/InviteRedirectController.cs index 3c374ed..0958dcc 100644 --- a/JobFlow.API/Controllers/InviteRedirectController.cs +++ b/JobFlow.API/Controllers/InviteRedirectController.cs @@ -1,11 +1,13 @@ using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; using JobFlow.Business.Services.ServiceInterfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; namespace JobFlow.API.Controllers; [ApiController] [Route("i")] +[AllowAnonymous] public class InviteRedirectController : ControllerBase { private readonly IFrontendSettings _frontendSettings; diff --git a/JobFlow.API/Controllers/JobController.cs b/JobFlow.API/Controllers/JobController.cs index f557d95..72a1713 100644 --- a/JobFlow.API/Controllers/JobController.cs +++ b/JobFlow.API/Controllers/JobController.cs @@ -14,11 +14,13 @@ namespace JobFlow.API.Controllers; public class JobController : ControllerBase { private readonly IJobService _jobService; + private readonly IJobRecurrenceService _recurrenceService; private readonly IMapper _mapper; - public JobController(IJobService jobService, IMapper mapper) + public JobController(IJobService jobService, IJobRecurrenceService recurrenceService, IMapper mapper) { _jobService = jobService; + _recurrenceService = recurrenceService; _mapper = mapper; } @@ -98,5 +100,19 @@ public async Task GetJobs() return Ok(result.Value); } + [HttpPut("{jobId:guid}/recurrence")] + public async Task UpsertRecurrence(Guid jobId, [FromBody] JobRecurrenceUpsertRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + if (organizationId == Guid.Empty) + return Unauthorized("Organization context missing."); + + var result = await _recurrenceService.UpsertAsync(jobId, organizationId, request); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + } \ No newline at end of file diff --git a/JobFlow.API/Controllers/OrganizationTypeController.cs b/JobFlow.API/Controllers/OrganizationTypeController.cs index 474bcab..513d061 100644 --- a/JobFlow.API/Controllers/OrganizationTypeController.cs +++ b/JobFlow.API/Controllers/OrganizationTypeController.cs @@ -2,6 +2,7 @@ using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Models; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; namespace JobFlow.API.Controllers; @@ -20,6 +21,7 @@ public OrganizationTypeController(IOrganizationTypeService organizationTypeServi [HttpGet] [Route("all")] + [AllowAnonymous] public async Task GetAllOrganizationTypes() { var result = await organizationTypeService.GetTypes(); @@ -28,6 +30,7 @@ public async Task GetAllOrganizationTypes() [HttpGet] [Route("id")] + [AllowAnonymous] public async Task GetTypeById(Guid organizationTypeId) { var result = await organizationTypeService.GetTypeById(organizationTypeId); diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index 9befecb..16fcc80 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication.JwtBearer; using System.Net.Http.Json; using System.Text.Json; using Stripe; @@ -22,6 +23,7 @@ namespace JobFlow.API.Controllers; [ApiController] [Route("api/payments/")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme + ",ClientPortalJwt")] public class PaymentController : ControllerBase { private readonly IOrganizationService _organizationService; @@ -314,6 +316,7 @@ public async Task SetDefaultPaymentMethod([FromBody] SetDefaultPa [HttpPost("webhook")] + [AllowAnonymous] public async Task HandleStripeWebhook() { var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); @@ -338,6 +341,7 @@ public async Task HandleStripeWebhook() } [HttpPost("square/webhook")] + [AllowAnonymous] public async Task HandleSquareWebhook() { var rawBody = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(); diff --git a/JobFlow.API/Hubs/ChatHub.cs b/JobFlow.API/Hubs/ChatHub.cs index 303a653..4273098 100644 --- a/JobFlow.API/Hubs/ChatHub.cs +++ b/JobFlow.API/Hubs/ChatHub.cs @@ -1,13 +1,63 @@ -using Microsoft.AspNetCore.SignalR; +using System.Security.Claims; +using System.Text.Json; +using JobFlow.API.Models; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; namespace JobFlow.API.Hubs; public class ChatHub : Hub { + private readonly IUserService _userService; + private readonly IUnitOfWork _unitOfWork; + + public ChatHub(IUserService userService, IUnitOfWork unitOfWork) + { + _userService = userService; + _unitOfWork = unitOfWork; + } + // Called when sending a message public async Task SendMessage(Guid conversationId, object message) { - await Clients.Group(conversationId.ToString()).SendAsync("ReceiveMessage", message); + var content = ExtractContent(message); + var attachmentUrl = ExtractAttachmentUrl(message); + if (string.IsNullOrWhiteSpace(content) && string.IsNullOrWhiteSpace(attachmentUrl)) + return; + + var currentUser = await ResolveCurrentUserAsync(); + if (currentUser is null) + return; + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return; + + var entity = new Message + { + Id = Guid.NewGuid(), + ConversationId = conversationId, + SenderId = currentUser.Id, + Content = content?.Trim() ?? string.Empty, + AttachmentUrl = string.IsNullOrWhiteSpace(attachmentUrl) ? null : attachmentUrl, + SentAt = DateTime.UtcNow, + IsRead = false + }; + + await _unitOfWork.RepositoryOf().AddAsync(entity); + await _unitOfWork.SaveChangesAsync(); + + var dto = await BuildMessageDtoAsync(entity, currentUser.Id, true); + await Clients.Caller.SendAsync("ReceiveMessage", dto); + + var otherDto = await BuildMessageDtoAsync(entity, currentUser.Id, false); + await Clients.OthersInGroup(conversationId.ToString()).SendAsync("ReceiveMessage", otherDto); } public override async Task OnConnectedAsync() @@ -26,4 +76,91 @@ public async Task LeaveConversation(Guid conversationId) await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString()); } + private async Task ResolveCurrentUserAsync() + { + var firebaseUid = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(firebaseUid)) + return null; + + var userResult = await _userService.GetUserByFirebaseUid(firebaseUid); + return userResult.IsSuccess ? userResult.Value : null; + } + + private async Task BuildMessageDtoAsync(Message message, Guid senderId, bool isMine) + { + var employee = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .FirstOrDefaultAsync(e => e.UserId == senderId); + + var senderName = employee?.FullName; + var senderAvatarUrl = employee?.ProfilePictureUrl; + + return new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + senderName, + senderAvatarUrl, + isMine); + } + + private static string? ExtractContent(object? payload) + { + if (payload is null) + return null; + + if (payload is string text) + return text; + + if (payload is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Object) + { + if (TryGetString(json, "content", out var contentValue)) + return contentValue; + if (TryGetString(json, "text", out var textValue)) + return textValue; + } + + if (json.ValueKind == JsonValueKind.String) + return json.GetString(); + } + + return payload.ToString(); + } + + private static string? ExtractAttachmentUrl(object? payload) + { + if (payload is null) + return null; + + if (payload is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Object) + { + if (TryGetString(json, "attachmentUrl", out var attachmentValue)) + return attachmentValue; + } + } + + return null; + } + + private static bool TryGetString(JsonElement json, string propertyName, out string? value) + { + value = null; + if (!json.TryGetProperty(propertyName, out var property)) + return false; + + if (property.ValueKind != JsonValueKind.String) + return false; + + value = property.GetString(); + return true; + } + } \ No newline at end of file diff --git a/JobFlow.API/JobFlow.API.csproj b/JobFlow.API/JobFlow.API.csproj index 2661e8f..277f2db 100644 --- a/JobFlow.API/JobFlow.API.csproj +++ b/JobFlow.API/JobFlow.API.csproj @@ -22,7 +22,6 @@ - all @@ -37,8 +36,6 @@ - - diff --git a/JobFlow.API/Models/ChatDtos.cs b/JobFlow.API/Models/ChatDtos.cs new file mode 100644 index 0000000..df05bba --- /dev/null +++ b/JobFlow.API/Models/ChatDtos.cs @@ -0,0 +1,25 @@ +namespace JobFlow.API.Models; + +public record ChatMessageDto( + Guid Id, + Guid ConversationId, + Guid SenderId, + string Content, + string? AttachmentUrl, + DateTime SentAt, + string? SenderName, + string? SenderAvatarUrl, + bool IsMine); + +public record ChatConversationDto( + Guid Id, + string Name, + string? AvatarUrl, + string? Role, + string Status, + int UnreadCount, + ChatMessageDto? LastMessage); + +public record CreateConversationRequest(List ParticipantIds); + +public record CreateMessageRequest(Guid ConversationId, string Content, string? AttachmentUrl); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 6bef1be..41e0f91 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -24,6 +24,7 @@ using JobFlow.Infrastructure.Persistence; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using QuestPDF; @@ -308,6 +309,9 @@ builder.Services.AddAuthorization(options => { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); options.AddPolicy("OrganizationAdminOnly", policy => policy.RequireRole(UserRoles.OrganizationAdmin)); options.AddPolicy("OrganizationEmployeeOnly", policy => policy.RequireRole(UserRoles.OrganizationEmployee)); options.AddPolicy("OrganizationClientOnly", policy => policy.RequireRole(UserRoles.OrganizationClient)); diff --git a/JobFlow.Business/Models/DTOs/JobRecurrenceUpsertRequest.cs b/JobFlow.Business/Models/DTOs/JobRecurrenceUpsertRequest.cs new file mode 100644 index 0000000..9b412ef --- /dev/null +++ b/JobFlow.Business/Models/DTOs/JobRecurrenceUpsertRequest.cs @@ -0,0 +1,34 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public enum RecurrencePattern +{ + Weekly = 1, + Monthly = 2 +} + +public enum RecurrenceEndType +{ + Never = 0, + OnDate = 1, + AfterCount = 2 +} + +public class JobRecurrenceUpsertRequest +{ + public DateTime ScheduledStart { get; set; } + public DateTime ScheduledEnd { get; set; } + + public ScheduleType ScheduleType { get; set; } = ScheduleType.Window; + + public RecurrencePattern Pattern { get; set; } = RecurrencePattern.Weekly; + public int Interval { get; set; } = 1; + + public List? DayOfWeek { get; set; } + public int? DayOfMonth { get; set; } + + public RecurrenceEndType EndType { get; set; } = RecurrenceEndType.Never; + public DateTime? EndDate { get; set; } + public int? OccurrenceCount { get; set; } +} diff --git a/JobFlow.Business/Services/EmployeeInviteService.cs b/JobFlow.Business/Services/EmployeeInviteService.cs index 2c071d6..d9a464b 100644 --- a/JobFlow.Business/Services/EmployeeInviteService.cs +++ b/JobFlow.Business/Services/EmployeeInviteService.cs @@ -82,6 +82,43 @@ public async Task> InviteAsync(EmployeeInvite invite) } } + public async Task>> GetByOrganizationAsync(Guid organizationId) + { + if (organizationId == Guid.Empty) + return Result.Failure>(EmployeeInviteErrors.OrganizationRequired); + + var invites = await _invites.Query() + .Include(i => i.Organization) + .Include(i => i.Role) + .Where(i => i.OrganizationId == organizationId) + .OrderByDescending(i => i.CreatedAt) + .ToListAsync(); + + var results = invites.Select(MapInviteDto).ToList(); + return Result.Success(results); + } + + public async Task RevokeAsync(Guid inviteId, Guid organizationId) + { + if (inviteId == Guid.Empty || organizationId == Guid.Empty) + return Result.Failure(EmployeeInviteErrors.OrganizationRequired); + + var invite = await _invites.Query() + .FirstOrDefaultAsync(i => i.Id == inviteId && i.OrganizationId == organizationId); + + if (invite is null) + return Result.Failure(EmployeeInviteErrors.InviteNotFound); + + if (invite.Status == EmployeeInviteStatus.Revoked) + return Result.Success(); + + invite.Status = EmployeeInviteStatus.Revoked; + _invites.Update(invite); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + public async Task> AcceptInviteAsync(Guid inviteToken) { if (inviteToken == Guid.Empty) @@ -163,7 +200,17 @@ public async Task> GetInviteByCode(string code) if (invite is null) return Result.Failure(EmployeeInviteErrors.InviteNotFound); - var dto = _mapper.Map(invite); + var dto = MapInviteDto(invite); return Result.Success(dto); } + + private EmployeeInviteDto MapInviteDto(EmployeeInvite invite) + { + var dto = _mapper.Map(invite); + dto.OrganizationName = invite.Organization?.OrganizationName; + dto.RoleName = invite.Role?.Name; + dto.IsAccepted = invite.Status == EmployeeInviteStatus.Accepted; + dto.IsRevoked = invite.Status == EmployeeInviteStatus.Revoked; + return dto; + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/JobRecurrenceService.cs b/JobFlow.Business/Services/JobRecurrenceService.cs new file mode 100644 index 0000000..152b20e --- /dev/null +++ b/JobFlow.Business/Services/JobRecurrenceService.cs @@ -0,0 +1,129 @@ +using JobFlow.Business.DI; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class JobRecurrenceService : IJobRecurrenceService +{ + private readonly IRepository _jobs; + private readonly IRepository _recurrences; + private readonly IUnitOfWork _unitOfWork; + + public JobRecurrenceService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _jobs = unitOfWork.RepositoryOf(); + _recurrences = unitOfWork.RepositoryOf(); + } + + public async Task> UpsertAsync(Guid jobId, Guid organizationId, JobRecurrenceUpsertRequest request) + { + if (jobId == Guid.Empty || organizationId == Guid.Empty) + return Result.Failure(AssignmentErrors.InvalidRecurrence); + + if (request.ScheduledEnd <= request.ScheduledStart) + return Result.Failure(AssignmentErrors.ScheduledEndMustBeAfterStart); + + if (request.Interval < 1) + return Result.Failure(AssignmentErrors.InvalidRecurrence); + + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job is null) + return Result.Failure(AssignmentErrors.JobNotFound); + + if (job.OrganizationClient?.OrganizationId != organizationId) + return Result.Failure(AssignmentErrors.InvalidOrganization); + + var recurrence = await _recurrences.Query() + .FirstOrDefaultAsync(r => r.JobId == jobId); + + if (recurrence is null) + { + recurrence = new JobRecurrence + { + Id = Guid.NewGuid(), + JobId = jobId + }; + await _recurrences.AddAsync(recurrence); + } + + var startDate = request.ScheduledStart.Date; + if (request.DayOfMonth.HasValue) + { + var day = Math.Clamp(request.DayOfMonth.Value, 1, DateTime.DaysInMonth(startDate.Year, startDate.Month)); + startDate = new DateTime(startDate.Year, startDate.Month, day); + } + + var endDate = ResolveEndDate(startDate, request); + + recurrence.Frequency = ResolveFrequency(request.Pattern, request.Interval); + recurrence.DaysOfWeekMask = ResolveDaysOfWeekMask(request.DayOfWeek, request.ScheduledStart.DayOfWeek); + recurrence.StartTime = request.ScheduledStart.TimeOfDay; + recurrence.Duration = request.ScheduledEnd - request.ScheduledStart; + recurrence.ScheduleType = request.ScheduleType; + recurrence.StartDate = startDate; + recurrence.EndDate = endDate; + recurrence.IsActive = true; + + await _unitOfWork.SaveChangesAsync(); + return Result.Success(recurrence); + } + + private static RecurrenceFrequency ResolveFrequency(RecurrencePattern pattern, int interval) + { + if (pattern == RecurrencePattern.Monthly) + return RecurrenceFrequency.Monthly; + + return interval == 2 ? RecurrenceFrequency.BiWeekly : RecurrenceFrequency.Weekly; + } + + private static int ResolveDaysOfWeekMask(List? dayOfWeek, DayOfWeek fallback) + { + var days = dayOfWeek is { Count: > 0 } ? dayOfWeek : new List { (int)fallback }; + var mask = 0; + + foreach (var day in days.Distinct()) + { + mask |= day switch + { + 0 => 1, // Sunday + 1 => 2, // Monday + 2 => 4, // Tuesday + 3 => 8, // Wednesday + 4 => 16, // Thursday + 5 => 32, // Friday + 6 => 64, // Saturday + _ => 0 + }; + } + + return mask; + } + + private static DateTime? ResolveEndDate(DateTime startDate, JobRecurrenceUpsertRequest request) + { + if (request.EndType == RecurrenceEndType.OnDate) + return request.EndDate?.Date; + + if (request.EndType != RecurrenceEndType.AfterCount || !request.OccurrenceCount.HasValue) + return null; + + var occurrences = Math.Max(request.OccurrenceCount.Value, 1); + + if (request.Pattern == RecurrencePattern.Monthly) + return startDate.AddMonths(request.Interval * (occurrences - 1)); + + return startDate.AddDays(7 * request.Interval * (occurrences - 1)); + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs index 2fffe3c..64f026f 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeInviteService.cs @@ -6,6 +6,8 @@ namespace JobFlow.Business.Services.ServiceInterfaces; public interface IEmployeeInviteService { Task> InviteAsync(EmployeeInvite invite); + Task>> GetByOrganizationAsync(Guid organizationId); + Task RevokeAsync(Guid inviteId, Guid organizationId); Task> GetInviteByCode(string code); Task> AcceptInviteAsync(Guid inviteToken); Task> ResolveShortCodeAsync(string code, string? ipAddress = null); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IJobRecurrenceService.cs b/JobFlow.Business/Services/ServiceInterfaces/IJobRecurrenceService.cs new file mode 100644 index 0000000..43360b8 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IJobRecurrenceService.cs @@ -0,0 +1,9 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Models; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IJobRecurrenceService +{ + Task> UpsertAsync(Guid jobId, Guid organizationId, JobRecurrenceUpsertRequest request); +} diff --git a/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj b/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj index 00ed064..05229b2 100644 --- a/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj +++ b/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj @@ -25,9 +25,7 @@ - - From 2324795712db644d396b29521738f753da6d8c74 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 19 Mar 2026 18:13:08 -0400 Subject: [PATCH 08/26] feat(messaging): Tighten Chat Feature --- JobFlow.API/Controllers/ChatController.cs | 268 +- JobFlow.API/Controllers/ChatSmsController.cs | 148 + .../Controllers/ClientHubController.cs | 265 +- JobFlow.API/Hubs/ChatHub.cs | 142 +- JobFlow.API/Hubs/ClientChatHub.cs | 96 + JobFlow.API/Models/ChatDtos.cs | 7 +- JobFlow.API/Program.cs | 18 + JobFlow.Domain/Models/Conversation.cs | 2 + JobFlow.Domain/Models/Message.cs | 7 +- .../ConversationConfiguration.cs | 5 + .../Configurations/MessageConfiguration.cs | 5 + ...9171453_AddChatExternalSenders.Designer.cs | 2423 +++++++++++++++++ .../20260319171453_AddChatExternalSenders.cs | 115 + .../JobFlowDbContextModelSnapshot.cs | 32 +- .../Weather/OpenMeteoWeatherService.cs | 46 +- 15 files changed, 3538 insertions(+), 41 deletions(-) create mode 100644 JobFlow.API/Controllers/ChatSmsController.cs create mode 100644 JobFlow.API/Hubs/ClientChatHub.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs diff --git a/JobFlow.API/Controllers/ChatController.cs b/JobFlow.API/Controllers/ChatController.cs index 173acea..bd3f654 100644 --- a/JobFlow.API/Controllers/ChatController.cs +++ b/JobFlow.API/Controllers/ChatController.cs @@ -1,9 +1,12 @@ using System.Security.Claims; using JobFlow.API.Extensions; +using JobFlow.API.Hubs; using JobFlow.API.Models; +using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; +using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -15,11 +18,22 @@ public class ChatController : ControllerBase { private readonly IUserService _userService; private readonly IUnitOfWork _unitOfWork; - - public ChatController(IUserService userService, IUnitOfWork unitOfWork) + private readonly ITwilioService _twilioService; + private readonly IHubContext _chatHubContext; + private readonly IHubContext _clientChatHubContext; + + public ChatController( + IUserService userService, + IUnitOfWork unitOfWork, + ITwilioService twilioService, + IHubContext chatHubContext, + IHubContext clientChatHubContext) { _userService = userService; _unitOfWork = unitOfWork; + _twilioService = twilioService; + _chatHubContext = chatHubContext; + _clientChatHubContext = clientChatHubContext; } [HttpGet("conversations")] @@ -53,8 +67,21 @@ public async Task GetConversations() .Where(u => participantIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id); + var clientIds = conversations + .Where(c => c.OrganizationClientId.HasValue) + .Select(c => c.OrganizationClientId!.Value) + .Distinct() + .ToList(); + + var clientLookup = clientIds.Count == 0 + ? new Dictionary() + : await _unitOfWork.RepositoryOf() + .Query() + .Where(c => clientIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + var results = conversations - .Select(conversation => MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)) + .Select(conversation => MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, clientLookup)) .OrderByDescending(c => c.LastMessage?.SentAt ?? DateTime.MinValue) .ToList(); @@ -92,7 +119,11 @@ public async Task GetMessages( .Take(pageSize) .ToListAsync(); - var senderIds = paged.Select(m => m.SenderId).Distinct().ToList(); + var senderIds = paged + .Where(m => m.SenderId.HasValue) + .Select(m => m.SenderId!.Value) + .Distinct() + .ToList(); var employeeLookup = await _unitOfWork.RepositoryOf() .Query() @@ -123,8 +154,8 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r if (request.ConversationId == Guid.Empty) return BadRequest("ConversationId is required."); - if (string.IsNullOrWhiteSpace(request.Content)) - return BadRequest("Message content is required."); + if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl)) + return BadRequest("Message content or attachment is required."); var isParticipant = await _unitOfWork.RepositoryOf() .Query() @@ -147,6 +178,9 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r await _unitOfWork.RepositoryOf().AddAsync(message); await _unitOfWork.SaveChangesAsync(); + await TrySendClientSmsAsync(request.ConversationId, request.Content, request.AttachmentUrl); + await SendToClientHubAsync(request.ConversationId, message); + var employeeLookup = await _unitOfWork.RepositoryOf() .Query() .Include(e => e.Role) @@ -190,9 +224,23 @@ public async Task MarkConversationRead(Guid conversationId) } await _unitOfWork.SaveChangesAsync(); + + var readIds = messages.Select(m => m.Id).ToList(); + await _chatHubContext.Clients.Group(conversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId, + messageIds = readIds + }); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId, + messageIds = readIds + }); return Ok(); } + [HttpPost("conversations")] public async Task CreateConversation([FromBody] CreateConversationRequest request) { @@ -241,7 +289,7 @@ public async Task CreateConversation([FromBody] CreateConversatio .ToDictionaryAsync(e => e.UserId!.Value); var userLookupExisting = users.ToDictionary(u => u.Id); - return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting)); + return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting, new Dictionary())); } } @@ -271,20 +319,113 @@ public async Task CreateConversation([FromBody] CreateConversatio var userLookup = users.ToDictionary(u => u.Id); - return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup)); + return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, new Dictionary())); + } + + [HttpPost("conversations/client")] + public async Task CreateClientConversation([FromBody] CreateClientConversationRequest request) + { + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); + if (currentUser is null) + return firebaseUidResult ?? Unauthorized(); + + if (request.OrganizationClientId == Guid.Empty) + return BadRequest("OrganizationClientId is required."); + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.OrganizationClientId && c.OrganizationId == organizationId); + + if (client is null) + return NotFound("Client not found for this organization."); + + var existing = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .Include(c => c.Messages) + .FirstOrDefaultAsync(c => c.OrganizationClientId == request.OrganizationClientId); + + if (existing is not null) + { + var employeeLookupExisting = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookupExisting = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => employeeLookupExisting.Keys.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + return Ok(MapConversation(existing, currentUser.Id, employeeLookupExisting, userLookupExisting, + new Dictionary { { client.Id, client } })); + } + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + Title = null, + OrganizationClientId = client.Id + }; + + var participantIds = await GetOrganizationUserIdsAsync(organizationId, currentUser.Id); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + + var employeeLookup = await _unitOfWork.RepositoryOf() + .Query() + .Include(e => e.Role) + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .ToDictionaryAsync(e => e.UserId!.Value); + + var userLookup = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => employeeLookup.Keys.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + var clientLookup = new Dictionary { { client.Id, client } }; + return Ok(MapConversation(conversation, currentUser.Id, employeeLookup, userLookup, clientLookup)); } private static ChatConversationDto MapConversation( Conversation conversation, Guid currentUserId, IDictionary employeeLookup, - IDictionary userLookup) + IDictionary userLookup, + IDictionary clientLookup) { - var otherParticipant = conversation.Participants - .Select(p => p.UserId) - .FirstOrDefault(id => id != currentUserId); + string? name = null; + string? role = null; + string? avatar = null; - var (name, role, avatar) = ResolveParticipantDisplay(otherParticipant, employeeLookup, userLookup); + if (conversation.OrganizationClientId.HasValue + && clientLookup.TryGetValue(conversation.OrganizationClientId.Value, out var client)) + { + name = client.ClientFullName().Trim(); + role = "Client"; + avatar = null; + } + else + { + var otherParticipant = conversation.Participants + .Select(p => p.UserId) + .FirstOrDefault(id => id != currentUserId); + + var resolved = ResolveParticipantDisplay(otherParticipant, employeeLookup, userLookup); + name = resolved.name; + role = resolved.role; + avatar = resolved.avatarUrl; + } var lastMessage = conversation.Messages .OrderByDescending(m => m.SentAt) @@ -312,7 +453,20 @@ private static ChatMessageDto MapMessage( IDictionary employeeLookup, IDictionary userLookup) { - var (name, _, avatar) = ResolveParticipantDisplay(message.SenderId, employeeLookup, userLookup); + string? name = null; + string? avatar = null; + + if (message.SenderId.HasValue) + { + var resolved = ResolveParticipantDisplay(message.SenderId.Value, employeeLookup, userLookup); + name = resolved.name; + avatar = resolved.avatarUrl; + } + else + { + name = message.ExternalSenderName; + avatar = null; + } return new ChatMessageDto( message.Id, @@ -323,7 +477,8 @@ private static ChatMessageDto MapMessage( message.SentAt, name, avatar, - message.SenderId == currentUserId); + message.SenderId.HasValue && message.SenderId.Value == currentUserId, + message.IsRead); } private static (string? name, string? role, string? avatarUrl) ResolveParticipantDisplay( @@ -361,4 +516,87 @@ private static (string? name, string? role, string? avatarUrl) ResolveParticipan var organizationId = HttpContext.GetOrganizationId(); return (userResult.Value, organizationId, null); } + + private async Task SendToClientHubAsync(Guid conversationId, Message message) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is null) + return; + + var clientDto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + "JobFlow Team", + null, + false, + message.IsRead); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()) + .SendAsync("ReceiveMessage", clientDto); + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId, Guid fallbackUserId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + if (!userIds.Contains(fallbackUserId)) + userIds.Add(fallbackUserId); + + return userIds; + } + + private async Task TrySendClientSmsAsync(Guid conversationId, string? content, string? attachmentUrl) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId); + + if (conversation?.OrganizationClientId is null) + return; + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversation.OrganizationClientId.Value); + + if (client is null || string.IsNullOrWhiteSpace(client.PhoneNumber)) + return; + + var smsBody = BuildSmsBody(content, attachmentUrl); + if (string.IsNullOrWhiteSpace(smsBody)) + return; + + await _twilioService.SendTextMessage(new TwilioModel + { + RecipientPhoneNumber = client.PhoneNumber, + Message = smsBody + }); + } + + private static string BuildSmsBody(string? content, string? attachmentUrl) + { + var message = content?.Trim() ?? string.Empty; + var attachment = attachmentUrl?.Trim(); + + if (!string.IsNullOrWhiteSpace(attachment)) + { + if (string.IsNullOrWhiteSpace(message)) + message = attachment; + else + message = $"{message}\n{attachment}"; + } + + return message; + } } diff --git a/JobFlow.API/Controllers/ChatSmsController.cs b/JobFlow.API/Controllers/ChatSmsController.cs new file mode 100644 index 0000000..51b6beb --- /dev/null +++ b/JobFlow.API/Controllers/ChatSmsController.cs @@ -0,0 +1,148 @@ +using JobFlow.API.Hubs; +using JobFlow.API.Models; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/chat/sms")] +public class ChatSmsController : ControllerBase +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _hubContext; + private readonly IHubContext _clientHubContext; + + public ChatSmsController( + IUnitOfWork unitOfWork, + IHubContext hubContext, + IHubContext clientHubContext) + { + _unitOfWork = unitOfWork; + _hubContext = hubContext; + _clientHubContext = clientHubContext; + } + + [HttpPost("inbound")] + [AllowAnonymous] + public async Task Inbound([FromForm] TwilioInboundSmsRequest request) + { + if (string.IsNullOrWhiteSpace(request.From)) + return TwilioOk(); + + var fromNormalized = NormalizePhone(request.From); + if (string.IsNullOrWhiteSpace(fromNormalized)) + return TwilioOk(); + + var clients = await _unitOfWork.RepositoryOf() + .Query() + .Where(c => !string.IsNullOrWhiteSpace(c.PhoneNumber)) + .ToListAsync(); + + var client = clients.FirstOrDefault(c => NormalizePhone(c.PhoneNumber) == fromNormalized); + if (client is null) + return TwilioOk(); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .FirstOrDefaultAsync(c => c.OrganizationClientId == client.Id); + + if (conversation is null) + { + conversation = new Conversation + { + Id = Guid.NewGuid(), + OrganizationClientId = client.Id + }; + + var participantIds = await GetOrganizationUserIdsAsync(client.OrganizationId); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + } + + var content = request.Body?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(content)) + return TwilioOk(); + + var senderName = client.ClientFullName().Trim(); + var message = new Message + { + Id = Guid.NewGuid(), + ConversationId = conversation.Id, + SenderId = null, + Content = content, + SentAt = DateTime.UtcNow, + IsRead = false, + ExternalSenderName = senderName, + ExternalSenderType = "client", + ExternalSenderPhone = client.PhoneNumber + }; + + await _unitOfWork.RepositoryOf().AddAsync(message); + await _unitOfWork.SaveChangesAsync(); + + var dto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + senderName, + null, + false, + message.IsRead); + + await _hubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + await _clientHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + + return TwilioOk(); + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + return userIds; + } + + private static string NormalizePhone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + return digits; + } + + private static ContentResult TwilioOk() + { + return new ContentResult + { + Content = "", + ContentType = "text/xml", + StatusCode = 200 + }; + } +} + +public record TwilioInboundSmsRequest(string? From, string? To, string? Body); diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 7492c0c..9e9e08a 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -1,12 +1,16 @@ using JobFlow.API.Extensions; using JobFlow.API.Hubs; +using JobFlow.API.Models; using JobFlow.Business.Extensions; using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; namespace JobFlow.API.Controllers; @@ -20,19 +24,28 @@ public class ClientHubController : ControllerBase private readonly IInvoiceService _invoices; private readonly IOrganizationClientService _clients; private readonly IHubContext _hubContext; + private readonly IHubContext _chatHubContext; + private readonly IHubContext _clientChatHubContext; + private readonly IUnitOfWork _unitOfWork; public ClientHubController( IEstimateService estimates, IEstimateRevisionService estimateRevisions, IInvoiceService invoices, IOrganizationClientService clients, - IHubContext hubContext) + IHubContext hubContext, + IHubContext chatHubContext, + IHubContext clientChatHubContext, + IUnitOfWork unitOfWork) { _estimates = estimates; _estimateRevisions = estimateRevisions; _invoices = invoices; _clients = clients; _hubContext = hubContext; + _chatHubContext = chatHubContext; + _clientChatHubContext = clientChatHubContext; + _unitOfWork = unitOfWork; } [HttpGet("me")] @@ -79,6 +92,193 @@ public async Task UpdateMe([FromBody] UpdateOrganizationClientRequest r return upsert.IsSuccess ? Results.Ok(upsert.Value) : upsert.ToProblemDetails(); } + [HttpGet("chat/conversation")] + public async Task GetChatConversation() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + var conversation = await FindOrCreateClientConversationAsync(orgClientId, organizationId); + + var lastMessage = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversation.Id) + .OrderByDescending(m => m.SentAt) + .FirstOrDefaultAsync(); + + var org = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(o => o.Id == organizationId); + + var name = org?.OrganizationName ?? "Your Team"; + var lastMessageDto = lastMessage is null + ? null + : MapClientHubMessage(lastMessage, clientResult.Value); + + var dto = new ChatConversationDto( + conversation.Id, + name, + null, + "Organization", + "online", + 0, + lastMessageDto); + + return Results.Ok(dto); + } + + [HttpGet("chat/messages")] + public async Task GetChatMessages([FromQuery] Guid conversationId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 200) pageSize = 200; + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var messages = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == conversationId) + .OrderByDescending(m => m.SentAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = messages + .OrderBy(m => m.SentAt) + .Select(m => MapClientHubMessage(m, clientResult.Value)) + .ToList(); + + return Results.Ok(result); + } + + [HttpPost("chat/messages")] + public async Task CreateChatMessage([FromBody] CreateMessageRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + if (request.ConversationId == Guid.Empty) + return Results.BadRequest("ConversationId is required."); + + if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl)) + return Results.BadRequest("Message content or attachment is required."); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.ConversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var senderName = clientResult.Value.ClientFullName().Trim(); + var message = new Message + { + Id = Guid.NewGuid(), + ConversationId = request.ConversationId, + SenderId = null, + Content = request.Content?.Trim() ?? string.Empty, + AttachmentUrl = string.IsNullOrWhiteSpace(request.AttachmentUrl) ? null : request.AttachmentUrl, + SentAt = DateTime.UtcNow, + IsRead = false, + ExternalSenderName = senderName, + ExternalSenderType = "client", + ExternalSenderPhone = clientResult.Value.PhoneNumber + }; + + await _unitOfWork.RepositoryOf().AddAsync(message); + await _unitOfWork.SaveChangesAsync(); + + var dto = MapClientHubMessage(message, clientResult.Value); + await _chatHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + await _clientChatHubContext.Clients.Group(conversation.Id.ToString()).SendAsync("ReceiveMessage", dto); + + return Results.Ok(dto); + } + + [HttpPost("chat/read")] + public async Task MarkChatRead([FromBody] ClientHubReadRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + if (request.ConversationId == Guid.Empty) + return Results.BadRequest("ConversationId is required."); + + var clientResult = await _clients.GetClientById(orgClientId); + if (!clientResult.IsSuccess) + return clientResult.ToProblemDetails(); + + if (clientResult.Value.OrganizationId != organizationId) + return Results.Unauthorized(); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == request.ConversationId && c.OrganizationClientId == orgClientId); + + if (conversation is null) + return Results.NotFound(); + + var messages = await _unitOfWork.RepositoryOf() + .Query() + .Where(m => m.ConversationId == request.ConversationId && m.SenderId.HasValue && !m.IsRead) + .ToListAsync(); + + if (messages.Count == 0) + return Results.Ok(new { updated = 0 }); + + foreach (var message in messages) + { + message.IsRead = true; + } + + await _unitOfWork.SaveChangesAsync(); + + var readIds = messages.Select(m => m.Id).ToList(); + await _chatHubContext.Clients.Group(request.ConversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId = request.ConversationId, + messageIds = readIds + }); + + await _clientChatHubContext.Clients.Group(request.ConversationId.ToString()).SendAsync("ReadReceipt", new + { + conversationId = request.ConversationId, + messageIds = readIds + }); + + return Results.Ok(new { updated = readIds.Count }); + } + [HttpGet("estimates")] public async Task GetMyEstimates() { @@ -215,6 +415,67 @@ public async Task GetMyInvoices() var result = await _invoices.GetInvoicesByClientAsync(orgClientId); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + + private async Task FindOrCreateClientConversationAsync(Guid orgClientId, Guid organizationId) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .FirstOrDefaultAsync(c => c.OrganizationClientId == orgClientId); + + if (conversation is not null) + return conversation; + + conversation = new Conversation + { + Id = Guid.NewGuid(), + OrganizationClientId = orgClientId + }; + + var participantIds = await GetOrganizationUserIdsAsync(organizationId); + foreach (var userId in participantIds) + { + conversation.Participants.Add(new ConversationParticipant + { + ConversationId = conversation.Id, + UserId = userId + }); + } + + await _unitOfWork.RepositoryOf().AddAsync(conversation); + await _unitOfWork.SaveChangesAsync(); + return conversation; + } + + private async Task> GetOrganizationUserIdsAsync(Guid organizationId) + { + var userIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(e => e.OrganizationId == organizationId && e.UserId.HasValue) + .Select(e => e.UserId!.Value) + .Distinct() + .ToListAsync(); + + return userIds; + } + + private static ChatMessageDto MapClientHubMessage(Message message, OrganizationClient client) + { + var isMine = !message.SenderId.HasValue; + var senderName = isMine ? client.ClientFullName().Trim() : "JobFlow Team"; + + return new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + senderName, + null, + isMine, + message.IsRead); + } } public record UpdateOrganizationClientRequest( @@ -231,3 +492,5 @@ public record UpdateOrganizationClientRequest( public record CreateEstimateRevisionFormRequest( string? Message, List? Attachments); + +public record ClientHubReadRequest(Guid ConversationId); diff --git a/JobFlow.API/Hubs/ChatHub.cs b/JobFlow.API/Hubs/ChatHub.cs index 4273098..2bfcb30 100644 --- a/JobFlow.API/Hubs/ChatHub.cs +++ b/JobFlow.API/Hubs/ChatHub.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using JobFlow.API.Models; +using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; @@ -13,11 +14,19 @@ public class ChatHub : Hub { private readonly IUserService _userService; private readonly IUnitOfWork _unitOfWork; + private readonly ITwilioService _twilioService; + private readonly IHubContext _clientChatHubContext; - public ChatHub(IUserService userService, IUnitOfWork unitOfWork) + public ChatHub( + IUserService userService, + IUnitOfWork unitOfWork, + ITwilioService twilioService, + IHubContext clientChatHubContext) { _userService = userService; _unitOfWork = unitOfWork; + _twilioService = twilioService; + _clientChatHubContext = clientChatHubContext; } // Called when sending a message @@ -53,6 +62,10 @@ public async Task SendMessage(Guid conversationId, object message) await _unitOfWork.RepositoryOf().AddAsync(entity); await _unitOfWork.SaveChangesAsync(); + await TrySendClientSmsAsync(conversationId, entity.Id, content, attachmentUrl); + + await SendToClientHubAsync(conversationId, entity); + var dto = await BuildMessageDtoAsync(entity, currentUser.Id, true); await Clients.Caller.SendAsync("ReceiveMessage", dto); @@ -105,7 +118,8 @@ private async Task BuildMessageDtoAsync(Message message, Guid se message.SentAt, senderName, senderAvatarUrl, - isMine); + isMine, + message.IsRead); } private static string? ExtractContent(object? payload) @@ -163,4 +177,128 @@ private static bool TryGetString(JsonElement json, string propertyName, out stri return true; } + private async Task SendToClientHubAsync(Guid conversationId, Message message) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is null) + return; + + var clientDto = new ChatMessageDto( + message.Id, + message.ConversationId, + message.SenderId, + message.Content, + message.AttachmentUrl, + message.SentAt, + "JobFlow Team", + null, + false, + message.IsRead); + + await _clientChatHubContext.Clients.Group(conversationId.ToString()) + .SendAsync("ReceiveMessage", clientDto); + } + + private async Task TrySendClientSmsAsync(Guid conversationId, Guid messageId, string? content, string? attachmentUrl) + { + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId); + + if (conversation?.OrganizationClientId is null) + return; + + var client = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversation.OrganizationClientId.Value); + + if (client is null || string.IsNullOrWhiteSpace(client.PhoneNumber)) + return; + + var smsBody = BuildSmsBody(content, attachmentUrl); + if (string.IsNullOrWhiteSpace(smsBody)) + return; + + try + { + await _twilioService.SendTextMessage(new TwilioModel + { + RecipientPhoneNumber = client.PhoneNumber, + Message = smsBody + }); + + await Clients.Caller.SendAsync("SmsStatus", new + { + conversationId, + messageId, + status = "sent", + to = client.PhoneNumber + }); + } + catch + { + await Clients.Caller.SendAsync("SmsStatus", new + { + conversationId, + messageId, + status = "failed", + to = client.PhoneNumber + }); + } + } + + public async Task Typing(Guid conversationId, bool isTyping) + { + var currentUser = await ResolveCurrentUserAsync(); + if (currentUser is null) + return; + + var isParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); + + if (!isParticipant) + return; + + await Clients.OthersInGroup(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "org" + }); + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId && c.OrganizationClientId.HasValue); + + if (conversation is not null) + { + await _clientChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "org" + }); + } + } + + private static string BuildSmsBody(string? content, string? attachmentUrl) + { + var message = content?.Trim() ?? string.Empty; + var attachment = attachmentUrl?.Trim(); + + if (!string.IsNullOrWhiteSpace(attachment)) + { + if (string.IsNullOrWhiteSpace(message)) + message = attachment; + else + message = $"{message}\n{attachment}"; + } + + return message; + } + } \ No newline at end of file diff --git a/JobFlow.API/Hubs/ClientChatHub.cs b/JobFlow.API/Hubs/ClientChatHub.cs new file mode 100644 index 0000000..48887f4 --- /dev/null +++ b/JobFlow.API/Hubs/ClientChatHub.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Hubs; + +[Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")] +public class ClientChatHub : Hub +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _orgChatHubContext; + + public ClientChatHub(IUnitOfWork unitOfWork, IHubContext orgChatHubContext) + { + _unitOfWork = unitOfWork; + _orgChatHubContext = orgChatHubContext; + } + + public override async Task OnConnectedAsync() + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId != Guid.Empty) + { + var conversationId = await _unitOfWork.RepositoryOf() + .Query() + .Where(c => c.OrganizationClientId == orgClientId) + .Select(c => c.Id) + .FirstOrDefaultAsync(); + + if (conversationId != Guid.Empty) + { + await Groups.AddToGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + } + + await base.OnConnectedAsync(); + } + + public async Task JoinConversation(Guid conversationId) + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId == Guid.Empty) + return; + + var isClientConversation = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (!isClientConversation) + return; + + await Groups.AddToGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + + public async Task LeaveConversation(Guid conversationId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString()); + } + + public async Task Typing(Guid conversationId, bool isTyping) + { + var orgClientId = GetOrganizationClientId(); + if (orgClientId == Guid.Empty) + return; + + var isClientConversation = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(c => c.Id == conversationId && c.OrganizationClientId == orgClientId); + + if (!isClientConversation) + return; + + await Clients.OthersInGroup(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "client" + }); + + await _orgChatHubContext.Clients.Group(conversationId.ToString()).SendAsync("Typing", new + { + conversationId, + isTyping, + senderType = "client" + }); + } + + private Guid GetOrganizationClientId() + { + var claim = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(claim, out var id) ? id : Guid.Empty; + } +} diff --git a/JobFlow.API/Models/ChatDtos.cs b/JobFlow.API/Models/ChatDtos.cs index df05bba..8f4d45e 100644 --- a/JobFlow.API/Models/ChatDtos.cs +++ b/JobFlow.API/Models/ChatDtos.cs @@ -3,13 +3,14 @@ namespace JobFlow.API.Models; public record ChatMessageDto( Guid Id, Guid ConversationId, - Guid SenderId, + Guid? SenderId, string Content, string? AttachmentUrl, DateTime SentAt, string? SenderName, string? SenderAvatarUrl, - bool IsMine); + bool IsMine, + bool IsRead); public record ChatConversationDto( Guid Id, @@ -22,4 +23,6 @@ public record ChatConversationDto( public record CreateConversationRequest(List ParticipantIds); +public record CreateClientConversationRequest(Guid OrganizationClientId); + public record CreateMessageRequest(Guid ConversationId, string Content, string? AttachmentUrl); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 41e0f91..e2de241 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -122,6 +122,23 @@ if (string.IsNullOrWhiteSpace(signingKey)) throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"].FirstOrDefault(); + var path = context.HttpContext.Request.Path; + + if (!string.IsNullOrWhiteSpace(accessToken) + && path.StartsWithSegments("/hubs/client-chat")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -380,6 +397,7 @@ app.MapControllers(); app.MapHub("/hubs/chat"); +app.MapHub("/hubs/client-chat"); app.MapHub("/hubs/notifier"); app.Run(); \ No newline at end of file diff --git a/JobFlow.Domain/Models/Conversation.cs b/JobFlow.Domain/Models/Conversation.cs index 62e046b..bbc40b3 100644 --- a/JobFlow.Domain/Models/Conversation.cs +++ b/JobFlow.Domain/Models/Conversation.cs @@ -3,6 +3,8 @@ public class Conversation : Entity { public string? Title { get; set; } // Optional – could be job title or username + public Guid? OrganizationClientId { get; set; } public ICollection Participants { get; set; } = new List(); public ICollection Messages { get; set; } = new List(); + public OrganizationClient? OrganizationClient { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Message.cs b/JobFlow.Domain/Models/Message.cs index 933d63d..227f473 100644 --- a/JobFlow.Domain/Models/Message.cs +++ b/JobFlow.Domain/Models/Message.cs @@ -3,11 +3,14 @@ public class Message : Entity { public Guid ConversationId { get; set; } - public Guid SenderId { get; set; } + public Guid? SenderId { get; set; } public string Content { get; set; } = string.Empty; public DateTime SentAt { get; set; } = DateTime.UtcNow; public bool IsRead { get; set; } public string? AttachmentUrl { get; set; } // <-- For file uploads + public string? ExternalSenderName { get; set; } + public string? ExternalSenderType { get; set; } + public string? ExternalSenderPhone { get; set; } public Conversation Conversation { get; set; } = null!; - public User Sender { get; set; } = null!; + public User? Sender { get; set; } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs index 4f77be9..2ddcee8 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/ConversationConfiguration.cs @@ -11,6 +11,11 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("Conversation", "messaging"); builder.HasKey(e => e.Id); + builder.HasOne(c => c.OrganizationClient) + .WithMany() + .HasForeignKey(c => c.OrganizationClientId) + .OnDelete(DeleteBehavior.Restrict); + builder.HasMany(c => c.Participants) .WithOne(p => p.Conversation) .HasForeignKey(p => p.ConversationId); diff --git a/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs index c2f75bb..324d990 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/MessageConfiguration.cs @@ -11,6 +11,11 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("Message", "messaging"); builder.HasKey(e => e.Id); builder.Property(e => e.Content).IsRequired(); + builder.Property(e => e.SenderId).IsRequired(false); + + builder.Property(e => e.ExternalSenderName).HasMaxLength(200); + builder.Property(e => e.ExternalSenderType).HasMaxLength(50); + builder.Property(e => e.ExternalSenderPhone).HasMaxLength(32); builder.HasOne(m => m.Sender) .WithMany() diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs new file mode 100644 index 0000000..be5157f --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.Designer.cs @@ -0,0 +1,2423 @@ +// +using System; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(JobFlowDbContext))] + [Migration("20260319171453_AddChatExternalSenders")] + partial class AddChatExternalSenders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalPaymentId") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaidAt") + .HasColumnType("datetimeoffset"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastSequence") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("InvoiceSequence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationClient", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("OrganizationClientPortalSession"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationService", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeInvoiceId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("PaymentHistory"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookCategories", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsTaxable") + .HasColumnType("bit"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanceledAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PaymentProfileId"); + + b.ToTable("SubscriptionRecord", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany() + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("AssignmentOrders") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("AssignmentOrders") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Participants") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", null) + .WithMany("PaymentProfiles") + .HasForeignKey("OrganizationId"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("Employees") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany("Employees") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("Employees") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Organization"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.EmployeeRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("EmployeeRoles") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("LineItems") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Estimate"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.EstimateRevisionRequest", "RevisionRequest") + .WithMany("Attachments") + .HasForeignKey("EstimateRevisionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RevisionRequest"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany("RevisionRequests") + .HasForeignKey("EstimateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.HasOne("JobFlow.Domain.Models.Order", "Order") + .WithMany("Invoices") + .HasForeignKey("OrderId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("LineItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.HasOne("JobFlow.Domain.Models.User", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobTrackings") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationType", "OrganizationType") + .WithMany() + .HasForeignKey("OrganizationTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OrganizationType"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationClient"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany("OnboardingSteps") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.HasOne("JobFlow.Domain.Models.PriceBookCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobFlow.Domain.Models.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("InventoryItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.HasOne("JobFlow.Domain.Models.CustomerPaymentProfile", "PaymentProfile") + .WithMany() + .HasForeignKey("PaymentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentProfile"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.HasOne("JobFlow.Domain.Models.SystemRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobFlow.Domain.Models.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Navigation("AssignmentOrders"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Navigation("LineItems"); + + b.Navigation("RevisionRequests"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Navigation("LineItems"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Navigation("Assignments"); + + b.Navigation("JobTrackings"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Navigation("AssignmentOrders"); + + b.Navigation("Invoices"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Navigation("EmployeeRoles"); + + b.Navigation("Employees"); + + b.Navigation("OnboardingSteps"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Navigation("Jobs"); + + b.Navigation("PaymentProfiles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Navigation("Employees"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs new file mode 100644 index 0000000..02c050e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260319171453_AddChatExternalSenders.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddChatExternalSenders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SenderId", + schema: "messaging", + table: "Message", + type: "uniqueidentifier", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uniqueidentifier"); + + migrationBuilder.AddColumn( + name: "ExternalSenderName", + schema: "messaging", + table: "Message", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalSenderPhone", + schema: "messaging", + table: "Message", + type: "nvarchar(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExternalSenderType", + schema: "messaging", + table: "Message", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "OrganizationClientId", + schema: "messaging", + table: "Conversation", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Conversation_OrganizationClientId", + schema: "messaging", + table: "Conversation", + column: "OrganizationClientId"); + + migrationBuilder.AddForeignKey( + name: "FK_Conversation_OrganizationClient_OrganizationClientId", + schema: "messaging", + table: "Conversation", + column: "OrganizationClientId", + principalTable: "OrganizationClient", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Conversation_OrganizationClient_OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.DropIndex( + name: "IX_Conversation_OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.DropColumn( + name: "ExternalSenderName", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "ExternalSenderPhone", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "ExternalSenderType", + schema: "messaging", + table: "Message"); + + migrationBuilder.DropColumn( + name: "OrganizationClientId", + schema: "messaging", + table: "Conversation"); + + migrationBuilder.AlterColumn( + name: "SenderId", + schema: "messaging", + table: "Message", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uniqueidentifier", + oldNullable: true); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 12703af..af6eb4a 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -213,6 +213,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("bit"); + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + b.Property("Title") .HasColumnType("nvarchar(max)"); @@ -224,6 +227,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrganizationClientId"); + b.ToTable("Conversation", "messaging"); }); @@ -1150,13 +1155,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("IsActive") .HasColumnType("bit"); b.Property("IsRead") .HasColumnType("bit"); - b.Property("SenderId") + b.Property("SenderId") .HasColumnType("uniqueidentifier"); b.Property("SentAt") @@ -1939,6 +1956,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Order"); }); + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany() + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("OrganizationClient"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => { b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") @@ -2157,8 +2184,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("JobFlow.Domain.Models.User", "Sender") .WithMany() .HasForeignKey("SenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.Restrict); b.Navigation("Conversation"); diff --git a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs index 148aaf3..832581e 100644 --- a/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs +++ b/JobFlow.Infrastructure/ExternalServices/Weather/OpenMeteoWeatherService.cs @@ -15,32 +15,46 @@ public OpenMeteoWeatherService(IHttpClientFactory httpClientFactory) _httpClientFactory = httpClientFactory; } - public async Task GetForecastAsync(double latitude, double longitude, int days = 5, CancellationToken cancellationToken = default) + public async Task GetForecastAsync( + double latitude, double longitude, int days = 5, CancellationToken cancellationToken = default) { days = Math.Clamp(days, 1, 7); var url = $"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m,wind_speed_10m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days={days}"; + var client = _httpClientFactory.CreateClient("OpenMeteo"); - using var client = _httpClientFactory.CreateClient(); - using var response = await client.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + try + { + using var response = await client.GetAsync(url, linkedCts.Token); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: linkedCts.Token); - var root = doc.RootElement; - var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; + var root = doc.RootElement; + var timezone = root.TryGetProperty("timezone", out var tzElement) ? tzElement.GetString() ?? "UTC" : "UTC"; - var current = ParseCurrent(root.GetProperty("current")); - var daily = ParseDaily(root.GetProperty("daily")); + var current = ParseCurrent(root.GetProperty("current")); + var daily = ParseDaily(root.GetProperty("daily")); - return new WeatherForecastDto + return new WeatherForecastDto + { + Timezone = timezone, + Current = current, + Daily = daily, + RiskAlerts = BuildRiskAlerts(daily) + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - Timezone = timezone, - Current = current, - Daily = daily, - RiskAlerts = BuildRiskAlerts(daily) - }; + throw; // caller aborted request + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException("OpenMeteo request timed out."); + } } private static WeatherCurrentDto ParseCurrent(JsonElement current) From 411bb92cd9d410c73381f7485e060561de957f71 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 19 Mar 2026 23:51:53 -0400 Subject: [PATCH 09/26] feat(dispatch): Initial Dispatch Implementation --- .../Controllers/AssignmentController.cs | 26 + JobFlow.API/Controllers/DispatchController.cs | 78 + JobFlow.API/Controllers/JobController.cs | 14 + .../ModelErrors/AssignmentErrors.cs | 6 + .../Models/DTOs/AssignmentDtos.cs | 21 + JobFlow.Business/Models/DTOs/DispatchDtos.cs | 22 + JobFlow.Business/Models/DTOs/JobDto.cs | 5 + .../Services/AssignmentService.cs | 110 + JobFlow.Business/Services/JobService.cs | 31 + .../ServiceInterfaces/IAssignmentService.cs | 4 + .../Services/ServiceInterfaces/IJobService.cs | 1 + JobFlow.Domain/Models/Assignment.cs | 2 + JobFlow.Domain/Models/AssignmentAssignee.cs | 1 + .../AssignmentAssigneeConfiguration.cs | 9 +- .../AssignmentOrderConfiguration.cs | 2 + .../ConversationParticipantConfiguration.cs | 1 + .../EmployeeInviteConfiguration.cs | 3 +- .../EmployeeRoleConfiguration.cs | 2 + .../Configurations/InvoiceConfiguration.cs | 4 + .../InvoiceLineItemConfiguration.cs | 4 +- .../Configurations/UserRoleConfiguration.cs | 2 + ...ddAssignmentAssigneeNavigation.Designer.cs | 2433 ++++++++++++++++ ...0025029_AddAssignmentAssigneeNavigation.cs | 30 + ...0260320025246_FixModelWarnings.Designer.cs | 2435 +++++++++++++++++ .../20260320025246_FixModelWarnings.cs | 22 + .../JobFlowDbContextModelSnapshot.cs | 16 +- 26 files changed, 5278 insertions(+), 6 deletions(-) create mode 100644 JobFlow.API/Controllers/DispatchController.cs create mode 100644 JobFlow.Business/Models/DTOs/DispatchDtos.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.cs diff --git a/JobFlow.API/Controllers/AssignmentController.cs b/JobFlow.API/Controllers/AssignmentController.cs index 6dcc2bd..5ac1e35 100644 --- a/JobFlow.API/Controllers/AssignmentController.cs +++ b/JobFlow.API/Controllers/AssignmentController.cs @@ -96,5 +96,31 @@ public async Task UpdateStatus(Guid id, [FromBody] UpdateAssignme return Ok(result.Value); } + // Update assignees for an assignment + [HttpPut("{id:guid}/assignees")] + public async Task UpdateAssignees(Guid id, [FromBody] UpdateAssignmentAssigneesRequestDto dto) + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _assignmentService.UpdateAssignmentAssigneesAsync(organizationId, id, dto); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + // Update assignment notes + [HttpPut("{id:guid}/notes")] + public async Task UpdateNotes(Guid id, [FromBody] UpdateAssignmentNotesRequestDto dto) + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _assignmentService.UpdateAssignmentNotesAsync(organizationId, id, dto); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + } diff --git a/JobFlow.API/Controllers/DispatchController.cs b/JobFlow.API/Controllers/DispatchController.cs new file mode 100644 index 0000000..b3d182d --- /dev/null +++ b/JobFlow.API/Controllers/DispatchController.cs @@ -0,0 +1,78 @@ +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/[controller]")] +public class DispatchController : ControllerBase +{ + private readonly IAssignmentService _assignmentService; + private readonly IAssignmentGenerator _assignmentGenerator; + private readonly IEmployeeService _employeeService; + private readonly IJobService _jobService; + + public DispatchController( + IAssignmentService assignmentService, + IAssignmentGenerator assignmentGenerator, + IEmployeeService employeeService, + IJobService jobService) + { + _assignmentService = assignmentService; + _assignmentGenerator = assignmentGenerator; + _employeeService = employeeService; + _jobService = jobService; + } + + [HttpGet("board")] + public async Task GetBoard([FromQuery] DateTime start, [FromQuery] DateTime end) + { + var organizationId = HttpContext.GetOrganizationId(); + + var startUtc = DateTime.SpecifyKind(start, DateTimeKind.Utc); + var endUtc = DateTime.SpecifyKind(end, DateTimeKind.Utc); + + var gen = await _assignmentGenerator.EnsureAssignmentsExistAsync(organizationId, startUtc, endUtc); + if (gen.IsFailure) + return BadRequest(gen.Error); + + var assignmentsResult = await _assignmentService.GetAssignmentsAsync(organizationId, startUtc, endUtc); + if (assignmentsResult.IsFailure) + return BadRequest(assignmentsResult.Error); + + var employeesResult = await _employeeService.GetByOrganizationIdAsync(organizationId); + if (employeesResult.IsFailure) + return BadRequest(employeesResult.Error); + + var jobsResult = await _jobService.GetJobsAsync(organizationId); + if (jobsResult.IsFailure) + return BadRequest(jobsResult.Error); + + var unscheduledJobs = jobsResult.Value + .Where(job => job.HasAssignments == false) + .Select(job => new DispatchUnscheduledJobDto + { + JobId = job.Id ?? Guid.Empty, + JobTitle = job.Title, + ClientName = job.OrganizationClient != null + ? $"{job.OrganizationClient.FirstName} {job.OrganizationClient.LastName}".Trim() + : null, + JobLifecycleStatus = job.LifecycleStatus, + Notes = job.Comments + }) + .ToList(); + + var response = new DispatchBoardDto + { + RangeStart = startUtc, + RangeEnd = endUtc, + Assignments = assignmentsResult.Value, + Employees = employeesResult.Value, + UnscheduledJobs = unscheduledJobs + }; + + return Ok(response); + } +} diff --git a/JobFlow.API/Controllers/JobController.cs b/JobFlow.API/Controllers/JobController.cs index 72a1713..0482757 100644 --- a/JobFlow.API/Controllers/JobController.cs +++ b/JobFlow.API/Controllers/JobController.cs @@ -114,5 +114,19 @@ public async Task UpsertRecurrence(Guid jobId, [FromBody] JobRecu return Ok(result.Value); } + [HttpPut("{jobId:guid}/status")] + public async Task UpdateStatus(Guid jobId, [FromBody] UpdateJobStatusRequestDto request) + { + var organizationId = HttpContext.GetOrganizationId(); + if (organizationId == Guid.Empty) + return Unauthorized("Organization context missing."); + + var result = await _jobService.UpdateJobStatusAsync(organizationId, jobId, request.Status); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + } \ No newline at end of file diff --git a/JobFlow.Business/ModelErrors/AssignmentErrors.cs b/JobFlow.Business/ModelErrors/AssignmentErrors.cs index 3d957ec..e5266e9 100644 --- a/JobFlow.Business/ModelErrors/AssignmentErrors.cs +++ b/JobFlow.Business/ModelErrors/AssignmentErrors.cs @@ -22,6 +22,12 @@ public static class AssignmentErrors "The assignment does not belong to the current organization." ); + public static readonly Error InvalidAssignee = + Error.Validation( + "Assignment.InvalidAssignee", + "One or more assignees are invalid for this organization." + ); + // ───────────────────────────────────────────── // Scheduling // ───────────────────────────────────────────── diff --git a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs index f078ab2..5a28b0e 100644 --- a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs +++ b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs @@ -24,6 +24,9 @@ public class AssignmentDto public string? Notes { get; set; } + public JobLifecycleStatus JobLifecycleStatus { get; set; } + public List Assignees { get; set; } = new(); + // Useful for UI calendar public string? JobTitle { get; set; } public Guid OrganizationClientId { get; set; } @@ -59,4 +62,22 @@ public class UpdateAssignmentStatusRequestDto public AssignmentStatus Status { get; set; } public DateTimeOffset? ActualStart { get; set; } public DateTimeOffset? ActualEnd { get; set; } +} + +public class AssignmentAssigneeDto +{ + public Guid EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public bool IsLead { get; set; } +} + +public class UpdateAssignmentAssigneesRequestDto +{ + public List EmployeeIds { get; set; } = new(); + public Guid? LeadEmployeeId { get; set; } +} + +public class UpdateAssignmentNotesRequestDto +{ + public string? Notes { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/DispatchDtos.cs b/JobFlow.Business/Models/DTOs/DispatchDtos.cs new file mode 100644 index 0000000..d17d952 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/DispatchDtos.cs @@ -0,0 +1,22 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public class DispatchBoardDto +{ + public DateTimeOffset RangeStart { get; set; } + public DateTimeOffset RangeEnd { get; set; } + + public List Employees { get; set; } = new(); + public List Assignments { get; set; } = new(); + public List UnscheduledJobs { get; set; } = new(); +} + +public class DispatchUnscheduledJobDto +{ + public Guid JobId { get; set; } + public string? JobTitle { get; set; } + public string? ClientName { get; set; } + public JobLifecycleStatus JobLifecycleStatus { get; set; } + public string? Notes { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/JobDto.cs b/JobFlow.Business/Models/DTOs/JobDto.cs index 12a3063..7cb1968 100644 --- a/JobFlow.Business/Models/DTOs/JobDto.cs +++ b/JobFlow.Business/Models/DTOs/JobDto.cs @@ -13,4 +13,9 @@ public class JobDto public IEnumerable? Assignments { get; set; } public bool HasAssignments => Assignments?.Any() == true; +} + +public class UpdateJobStatusRequestDto +{ + public JobLifecycleStatus Status { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Services/AssignmentService.cs b/JobFlow.Business/Services/AssignmentService.cs index e2710d8..ada4cf0 100644 --- a/JobFlow.Business/Services/AssignmentService.cs +++ b/JobFlow.Business/Services/AssignmentService.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 MapsterMapper; using Microsoft.EntityFrameworkCore; @@ -15,6 +16,8 @@ namespace JobFlow.Business.Services; public class AssignmentService : IAssignmentService { private readonly IRepository _assignments; + private readonly IRepository _assignmentAssignees; + private readonly IRepository _employees; private readonly IRepository _jobs; private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; @@ -29,6 +32,8 @@ public AssignmentService( { _unitOfWork = unitOfWork; _assignments = unitOfWork.RepositoryOf(); + _assignmentAssignees = unitOfWork.RepositoryOf(); + _employees = unitOfWork.RepositoryOf(); _jobs = unitOfWork.RepositoryOf(); _mapper = mapper; @@ -75,6 +80,8 @@ await _onboardingService.MarkStepCompleteAsync( var created = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .FirstAsync(a => a.Id == assignment.Id); return Result.Success(MapToDto(created)); @@ -88,6 +95,8 @@ public async Task> UpdateAssignmentScheduleAsync( var assignment = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .FirstOrDefaultAsync(a => a.Id == assignmentId && a.Job.OrganizationClient.OrganizationId == organizationId); @@ -113,6 +122,8 @@ public async Task> UpdateAssignmentStatusAsync( var assignment = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .FirstOrDefaultAsync(a => a.Id == assignmentId && a.Job.OrganizationClient.OrganizationId == organizationId); @@ -130,6 +141,90 @@ public async Task> UpdateAssignmentStatusAsync( return Result.Success(MapToDto(assignment)); } + public async Task> UpdateAssignmentAssigneesAsync( + Guid organizationId, + Guid assignmentId, + UpdateAssignmentAssigneesRequestDto dto) + { + var assignment = await _assignments.Query() + .Include(a => a.Job) + .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) + .FirstOrDefaultAsync(a => + a.Id == assignmentId && + a.Job.OrganizationClient.OrganizationId == organizationId); + + if (assignment == null) + return Result.Failure(AssignmentErrors.NotFound); + + var requestedIds = (dto.EmployeeIds ?? new List()).Distinct().ToList(); + if (requestedIds.Any()) + { + var validEmployeeIds = await _employees.Query() + .Where(e => e.OrganizationId == organizationId && requestedIds.Contains(e.Id)) + .Select(e => e.Id) + .ToListAsync(); + + if (validEmployeeIds.Count != requestedIds.Count) + return Result.Failure(AssignmentErrors.InvalidAssignee); + + requestedIds = validEmployeeIds; + } + + if (assignment.AssignmentAssignees.Any()) + { + _assignmentAssignees.RemoveRange(assignment.AssignmentAssignees); + } + + var newAssignees = requestedIds.Select(id => new AssignmentAssignee + { + AssignmentId = assignmentId, + EmployeeId = id, + IsLead = dto.LeadEmployeeId.HasValue && dto.LeadEmployeeId.Value == id + }).ToList(); + + if (newAssignees.Any()) + { + _assignmentAssignees.AddRange(newAssignees); + } + + await _unitOfWork.SaveChangesAsync(); + + var updated = await _assignments.Query() + .Include(a => a.Job) + .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) + .FirstAsync(a => a.Id == assignmentId); + + return Result.Success(MapToDto(updated)); + } + + public async Task> UpdateAssignmentNotesAsync( + Guid organizationId, + Guid assignmentId, + UpdateAssignmentNotesRequestDto dto) + { + var assignment = await _assignments.Query() + .Include(a => a.Job) + .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) + .FirstOrDefaultAsync(a => + a.Id == assignmentId && + a.Job.OrganizationClient.OrganizationId == organizationId); + + if (assignment == null) + return Result.Failure(AssignmentErrors.NotFound); + + assignment.Notes = dto.Notes?.Trim(); + _assignments.Update(assignment); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(MapToDto(assignment)); + } + public async Task>> GetAssignmentsAsync( Guid organizationId, DateTime start, @@ -138,6 +233,8 @@ public async Task>> GetAssignmentsAsync( var assignments = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .Where(a => a.Job.OrganizationClient.OrganizationId == organizationId && a.ScheduledStart < end && @@ -155,6 +252,8 @@ public async Task> GetAssignmentByIdAsync( var assignment = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) + .Include(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .FirstOrDefaultAsync(a => a.Id == assignmentId && a.Job.OrganizationClient.OrganizationId == organizationId); @@ -175,6 +274,17 @@ private AssignmentDto MapToDto(Assignment assignment) dto.ClientName = assignment.Job?.OrganizationClient != null ? $"{assignment.Job.OrganizationClient.FirstName} {assignment.Job.OrganizationClient.LastName}" : null; + dto.JobLifecycleStatus = assignment.Job?.LifecycleStatus ?? JobLifecycleStatus.Draft; + dto.Assignees = assignment.AssignmentAssignees + .Select(assignee => new AssignmentAssigneeDto + { + EmployeeId = assignee.EmployeeId, + EmployeeName = assignee.Employee != null + ? $"{assignee.Employee.FirstName} {assignee.Employee.LastName}".Trim() + : null, + IsLead = assignee.IsLead + }) + .ToList(); return dto; } diff --git a/JobFlow.Business/Services/JobService.cs b/JobFlow.Business/Services/JobService.cs index 7051a14..8b851cc 100644 --- a/JobFlow.Business/Services/JobService.cs +++ b/JobFlow.Business/Services/JobService.cs @@ -67,6 +67,8 @@ public async Task>> GetJobsAsync(Guid organizationId) var returnedJobs = await jobs.Query() .Include(j => j.OrganizationClient) .Include(e => e.Assignments) + .ThenInclude(a => a.AssignmentAssignees) + .ThenInclude(assignee => assignee.Employee) .Where(j => j.OrganizationClient.OrganizationId == organizationId) .OrderByDescending(j => j.CreatedAt) .ToListAsync(); @@ -89,6 +91,17 @@ public async Task>> GetJobsAsync(Guid organizationId) JobTitle = e.Title, Status = a.Status, OrganizationClientId = e.OrganizationClientId, + JobLifecycleStatus = e.LifecycleStatus, + Assignees = a.AssignmentAssignees + .Select(assignee => new AssignmentAssigneeDto + { + EmployeeId = assignee.EmployeeId, + EmployeeName = assignee.Employee != null + ? $"{assignee.Employee.FirstName} {assignee.Employee.LastName}".Trim() + : null, + IsLead = assignee.IsLead + }) + .ToList() }), OrganizationClient = new OrganizationClientDto { @@ -157,4 +170,22 @@ public async Task DeleteJobAsync(Guid id) return Result.Success(); } + + public async Task> UpdateJobStatusAsync(Guid organizationId, Guid jobId, JobLifecycleStatus status) + { + var job = await jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => + j.Id == jobId && + j.OrganizationClient.OrganizationId == organizationId); + + if (job == null) + return Result.Failure(JobErrors.NotFound); + + job.LifecycleStatus = status; + jobs.Update(job); + await unitOfWork.SaveChangesAsync(); + + return Result.Success(job); + } } diff --git a/JobFlow.Business/Services/ServiceInterfaces/IAssignmentService.cs b/JobFlow.Business/Services/ServiceInterfaces/IAssignmentService.cs index e0e7e4f..89ad3ca 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IAssignmentService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IAssignmentService.cs @@ -10,6 +10,10 @@ public interface IAssignmentService Task> UpdateAssignmentStatusAsync(Guid organizationId, Guid assignmentId, UpdateAssignmentStatusRequestDto dto); + Task> UpdateAssignmentAssigneesAsync(Guid organizationId, Guid assignmentId, UpdateAssignmentAssigneesRequestDto dto); + + Task> UpdateAssignmentNotesAsync(Guid organizationId, Guid assignmentId, UpdateAssignmentNotesRequestDto dto); + Task>> GetAssignmentsAsync(Guid organizationId, DateTime start, DateTime end); Task> GetAssignmentByIdAsync(Guid organizationId, Guid assignmentId); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs b/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs index fd1f152..84af9f2 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs @@ -11,4 +11,5 @@ public interface IJobService Task> UpsertJobAsync(Job model, Guid organizationId); Task DeleteJobAsync(Guid id); Task>> GetJobsAsync(Guid organizationId); + Task> UpdateJobStatusAsync(Guid organizationId, Guid jobId, JobLifecycleStatus status); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Assignment.cs b/JobFlow.Domain/Models/Assignment.cs index 482b929..bc87713 100644 --- a/JobFlow.Domain/Models/Assignment.cs +++ b/JobFlow.Domain/Models/Assignment.cs @@ -26,5 +26,7 @@ public class Assignment : Entity public string? Notes { get; set; } + public virtual ICollection AssignmentAssignees { get; set; } = new List(); + public virtual ICollection AssignmentOrders { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/AssignmentAssignee.cs b/JobFlow.Domain/Models/AssignmentAssignee.cs index 2fe1abc..211796e 100644 --- a/JobFlow.Domain/Models/AssignmentAssignee.cs +++ b/JobFlow.Domain/Models/AssignmentAssignee.cs @@ -10,6 +10,7 @@ public class AssignmentAssignee public virtual Assignment Assignment { get; set; } public Guid EmployeeId { get; set; } // assuming you already have Employee entity + public virtual Employee Employee { get; set; } public bool IsLead { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/JobFlow.Infrastructure.Persistence/Configurations/AssignmentAssigneeConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/AssignmentAssigneeConfiguration.cs index 23cc874..6b06449 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/AssignmentAssigneeConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/AssignmentAssigneeConfiguration.cs @@ -16,10 +16,17 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => new { x.AssignmentId, x.EmployeeId }); builder.HasOne(x => x.Assignment) - .WithMany() + .WithMany(a => a.AssignmentAssignees) .HasForeignKey(x => x.AssignmentId) .OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Employee) + .WithMany() + .HasForeignKey(x => x.EmployeeId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasQueryFilter(x => x.Assignment.IsActive && x.Employee.IsActive); + builder.HasIndex(x => x.EmployeeId) .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); } diff --git a/JobFlow.Infrastructure.Persistence/Configurations/AssignmentOrderConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/AssignmentOrderConfiguration.cs index 2055176..fcaa7e5 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/AssignmentOrderConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/AssignmentOrderConfiguration.cs @@ -22,6 +22,8 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(x => x.OrderId) .OnDelete(DeleteBehavior.Cascade); + builder.HasQueryFilter(x => x.Assignment.IsActive && x.Order.IsActive); + builder.HasIndex(x => x.AssignmentId); builder.HasIndex(x => x.OrderId); } diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ConversationParticipantConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ConversationParticipantConfiguration.cs index 2235bfa..0db38a2 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/ConversationParticipantConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/ConversationParticipantConfiguration.cs @@ -11,5 +11,6 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("ConversationParticipant", "messaging"); builder.HasKey(e => e.Id); builder.HasIndex(e => new { e.ConversationId, e.UserId }).IsUnique(); + builder.HasQueryFilter(e => e.Conversation.IsActive && e.User.IsActive); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeInviteConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeInviteConfiguration.cs index 3740746..3e14313 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeInviteConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeInviteConfiguration.cs @@ -37,7 +37,8 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); builder.Property(e => e.Status) - .HasDefaultValue(EmployeeInviteStatus.Pending); + .HasDefaultValue(EmployeeInviteStatus.Pending) + .HasSentinel(EmployeeInviteStatus.Pending); builder.HasOne(e => e.Organization) .WithMany() diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs index e47fbcc..d0ee4fc 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs @@ -17,5 +17,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(er => er.Name) .IsRequired() .HasMaxLength(100); + + builder.HasQueryFilter(er => er.Organization.IsActive); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs index 00a69c1..644dda1 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceConfiguration.cs @@ -18,6 +18,10 @@ public void Configure(EntityTypeBuilder builder) .HasPrecision(18, 2) .IsRequired(); + builder.Property(i => i.AmountPaid) + .HasPrecision(18, 2) + .IsRequired(); + builder.Property(i => i.Status) .IsRequired(); } diff --git a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceLineItemConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceLineItemConfiguration.cs index a4b8a0b..6e08e49 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/InvoiceLineItemConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/InvoiceLineItemConfiguration.cs @@ -4,9 +4,9 @@ namespace JobFlow.Infrastructure.Persistence.Configurations; -internal class InvoiceLineItemConfiguration : EntityTypeConfiguration +internal class InvoiceLineItemConfiguration : IEntityTypeConfiguration { - public override void Map(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { builder.ToTable("InvoiceLineItem"); builder.HasKey(e => e.Id); diff --git a/JobFlow.Infrastructure.Persistence/Configurations/UserRoleConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/UserRoleConfiguration.cs index f22ee88..b1b1661 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/UserRoleConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/UserRoleConfiguration.cs @@ -16,5 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasOne(ur => ur.Role) .WithMany(r => r.UserRoles) .HasForeignKey(ur => ur.RoleId); + + builder.HasQueryFilter(ur => ur.User.IsActive); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.Designer.cs new file mode 100644 index 0000000..83fd8a6 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.Designer.cs @@ -0,0 +1,2433 @@ +// +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("20260320025029_AddAssignmentAssigneeNavigation")] + partial class AddAssignmentAssigneeNavigation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ActualStart") + .HasColumnType("datetimeoffset"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("ScheduledEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ScheduledStart") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStart"); + + b.ToTable("Assignment", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsLead") + .HasColumnType("bit"); + + b.HasKey("AssignmentId", "EmployeeId"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("IX_AssignmentAssignee_EmployeeId"); + + b.ToTable("AssignmentAssignee", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("NewScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("NewScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OldScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("OldScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignmentId") + .HasDatabaseName("IX_AssignmentHistory_AssignmentId"); + + b.ToTable("AssignmentHistory", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentOrder", b => + { + b.Property("AssignmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AssignmentId", "OrderId"); + + b.HasIndex("AssignmentId"); + + b.HasIndex("OrderId"); + + b.ToTable("AssignmentOrder", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Conversation", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ConversationParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConversationId", "UserId") + .IsUnique(); + + b.ToTable("ConversationParticipant", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.CustomerPaymentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultPaymentMethodId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDelinquent") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("OwnerType") + .HasColumnType("int"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderCustomerId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("CustomerPaymentProfile", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProfilePictureUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("Employees", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AccessIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("AccessedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("InviteToken") + .HasMaxLength(128) + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("ShortCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RoleId"); + + b.HasIndex("ShortCode") + .IsUnique() + .HasFilter("[ShortCode] IS NOT NULL"); + + b.HasIndex("OrganizationId", "Email") + .IsUnique() + .HasFilter("[Status] = 1"); + + b.HasIndex("Status", "ExpiresAt"); + + b.ToTable("EmployeeInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PublicToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PublicTokenExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("PublicToken") + .IsUnique(); + + b.HasIndex("OrganizationId", "EstimateNumber") + .IsUnique(); + + b.ToTable("Estimates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateId"); + + b.ToTable("EstimateLineItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateRevisionRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EstimateRevisionRequestId"); + + b.ToTable("EstimateRevisionAttachments", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EstimateRevisionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationResponseMessage") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RevisionNumber") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("EstimateId", "RevisionNumber") + .IsUnique(); + + b.ToTable("EstimateRevisionRequests", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CostPerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("QuantityInStock") + .HasColumnType("int"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("InventoryItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalPaymentId") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaidAt") + .HasColumnType("datetimeoffset"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Invoice"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("InvoiceLineItem"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastSequence") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("InvoiceSequence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Comments") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Latitude") + .HasColumnType("float"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("Longitude") + .HasColumnType("float"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Job", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobRecurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DaysOfWeekMask") + .HasColumnType("int"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Duration") + .HasColumnType("time"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .HasColumnType("int"); + + b.Property("GenerateDaysAhead") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("ScheduleType") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "IsActive") + .HasDatabaseName("IX_JobRecurrence_JobId_IsActive"); + + b.ToTable("JobRecurrence", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("RecordedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("JobId"); + + b.ToTable("JobTracking"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttachmentUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExternalSenderName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalSenderPhone") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ExternalSenderType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("SenderId"); + + b.ToTable("Message", "messaging"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.ToTable("Order", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DefaultTaxRate") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EnableTax") + .HasColumnType("bit"); + + b.Property("HasFreeAccount") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsStripeConnected") + .HasColumnType("bit"); + + b.Property("OnBoardingComplete") + .HasColumnType("bit"); + + b.Property("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationClient", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("OrganizationClientPortalSession"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationService", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeInvoiceId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("PaymentHistory"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookCategories", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsTaxable") + .HasColumnType("bit"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanceledAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PaymentProfileId"); + + b.ToTable("SubscriptionRecord", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("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/20260320025029_AddAssignmentAssigneeNavigation.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.cs new file mode 100644 index 0000000..6d4f83f --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320025029_AddAssignmentAssigneeNavigation.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAssignmentAssigneeNavigation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddForeignKey( + name: "FK_AssignmentAssignee_Employees_EmployeeId", + table: "AssignmentAssignee", + column: "EmployeeId", + principalTable: "Employees", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AssignmentAssignee_Employees_EmployeeId", + table: "AssignmentAssignee"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.Designer.cs new file mode 100644 index 0000000..c80808d --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.Designer.cs @@ -0,0 +1,2435 @@ +// +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("20260320025246_FixModelWarnings")] + partial class FixModelWarnings + { + /// + 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("OrganizationName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationTypeId"); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationClient", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClientPortalSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationClientId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("OrganizationClientPortalSession"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationOnboardingStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StepName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "StepName") + .IsUnique(); + + b.ToTable("OrganizationOnboardingSteps", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationService", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("OrganizationType", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountPaid") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentProvider") + .HasColumnType("int"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StripeInvoiceId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.ToTable("PaymentHistory"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookCategories", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.PriceBookItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CategoryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InventoryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("InventoryUnitsPerSale") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,4)") + .HasDefaultValue(1.0m); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsTaxable") + .HasColumnType("bit"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PricePerUnit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("PriceBookItems", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanceledAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PaymentProfileId"); + + b.ToTable("SubscriptionRecord", "payment"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FirebaseUid") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("Assignments") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => + { + b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") + .WithMany("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/20260320025246_FixModelWarnings.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.cs new file mode 100644 index 0000000..03907ab --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320025246_FixModelWarnings.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class FixModelWarnings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index af6eb4a..a123307 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -823,6 +823,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uniqueidentifier"); b.Property("AmountPaid") + .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); b.Property("CreatedAt") @@ -917,6 +918,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("int"); b.Property("UnitPrice") + .HasPrecision(18, 2) .HasColumnType("decimal(18,2)"); b.Property("UpdatedAt") @@ -929,7 +931,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("InvoiceId"); - b.ToTable("InvoiceLineItem"); + b.ToTable("InvoiceLineItem", (string)null); }); modelBuilder.Entity("JobFlow.Domain.Models.InvoiceSequence", b => @@ -1918,12 +1920,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("JobFlow.Domain.Models.AssignmentAssignee", b => { b.HasOne("JobFlow.Domain.Models.Assignment", "Assignment") - .WithMany() + .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 => @@ -2332,6 +2342,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("JobFlow.Domain.Models.Assignment", b => { + b.Navigation("AssignmentAssignees"); + b.Navigation("AssignmentOrders"); }); From 6a557886852ffbe694567402316f399b1259592e Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Fri, 20 Mar 2026 20:37:31 -0400 Subject: [PATCH 10/26] Add workflow and schedule settings --- .../Controllers/ScheduleSettingsController.cs | 42 + .../Controllers/WorkflowSettingsController.cs | 42 + JobFlow.API/JobFlow.API.sln | 58 + .../ModelErrors/AssignmentErrors.cs | 6 + .../Models/DTOs/AssignmentDtos.cs | 2 + .../Models/DTOs/ScheduleSettingsDtos.cs | 17 + .../Models/DTOs/WorkflowSettingsDtos.cs | 15 + .../Builders/INotificationMessageBuilder.cs | 7 + .../Builders/NotificationMessageBuilder.cs | 35 + .../Notifications/NotificationService.cs | 12 + .../Services/AssignmentService.cs | 179 +- .../Services/ScheduleSettingsService.cs | 85 + .../ServiceInterfaces/INotificationService.cs | 7 + .../IScheduleSettingsService.cs | 9 + .../IWorkflowSettingsService.cs | 11 + .../Services/WorkflowSettingsService.cs | 185 ++ .../Models/OrganizationScheduleSettings.cs | 12 + .../Models/OrganizationWorkflowStatus.cs | 11 + ...ganizationScheduleSettingsConfiguration.cs | 19 + ...OrganizationWorkflowStatusConfiguration.cs | 18 + ...AddWorkflowAndScheduleSettings.Designer.cs | 2543 +++++++++++++++++ ...20233930_AddWorkflowAndScheduleSettings.cs | 81 + .../JobFlowDbContextModelSnapshot.cs | 108 + JobFlow.Tests/JobFlow.Tests.csproj | 28 + JobFlow.Tests/UnitTest1.cs | 112 + 25 files changed, 3636 insertions(+), 8 deletions(-) create mode 100644 JobFlow.API/Controllers/ScheduleSettingsController.cs create mode 100644 JobFlow.API/Controllers/WorkflowSettingsController.cs create mode 100644 JobFlow.Business/Models/DTOs/ScheduleSettingsDtos.cs create mode 100644 JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs create mode 100644 JobFlow.Business/Services/ScheduleSettingsService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IScheduleSettingsService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IWorkflowSettingsService.cs create mode 100644 JobFlow.Business/Services/WorkflowSettingsService.cs create mode 100644 JobFlow.Domain/Models/OrganizationScheduleSettings.cs create mode 100644 JobFlow.Domain/Models/OrganizationWorkflowStatus.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/OrganizationScheduleSettingsConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/OrganizationWorkflowStatusConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.cs create mode 100644 JobFlow.Tests/JobFlow.Tests.csproj create mode 100644 JobFlow.Tests/UnitTest1.cs diff --git a/JobFlow.API/Controllers/ScheduleSettingsController.cs b/JobFlow.API/Controllers/ScheduleSettingsController.cs new file mode 100644 index 0000000..59b43db --- /dev/null +++ b/JobFlow.API/Controllers/ScheduleSettingsController.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/schedule-settings")] +public class ScheduleSettingsController : ControllerBase +{ + private readonly IScheduleSettingsService _scheduleSettings; + + public ScheduleSettingsController(IScheduleSettingsService scheduleSettings) + { + _scheduleSettings = scheduleSettings; + } + + [HttpGet] + public async Task Get() + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _scheduleSettings.GetScheduleSettingsAsync(organizationId); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + [HttpPut] + public async Task Update([FromBody] ScheduleSettingsUpsertRequestDto dto) + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _scheduleSettings.UpsertScheduleSettingsAsync(organizationId, dto); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } +} diff --git a/JobFlow.API/Controllers/WorkflowSettingsController.cs b/JobFlow.API/Controllers/WorkflowSettingsController.cs new file mode 100644 index 0000000..2a9d9fd --- /dev/null +++ b/JobFlow.API/Controllers/WorkflowSettingsController.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/workflow-settings")] +public class WorkflowSettingsController : ControllerBase +{ + private readonly IWorkflowSettingsService _workflowSettings; + + public WorkflowSettingsController(IWorkflowSettingsService workflowSettings) + { + _workflowSettings = workflowSettings; + } + + [HttpGet("job-statuses")] + public async Task GetJobStatuses() + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _workflowSettings.GetJobLifecycleStatusesAsync(organizationId); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + [HttpPut("job-statuses")] + public async Task UpdateJobStatuses([FromBody] List statuses) + { + var organizationId = HttpContext.GetOrganizationId(); + + var result = await _workflowSettings.UpsertJobLifecycleStatusesAsync(organizationId, statuses); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } +} diff --git a/JobFlow.API/JobFlow.API.sln b/JobFlow.API/JobFlow.API.sln index 749dd4d..d0deb34 100644 --- a/JobFlow.API/JobFlow.API.sln +++ b/JobFlow.API/JobFlow.API.sln @@ -16,32 +16,90 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobFlow.Infrastructure.Pers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobFlow.Business", "..\JobFlow.Business\JobFlow.Business.csproj", "{CB514093-6D19-4261-A1E3-75CA8DA5EEE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobFlow.Tests", "..\JobFlow.Tests\JobFlow.Tests.csproj", "{306A853C-5A9F-44E1-AAE0-66EA4A314DEC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|x64.Build.0 = Debug|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Debug|x86.Build.0 = Debug|Any CPU {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|Any CPU.Build.0 = Release|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|x64.ActiveCfg = Release|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|x64.Build.0 = Release|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|x86.ActiveCfg = Release|Any CPU + {50A22EBE-07C0-4616-8F35-F8B6937927F3}.Release|x86.Build.0 = Release|Any CPU {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|x64.ActiveCfg = Debug|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|x64.Build.0 = Debug|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|x86.ActiveCfg = Debug|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Debug|x86.Build.0 = Debug|Any CPU {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|Any CPU.ActiveCfg = Release|Any CPU {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|Any CPU.Build.0 = Release|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|x64.ActiveCfg = Release|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|x64.Build.0 = Release|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|x86.ActiveCfg = Release|Any CPU + {48FBC45E-3C54-457C-8348-5EE12FDB7509}.Release|x86.Build.0 = Release|Any CPU {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|x64.ActiveCfg = Debug|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|x64.Build.0 = Debug|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|x86.ActiveCfg = Debug|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Debug|x86.Build.0 = Debug|Any CPU {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|Any CPU.ActiveCfg = Release|Any CPU {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|Any CPU.Build.0 = Release|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|x64.ActiveCfg = Release|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|x64.Build.0 = Release|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|x86.ActiveCfg = Release|Any CPU + {E78317D2-F53A-40E4-B6A3-D6EFE7176457}.Release|x86.Build.0 = Release|Any CPU {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|x64.ActiveCfg = Debug|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|x64.Build.0 = Debug|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|x86.ActiveCfg = Debug|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Debug|x86.Build.0 = Debug|Any CPU {64605E3D-70CB-4B7A-8457-71727057F147}.Release|Any CPU.ActiveCfg = Release|Any CPU {64605E3D-70CB-4B7A-8457-71727057F147}.Release|Any CPU.Build.0 = Release|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Release|x64.ActiveCfg = Release|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Release|x64.Build.0 = Release|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Release|x86.ActiveCfg = Release|Any CPU + {64605E3D-70CB-4B7A-8457-71727057F147}.Release|x86.Build.0 = Release|Any CPU {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|x64.Build.0 = Debug|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Debug|x86.Build.0 = Debug|Any CPU {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|Any CPU.Build.0 = Release|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|x64.ActiveCfg = Release|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|x64.Build.0 = Release|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|x86.ActiveCfg = Release|Any CPU + {CB514093-6D19-4261-A1E3-75CA8DA5EEE0}.Release|x86.Build.0 = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|x64.ActiveCfg = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|x64.Build.0 = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|x86.ActiveCfg = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Debug|x86.Build.0 = Debug|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|Any CPU.Build.0 = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|x64.ActiveCfg = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|x64.Build.0 = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|x86.ActiveCfg = Release|Any CPU + {306A853C-5A9F-44E1-AAE0-66EA4A314DEC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/JobFlow.Business/ModelErrors/AssignmentErrors.cs b/JobFlow.Business/ModelErrors/AssignmentErrors.cs index e5266e9..61a0617 100644 --- a/JobFlow.Business/ModelErrors/AssignmentErrors.cs +++ b/JobFlow.Business/ModelErrors/AssignmentErrors.cs @@ -50,6 +50,12 @@ public static class AssignmentErrors "Scheduled end date and time must be after the scheduled start." ); + public static readonly Error ScheduleConflictWithBuffer = + Error.Validation( + "Assignment.ScheduleConflictWithBuffer", + "This schedule overlaps another assignment when travel buffer is applied." + ); + // ───────────────────────────────────────────── // Status / Lifecycle // ───────────────────────────────────────────── diff --git a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs index 5a28b0e..4128b66 100644 --- a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs +++ b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs @@ -27,6 +27,8 @@ public class AssignmentDto public JobLifecycleStatus JobLifecycleStatus { get; set; } public List Assignees { get; set; } = new(); + public string? StatusLabel { get; set; } + // Useful for UI calendar public string? JobTitle { get; set; } public Guid OrganizationClientId { get; set; } diff --git a/JobFlow.Business/Models/DTOs/ScheduleSettingsDtos.cs b/JobFlow.Business/Models/DTOs/ScheduleSettingsDtos.cs new file mode 100644 index 0000000..50a261b --- /dev/null +++ b/JobFlow.Business/Models/DTOs/ScheduleSettingsDtos.cs @@ -0,0 +1,17 @@ +namespace JobFlow.Business.Models.DTOs; + +public class ScheduleSettingsDto +{ + public int TravelBufferMinutes { get; set; } + public int DefaultWindowMinutes { get; set; } + public bool EnforceTravelBuffer { get; set; } + public bool AutoNotifyReschedule { get; set; } +} + +public class ScheduleSettingsUpsertRequestDto +{ + public int TravelBufferMinutes { get; set; } + public int DefaultWindowMinutes { get; set; } + public bool EnforceTravelBuffer { get; set; } + public bool AutoNotifyReschedule { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs new file mode 100644 index 0000000..84e69d1 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs @@ -0,0 +1,15 @@ +namespace JobFlow.Business.Models.DTOs; + +public class WorkflowStatusDto +{ + public string StatusKey { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public int SortOrder { get; set; } +} + +public class WorkflowStatusUpsertRequestDto +{ + public string StatusKey { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public int SortOrder { get; set; } +} diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index f44ec29..5373cb8 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -12,6 +12,13 @@ public interface INotificationMessageBuilder NotificationMessage BuildClientWelcome(OrganizationClient client); NotificationMessage BuildClientJobCreated(OrganizationClient client, Job job); NotificationMessage BuildClientJobScheduled(OrganizationClient client, Job job); + NotificationMessage BuildClientJobRescheduled( + OrganizationClient client, + Job job, + DateTimeOffset previousStart, + DateTimeOffset? previousEnd, + DateTimeOffset newStart, + DateTimeOffset? newEnd); NotificationMessage BuildClientInvoiceCreated(OrganizationClient client, Invoice invoice); NotificationMessage BuildClientPaymentReceived(OrganizationClient client, Invoice invoice); diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 1eb3ae6..435e135 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -99,6 +99,28 @@ public NotificationMessage BuildClientJobScheduled(OrganizationClient client, Jo }; } + public NotificationMessage BuildClientJobRescheduled( + OrganizationClient client, + Job job, + DateTimeOffset previousStart, + DateTimeOffset? previousEnd, + DateTimeOffset newStart, + DateTimeOffset? newEnd) + { + var previousSlot = FormatScheduleRange(previousStart, previousEnd); + var nextSlot = FormatScheduleRange(newStart, newEnd); + + return new NotificationMessage + { + Name = client.ClientFullName(), + Email = client.EmailAddress, + Phone = client.PhoneNumber, + Subject = $"Appointment Updated: {job.Title}", + Body = $"Your appointment was rescheduled from {previousSlot} to {nextSlot}.", + Sms = $"Appointment updated: {nextSlot}." + }; + } + public NotificationMessage BuildClientInvoiceCreated(OrganizationClient client, Invoice invoice) { return new NotificationMessage @@ -248,4 +270,17 @@ This link will expire soon. TemplateId = EmailTemplate.OrganizationWelcome }; } + + private static string FormatScheduleRange(DateTimeOffset start, DateTimeOffset? end) + { + var localStart = start.ToLocalTime(); + var localEnd = (end ?? start).ToLocalTime(); + + if (localStart.Date == localEnd.Date) + { + return $"{localStart:MMM dd, yyyy} {localStart:t} - {localEnd:t}"; + } + + return $"{localStart:MMM dd, yyyy h:mm tt} - {localEnd:MMM dd, yyyy h:mm tt}"; + } } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 6312bf7..170412c 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -67,6 +67,18 @@ public async Task SendClientJobScheduledNotificationAsync(OrganizationClient cli await SendNotificationAsync(message); } + public async Task SendClientJobRescheduledNotificationAsync( + OrganizationClient client, + Job job, + DateTimeOffset previousStart, + DateTimeOffset? previousEnd, + DateTimeOffset newStart, + DateTimeOffset? newEnd) + { + var message = _builder.BuildClientJobRescheduled(client, job, previousStart, previousEnd, newStart, newEnd); + await SendNotificationAsync(message); + } + public async Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice) { var message = _builder.BuildClientInvoiceCreated(client, invoice); diff --git a/JobFlow.Business/Services/AssignmentService.cs b/JobFlow.Business/Services/AssignmentService.cs index ada4cf0..b09ef2d 100644 --- a/JobFlow.Business/Services/AssignmentService.cs +++ b/JobFlow.Business/Services/AssignmentService.cs @@ -22,12 +22,18 @@ public class AssignmentService : IAssignmentService private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly IOnboardingService _onboardingService; + private readonly IWorkflowSettingsService _workflowSettings; + private readonly IScheduleSettingsService _scheduleSettings; + private readonly INotificationService _notificationService; private readonly ILogger _logger; public AssignmentService( IUnitOfWork unitOfWork, IMapper mapper, IOnboardingService onboardingService, + IWorkflowSettingsService workflowSettings, + IScheduleSettingsService scheduleSettings, + INotificationService notificationService, ILogger logger) { _unitOfWork = unitOfWork; @@ -38,6 +44,9 @@ public AssignmentService( _mapper = mapper; _onboardingService = onboardingService; + _workflowSettings = workflowSettings; + _scheduleSettings = scheduleSettings; + _notificationService = notificationService; _logger = logger; } @@ -46,6 +55,10 @@ public async Task> CreateAssignmentAsync( Guid jobId, CreateAssignmentRequestDto dto) { + var validation = ValidateSchedule(dto.ScheduledStart, dto.ScheduledEnd, dto.ScheduleType); + if (validation.IsFailure) + return Result.Failure(validation.Error); + var job = await _jobs.Query() .Include(j => j.OrganizationClient) .FirstOrDefaultAsync(j => @@ -84,7 +97,7 @@ await _onboardingService.MarkStepCompleteAsync( .ThenInclude(assignee => assignee.Employee) .FirstAsync(a => a.Id == assignment.Id); - return Result.Success(MapToDto(created)); + return Result.Success(await MapToDtoAsync(organizationId, created)); } public async Task> UpdateAssignmentScheduleAsync( @@ -92,6 +105,10 @@ public async Task> UpdateAssignmentScheduleAsync( Guid assignmentId, UpdateAssignmentScheduleRequestDto dto) { + var validation = ValidateSchedule(dto.ScheduledStart, dto.ScheduledEnd, dto.ScheduleType); + if (validation.IsFailure) + return Result.Failure(validation.Error); + var assignment = await _assignments.Query() .Include(a => a.Job) .ThenInclude(j => j.OrganizationClient) @@ -104,6 +121,23 @@ public async Task> UpdateAssignmentScheduleAsync( if (assignment == null) return Result.Failure(AssignmentErrors.NotFound); + var originalStart = assignment.ScheduledStart; + var originalEnd = assignment.ScheduledEnd; + + var scheduleSettings = await _scheduleSettings.GetScheduleSettingsAsync(organizationId); + if (scheduleSettings.IsFailure) + return Result.Failure(scheduleSettings.Error); + + var conflictCheck = await EnsureNoBufferedConflictsAsync( + organizationId, + assignment, + dto.ScheduledStart, + dto.ScheduledEnd, + scheduleSettings.Value); + + if (conflictCheck.IsFailure) + return Result.Failure(conflictCheck.Error); + assignment.ScheduleType = dto.ScheduleType; assignment.ScheduledStart = dto.ScheduledStart; assignment.ScheduledEnd = dto.ScheduledEnd; @@ -111,7 +145,12 @@ public async Task> UpdateAssignmentScheduleAsync( _assignments.Update(assignment); await _unitOfWork.SaveChangesAsync(); - return Result.Success(MapToDto(assignment)); + if (scheduleSettings.Value.AutoNotifyReschedule && ScheduleChanged(originalStart, originalEnd, assignment)) + { + await TryNotifyRescheduleAsync(assignment, originalStart, originalEnd); + } + + return Result.Success(await MapToDtoAsync(organizationId, assignment)); } public async Task> UpdateAssignmentStatusAsync( @@ -138,7 +177,7 @@ public async Task> UpdateAssignmentStatusAsync( _assignments.Update(assignment); await _unitOfWork.SaveChangesAsync(); - return Result.Success(MapToDto(assignment)); + return Result.Success(await MapToDtoAsync(organizationId, assignment)); } public async Task> UpdateAssignmentAssigneesAsync( @@ -198,7 +237,7 @@ public async Task> UpdateAssignmentAssigneesAsync( .ThenInclude(assignee => assignee.Employee) .FirstAsync(a => a.Id == assignmentId); - return Result.Success(MapToDto(updated)); + return Result.Success(await MapToDtoAsync(organizationId, updated)); } public async Task> UpdateAssignmentNotesAsync( @@ -222,7 +261,7 @@ public async Task> UpdateAssignmentNotesAsync( _assignments.Update(assignment); await _unitOfWork.SaveChangesAsync(); - return Result.Success(MapToDto(assignment)); + return Result.Success(await MapToDtoAsync(organizationId, assignment)); } public async Task>> GetAssignmentsAsync( @@ -242,7 +281,14 @@ public async Task>> GetAssignmentsAsync( .OrderBy(a => a.ScheduledStart) .ToListAsync(); - return Result.Success(assignments.Select(MapToDto).ToList()); + var labelMapResult = await _workflowSettings.GetJobLifecycleLabelMapAsync(organizationId); + if (labelMapResult.IsFailure) + return Result.Failure>(labelMapResult.Error); + + var labelMap = labelMapResult.Value; + var mapped = assignments.Select(a => MapToDto(a, labelMap)).ToList(); + + return Result.Success(mapped); } public async Task> GetAssignmentByIdAsync( @@ -261,10 +307,18 @@ public async Task> GetAssignmentByIdAsync( if (assignment == null) return Result.Failure(AssignmentErrors.NotFound); - return Result.Success(MapToDto(assignment)); + return Result.Success(await MapToDtoAsync(organizationId, assignment)); + } + + private async Task MapToDtoAsync(Guid organizationId, Assignment assignment) + { + var labelMapResult = await _workflowSettings.GetJobLifecycleLabelMapAsync(organizationId); + var labelMap = labelMapResult.IsSuccess ? labelMapResult.Value : new Dictionary(); + + return MapToDto(assignment, labelMap); } - private AssignmentDto MapToDto(Assignment assignment) + private AssignmentDto MapToDto(Assignment assignment, Dictionary labelMap) { var dto = _mapper.Map(assignment); @@ -275,6 +329,10 @@ private AssignmentDto MapToDto(Assignment assignment) ? $"{assignment.Job.OrganizationClient.FirstName} {assignment.Job.OrganizationClient.LastName}" : null; dto.JobLifecycleStatus = assignment.Job?.LifecycleStatus ?? JobLifecycleStatus.Draft; + if (labelMap.TryGetValue(dto.JobLifecycleStatus, out var label)) + { + dto.StatusLabel = label; + } dto.Assignees = assignment.AssignmentAssignees .Select(assignee => new AssignmentAssigneeDto { @@ -288,4 +346,109 @@ private AssignmentDto MapToDto(Assignment assignment) return dto; } + + private static Result ValidateSchedule(DateTimeOffset scheduledStart, DateTimeOffset? scheduledEnd, ScheduleType scheduleType) + { + if (scheduledStart == default) + { + return Result.Failure(AssignmentErrors.ScheduledStartRequired); + } + + if (scheduleType == ScheduleType.Window && !scheduledEnd.HasValue) + { + return Result.Failure(AssignmentErrors.ScheduledEndRequiredForWindow); + } + + if (scheduledEnd.HasValue && scheduledEnd.Value <= scheduledStart) + { + return Result.Failure(AssignmentErrors.ScheduledEndMustBeAfterStart); + } + + return Result.Success(); + } + + private async Task EnsureNoBufferedConflictsAsync( + Guid organizationId, + Assignment assignment, + DateTimeOffset newStart, + DateTimeOffset? newEnd, + ScheduleSettingsDto settings) + { + if (!settings.EnforceTravelBuffer) + return Result.Success(); + + var assigneeIds = assignment.AssignmentAssignees + .Select(x => x.EmployeeId) + .Distinct() + .ToList(); + + if (assigneeIds.Count == 0) + return Result.Success(); + + var bufferMinutes = settings.TravelBufferMinutes; + var newRangeStart = newStart.AddMinutes(-bufferMinutes); + var newRangeEnd = (newEnd ?? newStart).AddMinutes(bufferMinutes); + + var candidateAssignments = await _assignments.Query() + .Include(a => a.AssignmentAssignees) + .Include(a => a.Job) + .ThenInclude(j => j.OrganizationClient) + .Where(a => + a.Id != assignment.Id && + a.Job.OrganizationClient.OrganizationId == organizationId && + a.AssignmentAssignees.Any(assignee => assigneeIds.Contains(assignee.EmployeeId))) + .ToListAsync(); + + foreach (var other in candidateAssignments) + { + var otherStart = other.ScheduledStart; + var otherEnd = other.ScheduledEnd ?? other.ScheduledStart; + var otherRangeStart = otherStart.AddMinutes(-bufferMinutes); + var otherRangeEnd = otherEnd.AddMinutes(bufferMinutes); + + if (RangesOverlap(newRangeStart, newRangeEnd, otherRangeStart, otherRangeEnd)) + { + return Result.Failure(AssignmentErrors.ScheduleConflictWithBuffer); + } + } + + return Result.Success(); + } + + private static bool RangesOverlap(DateTimeOffset startA, DateTimeOffset endA, DateTimeOffset startB, DateTimeOffset endB) + { + return startA < endB && startB < endA; + } + + private static bool ScheduleChanged(DateTimeOffset originalStart, DateTimeOffset? originalEnd, Assignment assignment) + { + if (originalStart != assignment.ScheduledStart) + return true; + + var originalEndValue = originalEnd ?? originalStart; + var updatedEndValue = assignment.ScheduledEnd ?? assignment.ScheduledStart; + + return originalEndValue != updatedEndValue; + } + + private async Task TryNotifyRescheduleAsync(Assignment assignment, DateTimeOffset oldStart, DateTimeOffset? oldEnd) + { + try + { + if (assignment.Job?.OrganizationClient == null) + return; + + await _notificationService.SendClientJobRescheduledNotificationAsync( + assignment.Job.OrganizationClient, + assignment.Job, + oldStart, + oldEnd, + assignment.ScheduledStart, + assignment.ScheduledEnd); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send reschedule notification for assignment {AssignmentId}", assignment.Id); + } + } } diff --git a/JobFlow.Business/Services/ScheduleSettingsService.cs b/JobFlow.Business/Services/ScheduleSettingsService.cs new file mode 100644 index 0000000..9584151 --- /dev/null +++ b/JobFlow.Business/Services/ScheduleSettingsService.cs @@ -0,0 +1,85 @@ +using JobFlow.Business; +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class ScheduleSettingsService : IScheduleSettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IRepository _settings; + + public ScheduleSettingsService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _settings = unitOfWork.RepositoryOf(); + } + + public async Task> GetScheduleSettingsAsync(Guid organizationId) + { + var settings = await _settings.Query() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId); + + if (settings == null) + { + return Result.Success(new ScheduleSettingsDto + { + TravelBufferMinutes = 20, + DefaultWindowMinutes = 120, + EnforceTravelBuffer = true, + AutoNotifyReschedule = true + }); + } + + return Result.Success(Map(settings)); + } + + public async Task> UpsertScheduleSettingsAsync( + Guid organizationId, + ScheduleSettingsUpsertRequestDto dto) + { + if (dto.TravelBufferMinutes < 0 || dto.DefaultWindowMinutes < 0) + { + return Result.Failure( + Error.Validation("ScheduleSettings.InvalidValues", "Buffer and window values must be zero or greater.")); + } + + var settings = await _settings.Query() + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId); + + if (settings == null) + { + settings = new OrganizationScheduleSettings + { + OrganizationId = organizationId + }; + _settings.Add(settings); + } + + settings.TravelBufferMinutes = dto.TravelBufferMinutes; + settings.DefaultWindowMinutes = dto.DefaultWindowMinutes; + settings.EnforceTravelBuffer = dto.EnforceTravelBuffer; + settings.AutoNotifyReschedule = dto.AutoNotifyReschedule; + + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(Map(settings)); + } + + private static ScheduleSettingsDto Map(OrganizationScheduleSettings settings) + { + return new ScheduleSettingsDto + { + TravelBufferMinutes = settings.TravelBufferMinutes, + DefaultWindowMinutes = settings.DefaultWindowMinutes, + EnforceTravelBuffer = settings.EnforceTravelBuffer, + AutoNotifyReschedule = settings.AutoNotifyReschedule + }; + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 4fa9a3a..0c551c6 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -13,6 +13,13 @@ public interface INotificationService Task SendClientWelcomeNotificationAsync(OrganizationClient client); Task SendClientJobCreatedNotificationAsync(OrganizationClient client, Job job); Task SendClientJobScheduledNotificationAsync(OrganizationClient client, Job job); + Task SendClientJobRescheduledNotificationAsync( + OrganizationClient client, + Job job, + DateTimeOffset previousStart, + DateTimeOffset? previousEnd, + DateTimeOffset newStart, + DateTimeOffset? newEnd); Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice); Task SendClientPaymentReceivedNotificationAsync(OrganizationClient client, Invoice invoice); Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IScheduleSettingsService.cs b/JobFlow.Business/Services/ServiceInterfaces/IScheduleSettingsService.cs new file mode 100644 index 0000000..35be093 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IScheduleSettingsService.cs @@ -0,0 +1,9 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IScheduleSettingsService +{ + Task> GetScheduleSettingsAsync(Guid organizationId); + Task> UpsertScheduleSettingsAsync(Guid organizationId, ScheduleSettingsUpsertRequestDto dto); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IWorkflowSettingsService.cs b/JobFlow.Business/Services/ServiceInterfaces/IWorkflowSettingsService.cs new file mode 100644 index 0000000..7f12a08 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IWorkflowSettingsService.cs @@ -0,0 +1,11 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IWorkflowSettingsService +{ + Task>> GetJobLifecycleStatusesAsync(Guid organizationId); + Task>> UpsertJobLifecycleStatusesAsync(Guid organizationId, List statuses); + Task>> GetJobLifecycleLabelMapAsync(Guid organizationId); +} diff --git a/JobFlow.Business/Services/WorkflowSettingsService.cs b/JobFlow.Business/Services/WorkflowSettingsService.cs new file mode 100644 index 0000000..59fea41 --- /dev/null +++ b/JobFlow.Business/Services/WorkflowSettingsService.cs @@ -0,0 +1,185 @@ +using JobFlow.Business; +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 WorkflowSettingsService : IWorkflowSettingsService +{ + private const string JobLifecycleCategory = "JobLifecycle"; + + private readonly IUnitOfWork _unitOfWork; + private readonly IRepository _statuses; + + public WorkflowSettingsService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _statuses = unitOfWork.RepositoryOf(); + } + + public async Task>> GetJobLifecycleStatusesAsync(Guid organizationId) + { + var stored = await _statuses.Query() + .AsNoTracking() + .Where(x => x.OrganizationId == organizationId && x.Category == JobLifecycleCategory) + .OrderBy(x => x.SortOrder) + .ToListAsync(); + + if (stored.Count == 0) + { + var defaults = BuildDefaultJobLifecycleStatuses(); + return Result.Success(defaults); + } + + var mapped = stored.Select(x => new WorkflowStatusDto + { + StatusKey = x.StatusKey, + Label = x.Label, + SortOrder = x.SortOrder + }).ToList(); + + return Result.Success(mapped); + } + + public async Task>> UpsertJobLifecycleStatusesAsync( + Guid organizationId, + List statuses) + { + if (statuses == null || statuses.Count == 0) + { + return Result.Failure>( + Error.Validation("Workflow.StatusesRequired", "At least one status is required.")); + } + + var normalized = statuses + .Select(x => new WorkflowStatusUpsertRequestDto + { + StatusKey = x.StatusKey?.Trim() ?? string.Empty, + Label = x.Label?.Trim() ?? string.Empty, + SortOrder = x.SortOrder + }) + .ToList(); + + if (normalized.Any(x => string.IsNullOrWhiteSpace(x.StatusKey) || string.IsNullOrWhiteSpace(x.Label))) + { + return Result.Failure>( + Error.Validation("Workflow.InvalidStatus", "Status key and label are required.")); + } + + var duplicateKeys = normalized + .GroupBy(x => x.StatusKey, StringComparer.OrdinalIgnoreCase) + .Any(g => g.Count() > 1); + + if (duplicateKeys) + { + return Result.Failure>( + Error.Validation("Workflow.DuplicateStatus", "Status keys must be unique.")); + } + + foreach (var status in normalized) + { + if (!Enum.TryParse(status.StatusKey, true, out _)) + { + return Result.Failure>( + Error.Validation("Workflow.InvalidStatusKey", $"Unknown status key: {status.StatusKey}.")); + } + } + + var existing = await _statuses.Query() + .Where(x => x.OrganizationId == organizationId && x.Category == JobLifecycleCategory) + .ToListAsync(); + + if (existing.Count > 0) + { + _statuses.RemoveRange(existing); + } + + var entities = normalized + .OrderBy(x => x.SortOrder) + .Select((status, index) => new OrganizationWorkflowStatus + { + OrganizationId = organizationId, + Category = JobLifecycleCategory, + StatusKey = status.StatusKey, + Label = status.Label, + SortOrder = index + }) + .ToList(); + + _statuses.AddRange(entities); + await _unitOfWork.SaveChangesAsync(); + + var dto = entities + .OrderBy(x => x.SortOrder) + .Select(x => new WorkflowStatusDto + { + StatusKey = x.StatusKey, + Label = x.Label, + SortOrder = x.SortOrder + }) + .ToList(); + + return Result.Success(dto); + } + + public async Task>> GetJobLifecycleLabelMapAsync(Guid organizationId) + { + var listResult = await GetJobLifecycleStatusesAsync(organizationId); + if (listResult.IsFailure) + { + return Result.Failure>(listResult.Error); + } + + var map = new Dictionary(); + foreach (var status in listResult.Value) + { + if (Enum.TryParse(status.StatusKey, true, out var enumValue)) + { + map[enumValue] = status.Label; + } + } + + return Result.Success(map); + } + + private static List BuildDefaultJobLifecycleStatuses() + { + return Enum.GetValues() + .OrderBy(x => (int)x) + .Select((status, index) => new WorkflowStatusDto + { + StatusKey = status.ToString(), + Label = SplitCamelCase(status.ToString()), + SortOrder = index + }) + .ToList(); + } + + private static string SplitCamelCase(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var buffer = new List(value.Length + 4); + for (var i = 0; i < value.Length; i++) + { + var current = value[i]; + if (i > 0 && char.IsUpper(current) && !char.IsWhiteSpace(value[i - 1])) + { + buffer.Add(' '); + } + + buffer.Add(current); + } + + return new string(buffer.ToArray()); + } +} diff --git a/JobFlow.Domain/Models/OrganizationScheduleSettings.cs b/JobFlow.Domain/Models/OrganizationScheduleSettings.cs new file mode 100644 index 0000000..5763aac --- /dev/null +++ b/JobFlow.Domain/Models/OrganizationScheduleSettings.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Domain.Models; + +public class OrganizationScheduleSettings : Entity +{ + public Guid OrganizationId { get; set; } + + public int TravelBufferMinutes { get; set; } = 20; + public int DefaultWindowMinutes { get; set; } = 120; + + public bool EnforceTravelBuffer { get; set; } = true; + public bool AutoNotifyReschedule { get; set; } = true; +} diff --git a/JobFlow.Domain/Models/OrganizationWorkflowStatus.cs b/JobFlow.Domain/Models/OrganizationWorkflowStatus.cs new file mode 100644 index 0000000..2a731c6 --- /dev/null +++ b/JobFlow.Domain/Models/OrganizationWorkflowStatus.cs @@ -0,0 +1,11 @@ +namespace JobFlow.Domain.Models; + +public class OrganizationWorkflowStatus : Entity +{ + public Guid OrganizationId { get; set; } + + public string Category { get; set; } = "JobLifecycle"; + public string StatusKey { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public int SortOrder { get; set; } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationScheduleSettingsConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationScheduleSettingsConfiguration.cs new file mode 100644 index 0000000..b02b94a --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationScheduleSettingsConfiguration.cs @@ -0,0 +1,19 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class OrganizationScheduleSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.HasIndex(x => x.OrganizationId).IsUnique(); + + builder.Property(x => x.TravelBufferMinutes).HasDefaultValue(20); + builder.Property(x => x.DefaultWindowMinutes).HasDefaultValue(120); + builder.Property(x => x.EnforceTravelBuffer).HasDefaultValue(true); + builder.Property(x => x.AutoNotifyReschedule).HasDefaultValue(true); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationWorkflowStatusConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationWorkflowStatusConfiguration.cs new file mode 100644 index 0000000..2b76fc9 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationWorkflowStatusConfiguration.cs @@ -0,0 +1,18 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class OrganizationWorkflowStatusConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.HasIndex(x => new { x.OrganizationId, x.Category, x.StatusKey }).IsUnique(); + + builder.Property(x => x.Category).HasMaxLength(60).IsRequired(); + builder.Property(x => x.StatusKey).HasMaxLength(80).IsRequired(); + builder.Property(x => x.Label).HasMaxLength(120).IsRequired(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.Designer.cs new file mode 100644 index 0000000..f279e07 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.Designer.cs @@ -0,0 +1,2543 @@ +// +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("20260320233930_AddWorkflowAndScheduleSettings")] + partial class AddWorkflowAndScheduleSettings + { + /// + 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("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/20260320233930_AddWorkflowAndScheduleSettings.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.cs new file mode 100644 index 0000000..1a8734c --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260320233930_AddWorkflowAndScheduleSettings.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddWorkflowAndScheduleSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationScheduleSettings", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + TravelBufferMinutes = table.Column(type: "int", nullable: false, defaultValue: 20), + DefaultWindowMinutes = table.Column(type: "int", nullable: false, defaultValue: 120), + EnforceTravelBuffer = table.Column(type: "bit", nullable: false, defaultValue: true), + AutoNotifyReschedule = table.Column(type: "bit", nullable: false, defaultValue: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationScheduleSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrganizationWorkflowStatus", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + Category = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + StatusKey = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + Label = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationWorkflowStatus", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationScheduleSettings_OrganizationId", + table: "OrganizationScheduleSettings", + column: "OrganizationId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationWorkflowStatus_OrganizationId_Category_StatusKey", + table: "OrganizationWorkflowStatus", + columns: new[] { "OrganizationId", "Category", "StatusKey" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationScheduleSettings"); + + migrationBuilder.DropTable( + name: "OrganizationWorkflowStatus"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index a123307..01c9084 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1488,6 +1488,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1559,6 +1614,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") diff --git a/JobFlow.Tests/JobFlow.Tests.csproj b/JobFlow.Tests/JobFlow.Tests.csproj new file mode 100644 index 0000000..e7d0bf2 --- /dev/null +++ b/JobFlow.Tests/JobFlow.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/JobFlow.Tests/UnitTest1.cs b/JobFlow.Tests/UnitTest1.cs new file mode 100644 index 0000000..449885c --- /dev/null +++ b/JobFlow.Tests/UnitTest1.cs @@ -0,0 +1,112 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services; +using JobFlow.Domain.Enums; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JobFlow.Tests; + +public class WorkflowAndScheduleSettingsTests +{ + [Fact] + public async Task WorkflowSettingsDefaults_WhenNoCustomStatuses() + { + var unitOfWork = CreateUnitOfWork(nameof(WorkflowSettingsDefaults_WhenNoCustomStatuses)); + var service = new WorkflowSettingsService(unitOfWork); + + var result = await service.GetJobLifecycleStatusesAsync(Guid.NewGuid()); + + Assert.True(result.IsSuccess); + Assert.NotEmpty(result.Value); + Assert.Contains(result.Value, status => + status.StatusKey == JobLifecycleStatus.InProgress.ToString() && + status.Label == "In Progress"); + } + + [Fact] + public async Task WorkflowSettingsRejectsDuplicateKeys() + { + var unitOfWork = CreateUnitOfWork(nameof(WorkflowSettingsRejectsDuplicateKeys)); + var service = new WorkflowSettingsService(unitOfWork); + + var payload = new List + { + new() + { + StatusKey = JobLifecycleStatus.Draft.ToString(), + Label = "Draft", + SortOrder = 0 + }, + new() + { + StatusKey = JobLifecycleStatus.Draft.ToString(), + Label = "Draft Again", + SortOrder = 1 + } + }; + + var result = await service.UpsertJobLifecycleStatusesAsync(Guid.NewGuid(), payload); + + Assert.True(result.IsFailure); + } + + [Fact] + public async Task ScheduleSettingsDefaultValues_WhenMissing() + { + var unitOfWork = CreateUnitOfWork(nameof(ScheduleSettingsDefaultValues_WhenMissing)); + var service = new ScheduleSettingsService(unitOfWork); + + var result = await service.GetScheduleSettingsAsync(Guid.NewGuid()); + + Assert.True(result.IsSuccess); + Assert.Equal(20, result.Value.TravelBufferMinutes); + Assert.Equal(120, result.Value.DefaultWindowMinutes); + Assert.True(result.Value.EnforceTravelBuffer); + Assert.True(result.Value.AutoNotifyReschedule); + } + + [Fact] + public async Task ScheduleSettingsRejectsNegativeValues() + { + var unitOfWork = CreateUnitOfWork(nameof(ScheduleSettingsRejectsNegativeValues)); + var service = new ScheduleSettingsService(unitOfWork); + + var result = await service.UpsertScheduleSettingsAsync( + Guid.NewGuid(), + new ScheduleSettingsUpsertRequestDto + { + TravelBufferMinutes = -5, + DefaultWindowMinutes = 30, + EnforceTravelBuffer = true, + AutoNotifyReschedule = true + }); + + Assert.True(result.IsFailure); + } + + private static JobFlowUnitOfWork CreateUnitOfWork(string databaseName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + var factory = new TestDbContextFactory(options); + return new JobFlowUnitOfWork(NullLogger.Instance, factory); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public JobFlowDbContext CreateDbContext() + { + return new JobFlowDbContext(_options); + } + } +} From 6da2fb73c014925c9c7849a0566938ba08adcf31 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 21 Mar 2026 09:44:14 -0400 Subject: [PATCH 11/26] feat(onboading): Add Workflows --- .../Controllers/ClientHubController.cs | 45 +- JobFlow.API/Controllers/InvoiceComtroller.cs | 54 +- .../InvoicingSettingsController.cs | 42 + .../Controllers/OnboardingController.cs | 85 +- .../OrganizationClientController.cs | 10 +- JobFlow.API/Controllers/PaymentController.cs | 30 + JobFlow.API/Hubs/ClientPortalHub.cs | 20 + .../Mappings/InvoiceMappingExtensions.cs | 29 +- JobFlow.API/Models/InvoiceDto.cs | 1 + JobFlow.API/Program.cs | 6 +- .../Services/InvoiceRealtimeNotifier.cs | 40 + JobFlow.Business/Models/DTOs/JobDto.cs | 1 + .../Models/DTOs/OnboardingQuickStartDtos.cs | 39 + .../Models/DTOs/WorkflowSettingsDtos.cs | 12 + .../Builders/INotificationMessageBuilder.cs | 1 + .../Builders/NotificationMessageBuilder.cs | 15 + .../Notifications/Enums/EmailTemplate.cs | 1 + .../Notifications/NotificationService.cs | 22 +- .../Onboarding/OnboardingCatalog.cs | 49 +- .../Onboarding/OnboardingPresetKeys.cs | 20 + .../Onboarding/OnboardingQuickStartCatalog.cs | 270 ++ .../Onboarding/OnboardingStepKeys.cs | 2 + .../Onboarding/OnboardingTrackKeys.cs | 17 + JobFlow.Business/Services/InvoiceService.cs | 145 +- .../Services/InvoicingSettingsService.cs | 70 + JobFlow.Business/Services/JobService.cs | 43 + .../Services/OnboardingService.cs | 155 +- .../OrganizationClientPortalService.cs | 136 +- .../IInvoiceRealtimeNotifier.cs | 8 + .../ServiceInterfaces/IInvoiceService.cs | 2 + .../IInvoicingSettingsService.cs | 11 + .../ServiceInterfaces/INotificationService.cs | 3 +- .../ServiceInterfaces/IOnboardingService.cs | 5 + .../IOrganizationClientPortalService.cs | 14 +- JobFlow.Domain/Enums/InvoicingWorkflow.cs | 7 + JobFlow.Domain/Models/Invoice.cs | 2 + JobFlow.Domain/Models/InvoiceLineItem.cs | 1 + JobFlow.Domain/Models/Job.cs | 1 + JobFlow.Domain/Models/Organization.cs | 4 + .../Models/OrganizationInvoicingSettings.cs | 9 + .../Configurations/InvoiceConfiguration.cs | 2 + .../Configurations/JobConfiguration.cs | 3 + ...anizationInvoicingSettingsConfiguration.cs | 18 + ..._AddOnboardingQuickStartFields.Designer.cs | 2555 ++++++++++++++++ ...321010243_AddOnboardingQuickStartFields.cs | 59 + ...7_AddInvoicingWorkflowSettings.Designer.cs | 2609 +++++++++++++++++ ...0321031057_AddInvoicingWorkflowSettings.cs | 87 + .../JobFlowDbContextModelSnapshot.cs | 66 + .../Extensions/ServiceCollectionExtensions.cs | 6 + .../Weather/OpenMeteoWeatherService.cs | 87 +- .../HttpClients/JobFlowNamedClient.cs | 1 + .../Middleware/ErrorHandlingMiddleware.cs | 52 +- .../Middleware/FirebaseAuthMiddleware.cs | 9 +- 53 files changed, 6898 insertions(+), 83 deletions(-) create mode 100644 JobFlow.API/Controllers/InvoicingSettingsController.cs create mode 100644 JobFlow.API/Hubs/ClientPortalHub.cs create mode 100644 JobFlow.API/Services/InvoiceRealtimeNotifier.cs create mode 100644 JobFlow.Business/Models/DTOs/OnboardingQuickStartDtos.cs create mode 100644 JobFlow.Business/Onboarding/OnboardingPresetKeys.cs create mode 100644 JobFlow.Business/Onboarding/OnboardingQuickStartCatalog.cs create mode 100644 JobFlow.Business/Onboarding/OnboardingTrackKeys.cs create mode 100644 JobFlow.Business/Services/InvoicingSettingsService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IInvoiceRealtimeNotifier.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IInvoicingSettingsService.cs create mode 100644 JobFlow.Domain/Enums/InvoicingWorkflow.cs create mode 100644 JobFlow.Domain/Models/OrganizationInvoicingSettings.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321010243_AddOnboardingQuickStartFields.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321031057_AddInvoicingWorkflowSettings.cs 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; } From 7c8a6d044c498860ee682b3fd79b2a18b7bb52f0 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 21 Mar 2026 11:08:12 -0400 Subject: [PATCH 12/26] feat(clienthub): Add updates for clients --- .../Controllers/ClientHubController.cs | 221 ++ JobFlow.API/Controllers/JobController.cs | 90 +- .../ModelErrors/JobUpdateErrors.cs | 11 + JobFlow.Business/Models/DTOs/JobDto.cs | 29 +- JobFlow.Business/Models/DTOs/JobUpdateDtos.cs | 40 + JobFlow.Business/Services/JobService.cs | 33 + JobFlow.Business/Services/JobUpdateService.cs | 189 ++ .../Services/ServiceInterfaces/IJobService.cs | 2 + .../ServiceInterfaces/IJobUpdateService.cs | 17 + .../CreateJobUpdateRequestValidator.cs | 75 + JobFlow.Domain/Enums/JobUpdateType.cs | 9 + JobFlow.Domain/Models/Job.cs | 1 + JobFlow.Domain/Models/JobUpdate.cs | 17 + JobFlow.Domain/Models/JobUpdateAttachment.cs | 12 + .../JobUpdateAttachmentConfiguration.cs | 19 + .../Configurations/JobUpdateConfiguration.cs | 30 + .../JobFlowDbContext.cs | 2 + .../20260321144452_AddJobUpdates.Designer.cs | 2742 +++++++++++++++++ .../20260321144452_AddJobUpdates.cs | 93 + .../JobFlowDbContextModelSnapshot.cs | 133 + 20 files changed, 3762 insertions(+), 3 deletions(-) create mode 100644 JobFlow.Business/ModelErrors/JobUpdateErrors.cs create mode 100644 JobFlow.Business/Models/DTOs/JobUpdateDtos.cs create mode 100644 JobFlow.Business/Services/JobUpdateService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IJobUpdateService.cs create mode 100644 JobFlow.Business/Validators/CreateJobUpdateRequestValidator.cs create mode 100644 JobFlow.Domain/Enums/JobUpdateType.cs create mode 100644 JobFlow.Domain/Models/JobUpdate.cs create mode 100644 JobFlow.Domain/Models/JobUpdateAttachment.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/JobUpdateAttachmentConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/JobUpdateConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.cs diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 762fa3c..45e4936 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -24,6 +24,8 @@ public class ClientHubController : ControllerBase private readonly IEstimateService _estimates; private readonly IEstimateRevisionService _estimateRevisions; private readonly IInvoiceService _invoices; + private readonly IJobService _jobs; + private readonly IJobUpdateService _jobUpdates; private readonly IOrganizationClientService _clients; private readonly IHubContext _hubContext; private readonly IHubContext _chatHubContext; @@ -35,6 +37,8 @@ public ClientHubController( IEstimateService estimates, IEstimateRevisionService estimateRevisions, IInvoiceService invoices, + IJobService jobs, + IJobUpdateService jobUpdates, IOrganizationClientService clients, IHubContext hubContext, IHubContext chatHubContext, @@ -45,6 +49,8 @@ public ClientHubController( _estimates = estimates; _estimateRevisions = estimateRevisions; _invoices = invoices; + _jobs = jobs; + _jobUpdates = jobUpdates; _clients = clients; _hubContext = hubContext; _chatHubContext = chatHubContext; @@ -412,6 +418,221 @@ public async Task DownloadEstimateRevisionAttachment(Guid estimateId, G return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName); } + [HttpGet("jobs")] + public async Task GetMyJobs() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var jobsResult = await _jobs.GetJobsForClientAsync(organizationId, orgClientId); + if (!jobsResult.IsSuccess) + return jobsResult.ToProblemDetails(); + + var response = jobsResult.Value + .Select(job => new ClientJobSummaryDto( + job.Id, + job.Title, + job.LifecycleStatus, + job.CreatedAt, + job.UpdatedAt)) + .ToList(); + + return Results.Ok(response); + } + + [HttpGet("jobs/{id:guid}")] + public async Task GetMyJob(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var jobResult = await _jobs.GetJobForClientAsync(id, organizationId, orgClientId); + if (!jobResult.IsSuccess) + return jobResult.ToProblemDetails(); + + var job = jobResult.Value; + var response = new ClientJobSummaryDto( + job.Id, + job.Title, + job.LifecycleStatus, + job.CreatedAt, + job.UpdatedAt); + + return Results.Ok(response); + } + + [HttpGet("jobs/{id:guid}/timeline")] + public async Task GetMyJobTimeline(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var jobResult = await _jobs.GetJobForClientAsync(id, organizationId, orgClientId); + if (!jobResult.IsSuccess) + return jobResult.ToProblemDetails(); + + var job = jobResult.Value; + var timeline = new List + { + new( + $"job-created-{job.Id}", + "job-created", + "Job created", + job.Title, + ToDateTimeOffset(job.CreatedAt), + job.LifecycleStatus.ToString(), + null, + null, + null, + null) + }; + + if (job.UpdatedAt.HasValue && job.UpdatedAt.Value > job.CreatedAt) + { + timeline.Add(new JobTimelineItemDto( + $"job-status-{job.Id}", + "status", + "Status updated", + job.LifecycleStatus.ToString(), + ToDateTimeOffset(job.UpdatedAt.Value), + job.LifecycleStatus.ToString(), + null, + null, + null, + null)); + } + + if (!string.IsNullOrWhiteSpace(job.Comments)) + { + var noteTimestamp = job.UpdatedAt ?? job.CreatedAt; + timeline.Add(new JobTimelineItemDto( + $"job-note-{job.Id}", + "note", + "Job note", + job.Comments, + ToDateTimeOffset(noteTimestamp), + null, + null, + null, + null, + null)); + } + + var updateResult = await _jobUpdates.GetByJobForClientAsync(id, organizationId, orgClientId); + if (!updateResult.IsSuccess) + return updateResult.ToProblemDetails(); + + foreach (var update in updateResult.Value) + { + var type = update.Type switch + { + JobUpdateType.StatusChange => "status", + JobUpdateType.Photo => "photo", + JobUpdateType.System => "system", + _ => "note" + }; + + var title = update.Type switch + { + JobUpdateType.StatusChange => "Status changed", + JobUpdateType.Photo => "Photo update", + JobUpdateType.System => "System update", + _ => "Job note" + }; + + var detail = update.Type switch + { + JobUpdateType.StatusChange => update.Status?.ToString(), + JobUpdateType.Photo => update.Attachments.Count > 1 + ? $"{update.Attachments.Count} photos shared" + : "Photo shared", + _ => update.Message + }; + + timeline.Add(new JobTimelineItemDto( + $"job-update-{update.Id}", + type, + title, + detail, + update.OccurredAt, + update.Status?.ToString(), + null, + null, + update.Id, + update.Attachments.Select(a => new JobTimelineAttachmentDto( + a.Id, + a.FileName, + a.ContentType)).ToList())); + } + + var invoicesResult = await _invoices.GetInvoicesByClientAsync(orgClientId); + if (!invoicesResult.IsSuccess) + return invoicesResult.ToProblemDetails(); + + foreach (var invoice in invoicesResult.Value.Where(i => i.JobId == job.Id)) + { + timeline.Add(new JobTimelineItemDto( + $"invoice-sent-{invoice.Id}", + "invoice-sent", + $"Invoice {invoice.InvoiceNumber} sent", + "Review and pay when ready.", + ToDateTimeOffset(invoice.InvoiceDate), + invoice.Status.ToString(), + invoice.TotalAmount, + invoice.Id, + null, + null)); + + if (invoice.PaidAt.HasValue) + { + timeline.Add(new JobTimelineItemDto( + $"invoice-paid-{invoice.Id}", + "invoice-paid", + $"Invoice {invoice.InvoiceNumber} paid", + "Payment received. Thank you!", + invoice.PaidAt.Value, + invoice.Status.ToString(), + invoice.AmountPaid, + invoice.Id, + null, + null)); + } + } + + var ordered = timeline + .OrderByDescending(item => item.OccurredAt) + .ToList(); + + return Results.Ok(ordered); + } + + [HttpGet("jobs/{jobId:guid}/updates/{updateId:guid}/attachments/{attachmentId:guid}")] + public async Task DownloadJobUpdateAttachment( + Guid jobId, + Guid updateId, + Guid attachmentId) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var result = await _jobUpdates.GetAttachmentAsync( + jobId, + updateId, + attachmentId, + organizationId, + orgClientId); + + return result.IsSuccess + ? Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName) + : result.ToProblemDetails(); + } + + private static DateTimeOffset ToDateTimeOffset(DateTime value) + { + var utc = DateTime.SpecifyKind(value, DateTimeKind.Utc); + return new DateTimeOffset(utc); + } + [HttpGet("invoices")] public async Task GetMyInvoices() { diff --git a/JobFlow.API/Controllers/JobController.cs b/JobFlow.API/Controllers/JobController.cs index 0482757..4a589e5 100644 --- a/JobFlow.API/Controllers/JobController.cs +++ b/JobFlow.API/Controllers/JobController.cs @@ -5,6 +5,7 @@ using JobFlow.Domain.Enums; using JobFlow.Domain.Models; using MapsterMapper; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; @@ -15,12 +16,18 @@ public class JobController : ControllerBase { private readonly IJobService _jobService; private readonly IJobRecurrenceService _recurrenceService; + private readonly IJobUpdateService _jobUpdates; private readonly IMapper _mapper; - public JobController(IJobService jobService, IJobRecurrenceService recurrenceService, IMapper mapper) + public JobController( + IJobService jobService, + IJobRecurrenceService recurrenceService, + IJobUpdateService jobUpdates, + IMapper mapper) { _jobService = jobService; _recurrenceService = recurrenceService; + _jobUpdates = jobUpdates; _mapper = mapper; } @@ -128,5 +135,84 @@ public async Task UpdateStatus(Guid jobId, [FromBody] UpdateJobSt return Ok(result.Value); } + [HttpGet("{jobId:guid}/updates")] + public async Task GetJobUpdates(Guid jobId) + { + var organizationId = HttpContext.GetOrganizationId(); + if (organizationId == Guid.Empty) + return Unauthorized("Organization context missing."); + + var result = await _jobUpdates.GetByJobAsync(jobId, organizationId); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + [HttpPost("{jobId:guid}/updates")] + [RequestSizeLimit(55_000_000)] + public async Task CreateJobUpdate( + Guid jobId, + [FromForm] CreateJobUpdateFormRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + if (organizationId == Guid.Empty) + return Unauthorized("Organization context missing."); + + var uploads = new List(); + if (request.Attachments is not null) + { + foreach (var file in request.Attachments) + { + if (file.Length <= 0) + continue; + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream); + + uploads.Add(new JobUpdateAttachmentUpload( + file.FileName, + file.ContentType, + stream.ToArray(), + file.Length)); + } + } + + var createRequest = new CreateJobUpdateRequest( + request.Type, + request.Message, + request.Status, + uploads); + + var result = await _jobUpdates.CreateAsync(jobId, organizationId, createRequest); + if (result.IsFailure) + return BadRequest(result.Error); + + return Ok(result.Value); + } + + [HttpGet("{jobId:guid}/updates/{updateId:guid}/attachments/{attachmentId:guid}")] + public async Task DownloadJobUpdateAttachment( + Guid jobId, + Guid updateId, + Guid attachmentId) + { + var organizationId = HttpContext.GetOrganizationId(); + if (organizationId == Guid.Empty) + return Unauthorized("Organization context missing."); + + var result = await _jobUpdates.GetAttachmentAsync(jobId, updateId, attachmentId, organizationId); + if (result.IsFailure) + return BadRequest(result.Error); + + return File(result.Value.Content, result.Value.ContentType, result.Value.FileName); + } + + +} -} \ No newline at end of file +public record CreateJobUpdateFormRequest( + JobUpdateType Type, + string? Message, + JobLifecycleStatus? Status, + List? Attachments); \ No newline at end of file diff --git a/JobFlow.Business/ModelErrors/JobUpdateErrors.cs b/JobFlow.Business/ModelErrors/JobUpdateErrors.cs new file mode 100644 index 0000000..6e9b899 --- /dev/null +++ b/JobFlow.Business/ModelErrors/JobUpdateErrors.cs @@ -0,0 +1,11 @@ +using JobFlow.Domain; + +namespace JobFlow.Business.ModelErrors; + +public static class JobUpdateErrors +{ + public static Error JobNotFound => Error.NotFound("JobUpdate", "Job not found."); + public static Error UpdateNotFound => Error.NotFound("JobUpdate", "Job update not found."); + public static Error AttachmentNotFound => Error.NotFound("JobUpdate", "Update attachment not found."); + public static Error UnauthorizedJobAccess => Error.Validation("JobUpdate.Unauthorized", "Unauthorized job access."); +} diff --git a/JobFlow.Business/Models/DTOs/JobDto.cs b/JobFlow.Business/Models/DTOs/JobDto.cs index 1f499ab..ec3f021 100644 --- a/JobFlow.Business/Models/DTOs/JobDto.cs +++ b/JobFlow.Business/Models/DTOs/JobDto.cs @@ -19,4 +19,31 @@ public class JobDto public class UpdateJobStatusRequestDto { public JobLifecycleStatus Status { get; set; } -} \ No newline at end of file +} + +public sealed record ClientJobSummaryDto( + Guid Id, + string? Title, + JobLifecycleStatus Status, + DateTime CreatedAt, + DateTime? UpdatedAt +); + +public sealed record JobTimelineItemDto( + string Id, + string Type, + string Title, + string? Detail, + DateTimeOffset OccurredAt, + string? Status, + decimal? Amount, + Guid? InvoiceId, + Guid? UpdateId, + IReadOnlyList? Attachments +); + +public sealed record JobTimelineAttachmentDto( + Guid Id, + string FileName, + string ContentType +); \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/JobUpdateDtos.cs b/JobFlow.Business/Models/DTOs/JobUpdateDtos.cs new file mode 100644 index 0000000..ab929d3 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/JobUpdateDtos.cs @@ -0,0 +1,40 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public sealed record JobUpdateAttachmentUpload( + string FileName, + string ContentType, + byte[] Content, + long SizeBytes +); + +public sealed record CreateJobUpdateRequest( + JobUpdateType Type, + string? Message, + JobLifecycleStatus? Status, + IReadOnlyList Attachments +); + +public sealed record JobUpdateAttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes +); + +public sealed record JobUpdateAttachmentDownloadDto( + string FileName, + string ContentType, + byte[] Content +); + +public sealed record JobUpdateDto( + Guid Id, + Guid JobId, + JobUpdateType Type, + string? Message, + JobLifecycleStatus? Status, + DateTimeOffset OccurredAt, + IReadOnlyList Attachments +); diff --git a/JobFlow.Business/Services/JobService.cs b/JobFlow.Business/Services/JobService.cs index 00fcf4d..fa65389 100644 --- a/JobFlow.Business/Services/JobService.cs +++ b/JobFlow.Business/Services/JobService.cs @@ -54,6 +54,24 @@ public async Task> GetJobByIdAsync(Guid id, Guid organizationId) return Result.Success(job); } + public async Task> GetJobForClientAsync( + Guid id, + Guid organizationId, + Guid organizationClientId) + { + var job = await jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => + j.Id == id && + j.OrganizationClient.OrganizationId == organizationId && + j.OrganizationClientId == organizationClientId); + + if (job == null) + return Result.Failure(JobErrors.NotFound); + + return Result.Success(job); + } + public async Task>> GetJobsByStatusAsync( Guid organizationId, JobLifecycleStatus status) @@ -68,6 +86,21 @@ public async Task>> GetJobsByStatusAsync( return Result.Success>(list); } + public async Task>> GetJobsForClientAsync( + Guid organizationId, + Guid organizationClientId) + { + var list = await jobs.Query() + .Include(j => j.OrganizationClient) + .Where(j => + j.OrganizationClient.OrganizationId == organizationId && + j.OrganizationClientId == organizationClientId) + .OrderByDescending(j => j.CreatedAt) + .ToListAsync(); + + return Result.Success>(list); + } + public async Task>> GetJobsAsync(Guid organizationId) { var returnedJobs = await jobs.Query() diff --git a/JobFlow.Business/Services/JobUpdateService.cs b/JobFlow.Business/Services/JobUpdateService.cs new file mode 100644 index 0000000..3cb419f --- /dev/null +++ b/JobFlow.Business/Services/JobUpdateService.cs @@ -0,0 +1,189 @@ +using FluentValidation; +using JobFlow.Business.DI; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class JobUpdateService : IJobUpdateService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + private readonly IRepository _jobs; + private readonly IRepository _updates; + private readonly IRepository _attachments; + + public JobUpdateService(IUnitOfWork unitOfWork, IValidator validator) + { + _unitOfWork = unitOfWork; + _validator = validator; + _jobs = unitOfWork.RepositoryOf(); + _updates = unitOfWork.RepositoryOf(); + _attachments = unitOfWork.RepositoryOf(); + } + + public async Task> CreateAsync( + Guid jobId, + Guid organizationId, + CreateJobUpdateRequest request) + { + var validationResult = await _validator.ValidateAsync(request); + if (!validationResult.IsValid) + { + return Result.Failure(Error.Validation( + "JobUpdate.ValidationFailed", + string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)))); + } + + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job is null) + return Result.Failure(JobUpdateErrors.JobNotFound); + + if (job.OrganizationClient.OrganizationId != organizationId) + return Result.Failure(JobUpdateErrors.UnauthorizedJobAccess); + + var update = new JobUpdate + { + JobId = job.Id, + OrganizationId = job.OrganizationClient.OrganizationId, + OrganizationClientId = job.OrganizationClientId, + Type = request.Type, + Message = request.Message?.Trim(), + Status = request.Status, + OccurredAt = DateTimeOffset.UtcNow + }; + + if (request.Type == JobUpdateType.Photo && request.Attachments.Count > 0) + { + foreach (var attachment in request.Attachments) + { + update.Attachments.Add(new JobUpdateAttachment + { + FileName = attachment.FileName, + ContentType = attachment.ContentType, + FileSizeBytes = attachment.SizeBytes, + FileData = attachment.Content + }); + } + } + + if (request.Type == JobUpdateType.StatusChange && request.Status.HasValue) + { + job.LifecycleStatus = request.Status.Value; + _jobs.Update(job); + } + + await _updates.AddAsync(update); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(update)); + } + + public async Task>> GetByJobAsync(Guid jobId, Guid organizationId) + { + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job is null) + return Result.Failure>(JobUpdateErrors.JobNotFound); + + if (job.OrganizationClient.OrganizationId != organizationId) + return Result.Failure>(JobUpdateErrors.UnauthorizedJobAccess); + + var updates = await _updates.Query() + .Where(u => u.JobId == jobId) + .Include(u => u.Attachments) + .OrderByDescending(u => u.OccurredAt) + .ToListAsync(); + + return Result.Success>(updates.Select(ToDto).ToList()); + } + + public async Task>> GetByJobForClientAsync( + Guid jobId, + Guid organizationId, + Guid organizationClientId) + { + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job is null) + return Result.Failure>(JobUpdateErrors.JobNotFound); + + if (job.OrganizationClient.OrganizationId != organizationId || job.OrganizationClientId != organizationClientId) + return Result.Failure>(JobUpdateErrors.UnauthorizedJobAccess); + + var updates = await _updates.Query() + .Where(u => u.JobId == jobId) + .Include(u => u.Attachments) + .OrderByDescending(u => u.OccurredAt) + .ToListAsync(); + + return Result.Success>(updates.Select(ToDto).ToList()); + } + + public async Task> GetAttachmentAsync( + Guid jobId, + Guid updateId, + Guid attachmentId, + Guid organizationId, + Guid? organizationClientId = null) + { + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job is null) + return Result.Failure(JobUpdateErrors.JobNotFound); + + var authorized = job.OrganizationClient.OrganizationId == organizationId + && (!organizationClientId.HasValue || job.OrganizationClientId == organizationClientId.Value); + + if (!authorized) + return Result.Failure(JobUpdateErrors.UnauthorizedJobAccess); + + var update = await _updates.Query() + .FirstOrDefaultAsync(u => u.Id == updateId && u.JobId == jobId); + + if (update is null) + return Result.Failure(JobUpdateErrors.UpdateNotFound); + + var attachment = await _attachments.Query() + .FirstOrDefaultAsync(a => a.Id == attachmentId && a.JobUpdateId == updateId); + + if (attachment is null) + return Result.Failure(JobUpdateErrors.AttachmentNotFound); + + return Result.Success(new JobUpdateAttachmentDownloadDto( + attachment.FileName, + attachment.ContentType, + attachment.FileData)); + } + + private static JobUpdateDto ToDto(JobUpdate update) + { + return new JobUpdateDto( + update.Id, + update.JobId, + update.Type, + update.Message, + update.Status, + update.OccurredAt, + update.Attachments.Select(a => new JobUpdateAttachmentDto( + a.Id, + a.FileName, + a.ContentType, + a.FileSizeBytes)).ToList()); + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs b/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs index 84af9f2..2ef9a42 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IJobService.cs @@ -7,7 +7,9 @@ namespace JobFlow.Business.Services.ServiceInterfaces; public interface IJobService { Task> GetJobByIdAsync(Guid id, Guid organizationId); + Task> GetJobForClientAsync(Guid id, Guid organizationId, Guid organizationClientId); Task>> GetJobsByStatusAsync(Guid organizationId, JobLifecycleStatus status); + Task>> GetJobsForClientAsync(Guid organizationId, Guid organizationClientId); Task> UpsertJobAsync(Job model, Guid organizationId); Task DeleteJobAsync(Guid id); Task>> GetJobsAsync(Guid organizationId); diff --git a/JobFlow.Business/Services/ServiceInterfaces/IJobUpdateService.cs b/JobFlow.Business/Services/ServiceInterfaces/IJobUpdateService.cs new file mode 100644 index 0000000..4fb3284 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IJobUpdateService.cs @@ -0,0 +1,17 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Models; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IJobUpdateService +{ + Task> CreateAsync(Guid jobId, Guid organizationId, CreateJobUpdateRequest request); + Task>> GetByJobAsync(Guid jobId, Guid organizationId); + Task>> GetByJobForClientAsync(Guid jobId, Guid organizationId, Guid organizationClientId); + Task> GetAttachmentAsync( + Guid jobId, + Guid updateId, + Guid attachmentId, + Guid organizationId, + Guid? organizationClientId = null); +} diff --git a/JobFlow.Business/Validators/CreateJobUpdateRequestValidator.cs b/JobFlow.Business/Validators/CreateJobUpdateRequestValidator.cs new file mode 100644 index 0000000..c817190 --- /dev/null +++ b/JobFlow.Business/Validators/CreateJobUpdateRequestValidator.cs @@ -0,0 +1,75 @@ +using FluentValidation; +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Validators; + +public class CreateJobUpdateRequestValidator : AbstractValidator +{ + private static readonly HashSet AllowedContentTypes = + [ + "image/jpeg", + "image/png", + "image/webp" + ]; + + public CreateJobUpdateRequestValidator() + { + RuleFor(x => x.Attachments) + .NotNull(); + + RuleFor(x => x.Type) + .IsInEnum(); + + When(x => x.Type == JobUpdateType.Note, () => + { + RuleFor(x => x.Message) + .NotEmpty() + .MaximumLength(2000); + }); + + When(x => x.Type == JobUpdateType.StatusChange, () => + { + RuleFor(x => x.Status) + .NotNull(); + }); + + When(x => x.Type == JobUpdateType.Photo, () => + { + RuleFor(x => x.Attachments.Count) + .GreaterThan(0) + .LessThanOrEqualTo(6); + + RuleForEach(x => x.Attachments).ChildRules(attachment => + { + attachment.RuleFor(x => x.FileName) + .NotEmpty() + .MaximumLength(260); + + attachment.RuleFor(x => x.ContentType) + .NotEmpty() + .Must(contentType => AllowedContentTypes.Contains(contentType)) + .WithMessage("Unsupported attachment content type."); + + attachment.RuleFor(x => x.Content) + .NotNull() + .Must(content => content.Length > 0) + .WithMessage("Attachment content is required."); + + attachment.RuleFor(x => x.SizeBytes) + .GreaterThan(0) + .LessThanOrEqualTo(15 * 1024 * 1024); + + attachment.RuleFor(x => x) + .Must(x => x.Content.Length == x.SizeBytes) + .WithMessage("Attachment size does not match payload size."); + }); + }); + + When(x => x.Type != JobUpdateType.Photo, () => + { + RuleFor(x => x.Attachments.Count) + .LessThanOrEqualTo(0); + }); + } +} diff --git a/JobFlow.Domain/Enums/JobUpdateType.cs b/JobFlow.Domain/Enums/JobUpdateType.cs new file mode 100644 index 0000000..55faf10 --- /dev/null +++ b/JobFlow.Domain/Enums/JobUpdateType.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Domain.Enums; + +public enum JobUpdateType +{ + Note = 0, + StatusChange = 1, + Photo = 2, + System = 3 +} diff --git a/JobFlow.Domain/Models/Job.cs b/JobFlow.Domain/Models/Job.cs index 5324c59..4b2bc55 100644 --- a/JobFlow.Domain/Models/Job.cs +++ b/JobFlow.Domain/Models/Job.cs @@ -17,4 +17,5 @@ public class Job : Entity public virtual ICollection Assignments { get; set; } = new List(); public virtual ICollection JobTrackings { get; set; } = new List(); + public virtual ICollection JobUpdates { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/JobUpdate.cs b/JobFlow.Domain/Models/JobUpdate.cs new file mode 100644 index 0000000..ec31c01 --- /dev/null +++ b/JobFlow.Domain/Models/JobUpdate.cs @@ -0,0 +1,17 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class JobUpdate : Entity +{ + public Guid JobId { get; set; } + public Guid OrganizationId { get; set; } + public Guid OrganizationClientId { get; set; } + public JobUpdateType Type { get; set; } + public string? Message { get; set; } + public JobLifecycleStatus? Status { get; set; } + public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow; + + public Job Job { get; set; } = null!; + public ICollection Attachments { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/JobUpdateAttachment.cs b/JobFlow.Domain/Models/JobUpdateAttachment.cs new file mode 100644 index 0000000..99d3881 --- /dev/null +++ b/JobFlow.Domain/Models/JobUpdateAttachment.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Domain.Models; + +public class JobUpdateAttachment : Entity +{ + public Guid JobUpdateId { get; set; } + public string FileName { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public byte[] FileData { get; set; } = Array.Empty(); + + public JobUpdate JobUpdate { get; set; } = null!; +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateAttachmentConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateAttachmentConfiguration.cs new file mode 100644 index 0000000..8c388d6 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateAttachmentConfiguration.cs @@ -0,0 +1,19 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class JobUpdateAttachmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("JobUpdateAttachments"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.FileName).HasMaxLength(260).IsRequired(); + builder.Property(x => x.ContentType).HasMaxLength(200).IsRequired(); + builder.Property(x => x.FileSizeBytes).IsRequired(); + builder.Property(x => x.FileData).HasColumnType("varbinary(max)").IsRequired(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateConfiguration.cs new file mode 100644 index 0000000..28c0e62 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/JobUpdateConfiguration.cs @@ -0,0 +1,30 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class JobUpdateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("JobUpdates"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Message).HasMaxLength(4000); + builder.Property(x => x.Type).IsRequired(); + builder.Property(x => x.OccurredAt).IsRequired(); + + builder.HasIndex(x => new { x.JobId, x.OccurredAt }); + + builder.HasOne(x => x.Job) + .WithMany(j => j.JobUpdates) + .HasForeignKey(x => x.JobId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.Attachments) + .WithOne(x => x.JobUpdate) + .HasForeignKey(x => x.JobUpdateId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 4e85da1..e2b7709 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -15,6 +15,8 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet EstimateLineItems { get; set; } public DbSet EstimateRevisionRequests { get; set; } public DbSet EstimateRevisionAttachments { get; set; } + public DbSet JobUpdates { get; set; } + public DbSet JobUpdateAttachments { get; set; } public DbSet InvoiceSequences { get; set; } public DbSet Organizations { get; set; } public DbSet OrganizationTypes { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.Designer.cs new file mode 100644 index 0000000..ba6babb --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.Designer.cs @@ -0,0 +1,2742 @@ +// +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("20260321144452_AddJobUpdates")] + partial class AddJobUpdates + { + /// + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260321144452_AddJobUpdates.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.cs new file mode 100644 index 0000000..7bc6f7a --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260321144452_AddJobUpdates.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddJobUpdates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobUpdates", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + JobId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationClientId = table.Column(type: "uniqueidentifier", nullable: false), + Type = table.Column(type: "int", nullable: false), + Message = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + Status = table.Column(type: "int", nullable: true), + OccurredAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_JobUpdates", x => x.Id); + table.ForeignKey( + name: "FK_JobUpdates_Job_JobId", + column: x => x.JobId, + principalTable: "Job", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "JobUpdateAttachments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + JobUpdateId = table.Column(type: "uniqueidentifier", nullable: false), + FileName = table.Column(type: "nvarchar(260)", maxLength: 260, nullable: false), + ContentType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + FileData = table.Column(type: "varbinary(max)", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_JobUpdateAttachments", x => x.Id); + table.ForeignKey( + name: "FK_JobUpdateAttachments_JobUpdates_JobUpdateId", + column: x => x.JobUpdateId, + principalTable: "JobUpdates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobUpdateAttachments_JobUpdateId", + table: "JobUpdateAttachments", + column: "JobUpdateId"); + + migrationBuilder.CreateIndex( + name: "IX_JobUpdates_JobId_OccurredAt", + table: "JobUpdates", + columns: new[] { "JobId", "OccurredAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobUpdateAttachments"); + + migrationBuilder.DropTable( + name: "JobUpdates"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 7547b40..9f38e57 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1140,6 +1140,110 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("JobTracking"); }); + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => { b.Property("Id") @@ -2357,6 +2461,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Job"); }); + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Message", b => { b.HasOne("JobFlow.Domain.Models.Conversation", "Conversation") @@ -2557,6 +2683,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Assignments"); b.Navigation("JobTrackings"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); }); modelBuilder.Entity("JobFlow.Domain.Models.Order", b => From f4b999578653a9ea06394c74f8ab33ff5b3e0fd7 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 21 Mar 2026 13:13:13 -0400 Subject: [PATCH 13/26] chore: update api --- JobFlow.API/Controllers/PaymentController.cs | 30 +- .../IPaymentSettings.cs | 2 +- JobFlow.Business/Models/DTOs/JobDto.cs | 2 +- .../Models/DTOs/OrganizationDto.cs | 2 +- .../Onboarding/OnboardingStepKeys.cs | 2 +- .../SharedModels/PaymentSessionRequest.cs | 2 +- .../Services/AssignmentService.cs | 2 +- JobFlow.Business/Services/InvoiceService.cs | 16 +- JobFlow.Business/Services/JobService.cs | 28 +- .../Services/OrganizationClientService.cs | 2 +- .../Services/OrganizationService.cs | 2 +- .../Services/PaymentProfileService.cs | 2 +- JobFlow.Business/Services/UserService.cs | 2 +- JobFlow.Domain/Models/Assignment.cs | 2 +- JobFlow.Domain/Models/Invoice.cs | 2 +- JobFlow.Domain/Models/Job.cs | 2 +- .../Configurations/JobConfiguration.cs | 4 +- .../Square/SquarePaymentProcessor.cs | 21 +- .../Stripe/StripePaymentProcessor.cs | 2 +- .../Stripe/StripeWebhookService.cs | 334 +++++++++--------- 20 files changed, 252 insertions(+), 209 deletions(-) diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index c1c5e6d..5511e1c 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -73,6 +73,11 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque var org = await _organizationService.GetOrganiztionById(orgId); if (org.IsFailure) return NotFound("Organization not found."); + var organization = org.Value; + var provider = Enum.IsDefined(typeof(PaymentProvider), organization.PaymentProvider) + ? organization.PaymentProvider + : PaymentProvider.Stripe; + if (request.InvoiceId.HasValue) { var invoiceResult = await _invoiceService.GetInvoiceByIdAsync(request.InvoiceId.Value); @@ -101,16 +106,25 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque request.OrganizationId = invoice.OrganizationId; request.OrganizationClientId = invoice.OrganizationClientId; request.ProductName ??= $"Invoice {invoice.InvoiceNumber}"; + + if (Enum.IsDefined(typeof(PaymentProvider), invoice.PaymentProvider) + && invoice.PaymentProvider != 0) + { + provider = invoice.PaymentProvider; + } } - var processor = _processorFactory.GetProcessor(org.Value.PaymentProvider.ToString()); + var processor = _processorFactory.GetProcessor(provider); string checkoutUrl; if (request.Mode == "subscription") checkoutUrl = await processor.CreateSubscriptionCheckoutSessionAsync(request); else { - request.ConnectedAccountId = org.Value.StripeConnectAccountId; + if (provider == PaymentProvider.Stripe) + { + request.ConnectedAccountId = organization.StripeConnectAccountId; + } var paymentIntent = await processor.CreatePaymentIntentAsync(request); return Ok(new @@ -227,7 +241,9 @@ public async Task RefundPayment([FromBody] PaymentRefundRequestDt Amount = request.Amount, Currency = request.Currency, Reason = request.Reason, - ConnectedAccountId = orgResult.Value.StripeConnectAccountId + ConnectedAccountId = request.Provider == PaymentProvider.Stripe + ? orgResult.Value.StripeConnectAccountId + : null }); return result.Success ? Ok(result) : BadRequest(result); @@ -253,7 +269,9 @@ public async Task AdjustPayment([FromBody] PaymentAdjustmentReque Reason = request.Reason, ProductName = request.ProductName, InvoiceId = request.InvoiceId, - ConnectedAccountId = orgResult.Value.StripeConnectAccountId + ConnectedAccountId = request.Provider == PaymentProvider.Stripe + ? orgResult.Value.StripeConnectAccountId + : null }); return result.Success ? Ok(result) : BadRequest(result); @@ -279,7 +297,9 @@ public async Task CreateDeposit([FromBody] DepositPaymentRequestD ProductName = request.ProductName, Amount = request.Amount, DepositAmount = request.Amount, - ConnectedAccountId = orgResult.Value.StripeConnectAccountId + ConnectedAccountId = request.Provider == PaymentProvider.Stripe + ? orgResult.Value.StripeConnectAccountId + : null }); return Ok(new diff --git a/JobFlow.Business/ConfigurationSettings/ConfigurationInterfaces/IPaymentSettings.cs b/JobFlow.Business/ConfigurationSettings/ConfigurationInterfaces/IPaymentSettings.cs index 21e8d62..80a98f4 100644 --- a/JobFlow.Business/ConfigurationSettings/ConfigurationInterfaces/IPaymentSettings.cs +++ b/JobFlow.Business/ConfigurationSettings/ConfigurationInterfaces/IPaymentSettings.cs @@ -2,5 +2,5 @@ public interface IPaymentSettings { - decimal ApplicationFee { get; } + decimal ApplicationFee { get; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/JobDto.cs b/JobFlow.Business/Models/DTOs/JobDto.cs index ec3f021..b55fe1a 100644 --- a/JobFlow.Business/Models/DTOs/JobDto.cs +++ b/JobFlow.Business/Models/DTOs/JobDto.cs @@ -11,7 +11,7 @@ public class JobDto public InvoicingWorkflow? InvoicingWorkflow { get; set; } public Guid OrganizationClientId { get; set; } public OrganizationClientDto? OrganizationClient { get; set; } - + public IEnumerable? Assignments { get; set; } public bool HasAssignments => Assignments?.Any() == true; } diff --git a/JobFlow.Business/Models/DTOs/OrganizationDto.cs b/JobFlow.Business/Models/DTOs/OrganizationDto.cs index 4b85e30..a301a13 100644 --- a/JobFlow.Business/Models/DTOs/OrganizationDto.cs +++ b/JobFlow.Business/Models/DTOs/OrganizationDto.cs @@ -19,7 +19,7 @@ public class OrganizationClientDto public string? EmailAddress { get; set; } public OrganizationDto? Organization { get; set; } public string? FullName => $"{FirstName} {LastName}".Trim(); - + } public class OrganizationDto diff --git a/JobFlow.Business/Onboarding/OnboardingStepKeys.cs b/JobFlow.Business/Onboarding/OnboardingStepKeys.cs index 5d5a29b..7af44a6 100644 --- a/JobFlow.Business/Onboarding/OnboardingStepKeys.cs +++ b/JobFlow.Business/Onboarding/OnboardingStepKeys.cs @@ -10,5 +10,5 @@ public static class OnboardingStepKeys public const string CreateInvoice = "create_invoice"; public const string SendInvoice = "send_invoice"; public const string ReceivePayment = "receive_payment"; - public const string ConnectStripe = "connect_stripe"; + public const string ConnectStripe = "connect_stripe"; } \ No newline at end of file diff --git a/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs b/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs index b7b6253..cba467f 100644 --- a/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs +++ b/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs @@ -9,7 +9,7 @@ public class PaymentSessionRequest public string? CancelUrl { get; set; } public string? Email { get; set; } public Guid? OrgId { get; set; } - + public Guid? InvoiceId { get; set; } // Subscription-specific diff --git a/JobFlow.Business/Services/AssignmentService.cs b/JobFlow.Business/Services/AssignmentService.cs index b09ef2d..2a15e0b 100644 --- a/JobFlow.Business/Services/AssignmentService.cs +++ b/JobFlow.Business/Services/AssignmentService.cs @@ -41,7 +41,7 @@ public AssignmentService( _assignmentAssignees = unitOfWork.RepositoryOf(); _employees = unitOfWork.RepositoryOf(); _jobs = unitOfWork.RepositoryOf(); - + _mapper = mapper; _onboardingService = onboardingService; _workflowSettings = workflowSettings; diff --git a/JobFlow.Business/Services/InvoiceService.cs b/JobFlow.Business/Services/InvoiceService.cs index fd758a5..e9490a1 100644 --- a/JobFlow.Business/Services/InvoiceService.cs +++ b/JobFlow.Business/Services/InvoiceService.cs @@ -22,11 +22,13 @@ public class InvoiceService : IInvoiceService private readonly INotificationService _notifications; private readonly IOrganizationClientPortalService _clientPortal; private readonly IInvoiceRealtimeNotifier? _realtimeNotifier; + private readonly IOrganizationService _organizationService; private readonly IUnitOfWork unitOfWork; public InvoiceService( ILogger logger, IUnitOfWork unitOfWork, + IOrganizationService organizationService, IOnboardingService onboardingService, IInvoiceNumberGenerator numberGenerator, INotificationService notifications, @@ -35,6 +37,7 @@ public InvoiceService( { this.logger = logger; this.unitOfWork = unitOfWork; + _organizationService = organizationService; invoices = unitOfWork.RepositoryOf(); estimates = unitOfWork.RepositoryOf(); clients = unitOfWork.RepositoryOf(); @@ -94,6 +97,15 @@ public async Task> UpsertInvoiceAsync(Invoice model) // Calculate TotalAmount manually since it's not mapped model.TotalAmount = model.LineItems?.Sum(li => li.Quantity * li.UnitPrice) ?? 0; + if (!Enum.IsDefined(typeof(PaymentProvider), model.PaymentProvider) + || model.PaymentProvider == 0) + { + var orgResult = await _organizationService.GetOrganiztionById(model.OrganizationId); + model.PaymentProvider = orgResult.IsSuccess + ? (orgResult.Value.PaymentProvider == 0 ? PaymentProvider.Stripe : orgResult.Value.PaymentProvider) + : PaymentProvider.Stripe; + } + if (exists) invoices.Update(model); else @@ -109,7 +121,7 @@ public async Task> UpsertInvoiceAsync(Invoice model) } await unitOfWork.SaveChangesAsync(); - + await _onboardingService.MarkStepCompleteAsync( model.OrganizationId, OnboardingStepKeys.CreateInvoice @@ -129,7 +141,7 @@ public async Task DeleteInvoiceAsync(Guid id) await unitOfWork.SaveChangesAsync(); return Result.Success(); } - + public async Task MarkInvoiceSentAsync(Guid invoiceId) { var invoice = await invoices diff --git a/JobFlow.Business/Services/JobService.cs b/JobFlow.Business/Services/JobService.cs index fa65389..61ccf56 100644 --- a/JobFlow.Business/Services/JobService.cs +++ b/JobFlow.Business/Services/JobService.cs @@ -111,12 +111,12 @@ public async Task>> GetJobsAsync(Guid organizationId) .Where(j => j.OrganizationClient.OrganizationId == organizationId) .OrderByDescending(j => j.CreatedAt) .ToListAsync(); - + var dto = returnedJobs.Select(e => new JobDto { Id = e.Id, OrganizationClientId = e.OrganizationClient.Id, - Title = e.Title, + Title = e.Title, Comments = e.Comments, LifecycleStatus = e.LifecycleStatus, InvoicingWorkflow = e.InvoicingWorkflow, @@ -126,11 +126,11 @@ public async Task>> GetJobsAsync(Guid organizationId) ScheduledEnd = a.ScheduledEnd, ActualEnd = a.ActualEnd, ActualStart = a.ActualStart, - Id = a.Id, - JobId = e.Id, - JobTitle = e.Title, + Id = a.Id, + JobId = e.Id, + JobTitle = e.Title, Status = a.Status, - OrganizationClientId = e.OrganizationClientId, + OrganizationClientId = e.OrganizationClientId, JobLifecycleStatus = e.LifecycleStatus, Assignees = a.AssignmentAssignees .Select(assignee => new AssignmentAssigneeDto @@ -145,16 +145,16 @@ public async Task>> GetJobsAsync(Guid organizationId) }), OrganizationClient = new OrganizationClientDto { - OrganizationId = e.OrganizationClient.OrganizationId, - FirstName = e.OrganizationClient.FirstName, - LastName = e.OrganizationClient.LastName, - EmailAddress = e.OrganizationClient.EmailAddress, + OrganizationId = e.OrganizationClient.OrganizationId, + FirstName = e.OrganizationClient.FirstName, + LastName = e.OrganizationClient.LastName, + EmailAddress = e.OrganizationClient.EmailAddress, PhoneNumber = e.OrganizationClient.PhoneNumber, - Address1 = e.OrganizationClient.Address1, + Address1 = e.OrganizationClient.Address1, Address2 = e.OrganizationClient.Address2, - City = e.OrganizationClient.City, - State = e.OrganizationClient.State, - ZipCode = e.OrganizationClient.ZipCode + City = e.OrganizationClient.City, + State = e.OrganizationClient.State, + ZipCode = e.OrganizationClient.ZipCode } }).ToList(); return Result.Success>(dto); diff --git a/JobFlow.Business/Services/OrganizationClientService.cs b/JobFlow.Business/Services/OrganizationClientService.cs index 808b130..56d0605 100644 --- a/JobFlow.Business/Services/OrganizationClientService.cs +++ b/JobFlow.Business/Services/OrganizationClientService.cs @@ -141,7 +141,7 @@ public async Task> UpsertClient(OrganizationClient mo } await unitOfWork.SaveChangesAsync(); - + if (!exists) { await onboardingService.MarkStepCompleteAsync( diff --git a/JobFlow.Business/Services/OrganizationService.cs b/JobFlow.Business/Services/OrganizationService.cs index 345e22d..56b59e7 100644 --- a/JobFlow.Business/Services/OrganizationService.cs +++ b/JobFlow.Business/Services/OrganizationService.cs @@ -21,7 +21,7 @@ public class OrganizationService : IOrganizationService private readonly IRepository _subscriptions; public OrganizationService( - IUnitOfWork unitOfWork, + IUnitOfWork unitOfWork, ILogger logger, IOnboardingService onboardingService) { diff --git a/JobFlow.Business/Services/PaymentProfileService.cs b/JobFlow.Business/Services/PaymentProfileService.cs index d1d46cb..281bd05 100644 --- a/JobFlow.Business/Services/PaymentProfileService.cs +++ b/JobFlow.Business/Services/PaymentProfileService.cs @@ -65,7 +65,7 @@ public async Task> CreateAsync(Guid ownerId, Paym OwnerType = ownerType, Provider = provider, ProviderCustomerId = providerCustomerId, - CreatedAt = DateTime.UtcNow + CreatedAt = DateTime.UtcNow }; paymentProfiles.Add(profile); diff --git a/JobFlow.Business/Services/UserService.cs b/JobFlow.Business/Services/UserService.cs index 3b30aa9..ae9a954 100644 --- a/JobFlow.Business/Services/UserService.cs +++ b/JobFlow.Business/Services/UserService.cs @@ -122,7 +122,7 @@ public async Task> GetUserByFirebaseUid(string uid) return Result.Success(user); } - + private static string ResolvePrimaryRole(User user) { return user.UserRoles diff --git a/JobFlow.Domain/Models/Assignment.cs b/JobFlow.Domain/Models/Assignment.cs index bc87713..1a9956c 100644 --- a/JobFlow.Domain/Models/Assignment.cs +++ b/JobFlow.Domain/Models/Assignment.cs @@ -15,7 +15,7 @@ public class Assignment : Entity public DateTimeOffset? ActualStart { get; set; } public DateTimeOffset? ActualEnd { get; set; } - + public AssignmentStatus Status { get; set; } = AssignmentStatus.Scheduled; // Optional: assignment-level override location (job location can differ from client address) diff --git a/JobFlow.Domain/Models/Invoice.cs b/JobFlow.Domain/Models/Invoice.cs index 0675f36..3b867f7 100644 --- a/JobFlow.Domain/Models/Invoice.cs +++ b/JobFlow.Domain/Models/Invoice.cs @@ -15,7 +15,7 @@ public class Invoice : Entity public decimal AmountPaid { get; set; } public decimal BalanceDue => TotalAmount - AmountPaid; public InvoiceStatus Status { get; set; } - + public PaymentProvider PaymentProvider { get; set; } public string? ExternalPaymentId { get; set; } public DateTimeOffset? PaidAt { get; set; } diff --git a/JobFlow.Domain/Models/Job.cs b/JobFlow.Domain/Models/Job.cs index 4b2bc55..55ab4c6 100644 --- a/JobFlow.Domain/Models/Job.cs +++ b/JobFlow.Domain/Models/Job.cs @@ -8,7 +8,7 @@ public class Job : Entity public InvoicingWorkflow? InvoicingWorkflow { get; set; } public string? Title { get; set; } public string? Comments { get; set; } - + public Guid OrganizationClientId { get; set; } public virtual OrganizationClient OrganizationClient { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs index 1d8f5ee..39208d3 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/JobConfiguration.cs @@ -17,14 +17,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Comments) .HasMaxLength(2000); - + builder.Property(j => j.LifecycleStatus) .HasConversion() .IsRequired(); builder.Property(j => j.InvoicingWorkflow) .HasConversion(); - + // ✅ Relationship with OrganizationClient builder.HasOne(j => j.OrganizationClient) .WithMany(c => c.Jobs) // assuming you added ICollection Jobs to OrganizationClient diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index 0d16ae0..c3ba9b3 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -4,8 +4,8 @@ using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; using System.Net.Http.Json; using Square; - using Square.Checkout.PaymentLinks; +using Microsoft.Extensions.Hosting; namespace JobFlow.Infrastructure.PaymentGateways.SquarePayment; @@ -16,8 +16,9 @@ public class SquarePaymentProcessor : IPaymentProcessor, IPaymentOperationsProce private readonly SquareClient _client; private readonly string _accessToken; private readonly string _locationId; + private readonly string _baseUrl; - public SquarePaymentProcessor(ISquareSettings settings) + public SquarePaymentProcessor(ISquareSettings settings, IHostEnvironment hostEnvironment) { ArgumentNullException.ThrowIfNull(settings); if (string.IsNullOrWhiteSpace(settings.AccessToken)) @@ -25,9 +26,13 @@ public SquarePaymentProcessor(ISquareSettings settings) _accessToken = settings.AccessToken; + _baseUrl = hostEnvironment.IsDevelopment() + ? "https://connect.squareupsandbox.com" + : "https://connect.squareup.com"; + _client = new SquareClient(settings.AccessToken, new ClientOptions { - BaseUrl = "https://connect.squareupsandbox.com" + BaseUrl = _baseUrl }); _locationId = settings.LocationId ?? string.Empty; @@ -61,9 +66,15 @@ public async Task CreateCheckoutSessionAsync(PaymentSessionRequest reque var paymentLinkRequest = new CreatePaymentLinkRequest { QuickPay = quickPay, - IdempotencyKey = idempotencyKey + IdempotencyKey = idempotencyKey, + Description = request.ProductName }; + if (request.InvoiceId.HasValue) + { + paymentLinkRequest.PaymentNote = $"invoiceId={request.InvoiceId.Value}"; + } + try { var result = await _client.Checkout.PaymentLinks.CreateAsync(paymentLinkRequest); @@ -158,7 +169,7 @@ private HttpClient CreateApiClient() { var httpClient = new HttpClient { - BaseAddress = new Uri("https://connect.squareupsandbox.com/") + BaseAddress = new Uri($"{_baseUrl}/") }; httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 972fa2f..27fb30c 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -85,7 +85,7 @@ public async Task CreatePaymentIntentAsync( }, ApplicationFeeAmount = applicationFee, - + TransferData = new PaymentIntentTransferDataOptions { Destination = request.ConnectedAccountId diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index 6cbfd4c..2f21d59 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -42,175 +42,175 @@ public StripeWebhookService( } public async Task HandleEventAsync(Event stripeEvent) -{ - _logger.LogInformation( - "Stripe webhook received: EventId={EventId}, Type={Type}, ObjectType={ObjectType}", - stripeEvent.Id, - stripeEvent.Type, - stripeEvent.Data.Object?.GetType().Name - ); - - switch (stripeEvent.Type) { - case StripeEvents.CheckoutSessionCompleted: - { - var session = Deserialize(stripeEvent); - if (session != null) - await HandleCheckoutSessionAsync(session); - break; - } - - case StripeEvents.AccountUpdated: - { - var account = Deserialize(stripeEvent); - if (account == null) return; - - if (account.ChargesEnabled && - account.PayoutsEnabled && - account.DetailsSubmitted) - { - await _organizationService.MarkStripeConnectedAsync(account.Id); - } - else - { - //await _organizationService.MarkStripeDisconnectedAsync(account.Id); - } - - break; - } - - - case StripeEvents.PaymentIntentSucceeded: - { - var intent = Deserialize(stripeEvent); - - if (intent == null) - throw new InvalidOperationException( - $"Stripe webhook deserialization failed. EventId={stripeEvent.Id}, Type={stripeEvent.Type}" - ); - - await HandlePaymentIntentAsync(intent); - break; - } - - - case StripeEvents.PaymentIntentFailed: - { - var intent = Deserialize(stripeEvent); - if (intent != null) - await HandlePaymentIntentFailedAsync(intent); - break; - } - - case StripeEvents.ChargeRefunded: - { - var charge = Deserialize(stripeEvent); - if (charge != null) - await HandleChargeRefundedAsync(charge, stripeEvent.Type); - break; - } - - case StripeEvents.InvoiceCreated: - { - var invoice = Deserialize(stripeEvent); - if (invoice != null) - await HandleInvoiceCreatedAsync(invoice); - break; - } - - case StripeEvents.InvoiceFinalized: - { - var invoice = Deserialize(stripeEvent); - if (invoice != null) - await HandleInvoiceFinalizedAsync(invoice); - break; - } - - case StripeEvents.InvoiceMarkedUncollectible: - { - var invoice = Deserialize(stripeEvent); - if (invoice != null) - await HandleInvoiceMarkedUncollectibleAsync(invoice); - break; - } - - case StripeEvents.CustomerSubscriptionUpdated: - { - var subscription = Deserialize(stripeEvent); - if (subscription != null) - await HandleSubscriptionUpdatedAsync(subscription); - break; - } - - case StripeEvents.CustomerSubscriptionDeleted: - { - var subscription = Deserialize(stripeEvent); - if (subscription != null) - await _subscriptionRecordService.CancelAsync( - subscription.Id, - DateTime.UtcNow - ); - break; - } - - case StripeEvents.SubscriptionCreated: - { - var subscription = Deserialize(stripeEvent); - if (subscription != null) - await HandleSubscriptionCreatedAsync(subscription); - break; - } - - case StripeEvents.SubscriptionTrialWillEnd: - { - var subscription = Deserialize(stripeEvent); - if (subscription != null) - await HandleSubscriptionTrialWillEndAsync(subscription); - break; - } - - case StripeEvents.CustomerCreated: - { - var customer = Deserialize(stripeEvent); - if (customer != null) - await HandleCustomerCreatedAsync(customer); - break; - } - - case StripeEvents.CustomerUpdated: - { - var customer = Deserialize(stripeEvent); - if (customer != null) - await HandleCustomerUpdatedAsync(customer); - break; - } - - case StripeEvents.CustomerDeleted: - { - var customer = Deserialize(stripeEvent); - if (customer != null) - await HandleCustomerDeletedAsync(customer); - break; - } - - case StripeEvents.PaymentMethodAttached: - { - var method = Deserialize(stripeEvent); - if (method != null) - await HandlePaymentMethodAttachedAsync(method); - break; - } + _logger.LogInformation( + "Stripe webhook received: EventId={EventId}, Type={Type}, ObjectType={ObjectType}", + stripeEvent.Id, + stripeEvent.Type, + stripeEvent.Data.Object?.GetType().Name + ); - case StripeEvents.PaymentMethodDetached: + switch (stripeEvent.Type) { - var method = Deserialize(stripeEvent); - if (method != null) - await HandlePaymentMethodDetachedAsync(method); - break; + case StripeEvents.CheckoutSessionCompleted: + { + var session = Deserialize(stripeEvent); + if (session != null) + await HandleCheckoutSessionAsync(session); + break; + } + + case StripeEvents.AccountUpdated: + { + var account = Deserialize(stripeEvent); + if (account == null) return; + + if (account.ChargesEnabled && + account.PayoutsEnabled && + account.DetailsSubmitted) + { + await _organizationService.MarkStripeConnectedAsync(account.Id); + } + else + { + //await _organizationService.MarkStripeDisconnectedAsync(account.Id); + } + + break; + } + + + case StripeEvents.PaymentIntentSucceeded: + { + var intent = Deserialize(stripeEvent); + + if (intent == null) + throw new InvalidOperationException( + $"Stripe webhook deserialization failed. EventId={stripeEvent.Id}, Type={stripeEvent.Type}" + ); + + await HandlePaymentIntentAsync(intent); + break; + } + + + case StripeEvents.PaymentIntentFailed: + { + var intent = Deserialize(stripeEvent); + if (intent != null) + await HandlePaymentIntentFailedAsync(intent); + break; + } + + case StripeEvents.ChargeRefunded: + { + var charge = Deserialize(stripeEvent); + if (charge != null) + await HandleChargeRefundedAsync(charge, stripeEvent.Type); + break; + } + + case StripeEvents.InvoiceCreated: + { + var invoice = Deserialize(stripeEvent); + if (invoice != null) + await HandleInvoiceCreatedAsync(invoice); + break; + } + + case StripeEvents.InvoiceFinalized: + { + var invoice = Deserialize(stripeEvent); + if (invoice != null) + await HandleInvoiceFinalizedAsync(invoice); + break; + } + + case StripeEvents.InvoiceMarkedUncollectible: + { + var invoice = Deserialize(stripeEvent); + if (invoice != null) + await HandleInvoiceMarkedUncollectibleAsync(invoice); + break; + } + + case StripeEvents.CustomerSubscriptionUpdated: + { + var subscription = Deserialize(stripeEvent); + if (subscription != null) + await HandleSubscriptionUpdatedAsync(subscription); + break; + } + + case StripeEvents.CustomerSubscriptionDeleted: + { + var subscription = Deserialize(stripeEvent); + if (subscription != null) + await _subscriptionRecordService.CancelAsync( + subscription.Id, + DateTime.UtcNow + ); + break; + } + + case StripeEvents.SubscriptionCreated: + { + var subscription = Deserialize(stripeEvent); + if (subscription != null) + await HandleSubscriptionCreatedAsync(subscription); + break; + } + + case StripeEvents.SubscriptionTrialWillEnd: + { + var subscription = Deserialize(stripeEvent); + if (subscription != null) + await HandleSubscriptionTrialWillEndAsync(subscription); + break; + } + + case StripeEvents.CustomerCreated: + { + var customer = Deserialize(stripeEvent); + if (customer != null) + await HandleCustomerCreatedAsync(customer); + break; + } + + case StripeEvents.CustomerUpdated: + { + var customer = Deserialize(stripeEvent); + if (customer != null) + await HandleCustomerUpdatedAsync(customer); + break; + } + + case StripeEvents.CustomerDeleted: + { + var customer = Deserialize(stripeEvent); + if (customer != null) + await HandleCustomerDeletedAsync(customer); + break; + } + + case StripeEvents.PaymentMethodAttached: + { + var method = Deserialize(stripeEvent); + if (method != null) + await HandlePaymentMethodAttachedAsync(method); + break; + } + + case StripeEvents.PaymentMethodDetached: + { + var method = Deserialize(stripeEvent); + if (method != null) + await HandlePaymentMethodDetachedAsync(method); + break; + } } } -} - + private async Task HandleCheckoutSessionAsync(Session session) { var subscriptionService = new SubscriptionService(); @@ -247,7 +247,7 @@ private async Task HandlePaymentIntentAsync(PaymentIntent intent) if (!Guid.TryParse(invoiceIdRaw, out var invoiceId)) return; - + if (await _invoiceService.IsPaidAsync(invoiceId)) return; @@ -303,7 +303,7 @@ await _paymentHistoryService.LogAsync(new JobFlow.Domain.Models.PaymentHistory PaidAt = DateTime.UtcNow, RawEventJson = "{}" }); - } + } private async Task HandleChargeRefundedAsync(Charge charge, string eventType) { @@ -403,7 +403,7 @@ private async Task HandlePaymentMethodDetachedAsync(PaymentMethod paymentMethod) { // Remove payment method from profile if needed } - + private static T? Deserialize(Event stripeEvent) where T : StripeEntity { return stripeEvent.Data.Object as T; From 6ccf261ec6a3784b15ae5101c0577b99e0cd8a9b Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Mon, 23 Mar 2026 14:39:32 -0400 Subject: [PATCH 14/26] feat(redesign): Redesign UI screens and Endpoints --- JobFlow.API/Controllers/AuthController.cs | 2 +- JobFlow.API/Controllers/EmailController.cs | 5 + .../Controllers/EmployeeRoleController.cs | 39 +- .../EmployeeRolePresetsController.cs | 108 + JobFlow.API/Controllers/InvoiceComtroller.cs | 11 +- .../Controllers/OrganizationController.cs | 54 +- JobFlow.API/Controllers/PaymentController.cs | 2 +- .../Controllers/StripePaymentController.cs | 2 +- .../Mappings/InvoiceMappingExtensions.cs | 3 +- JobFlow.API/Models/InvoiceDto.cs | 2 +- JobFlow.API/Models/MarkStepRequestDto.cs | 2 +- JobFlow.API/Models/OnboardingStepDto.cs | 2 +- JobFlow.API/Program.cs | 4 +- .../ModelErrors/EmployeeRolePresetErrors.cs | 12 + .../Models/DTOs/EmployeeRoleDto.cs | 1 + .../DTOs/EmployeeRolePresetApplyResultDto.cs | 8 + .../Models/DTOs/EmployeeRolePresetDto.cs | 12 + .../Models/DTOs/EmployeeRolePresetItemDto.cs | 9 + .../Models/DTOs/EmployeeRoleUsageDto.cs | 7 + .../Models/DTOs/OrganizationDto.cs | 1 + .../DTOs/OrganizationIndustryUpdateDto.cs | 6 + .../Services/EmployeeRolePresetService.cs | 215 ++ .../Services/EmployeeRoleService.cs | 33 + .../Services/OrganizationService.cs | 15 + .../IEmployeeRolePresetService.cs | 14 + .../ServiceInterfaces/IEmployeeRoleService.cs | 2 + .../ServiceInterfaces/IOrganizationService.cs | 1 + JobFlow.Domain/Models/EmployeeRole.cs | 1 + JobFlow.Domain/Models/EmployeeRolePreset.cs | 12 + .../Models/EmployeeRolePresetItem.cs | 10 + JobFlow.Domain/Models/Organization.cs | 1 + .../EmployeeRoleConfiguration.cs | 3 + .../EmployeeRolePresetConfiguration.cs | 72 + .../EmployeeRolePresetItemConfiguration.cs | 190 + .../OrganizationConfiguration.cs | 2 + .../JobFlowDbContext.cs | 2 + ...dRoleDescriptionAndIndustryKey.Designer.cs | 2750 +++++++++++++++ ...171218_AddRoleDescriptionAndIndustryKey.cs | 40 + ...3173511_AddEmployeeRolePresets.Designer.cs | 3076 +++++++++++++++++ .../20260323173511_AddEmployeeRolePresets.cs | 126 + .../JobFlowDbContextModelSnapshot.cs | 334 ++ .../ConfigurationModels/BrevoSettings.cs | 2 +- .../ConfigurationModels/StripeSettings.cs | 8 +- .../ConfigurationModels/TwilioSettings.cs | 8 +- .../ExternalServices/Brevo/BrevoService.cs | 8 +- .../Firebase/FirebaseTokenValidator.cs | 6 +- .../JobFlow.Infrastructure.csproj | 2 - .../Middleware/FirebaseAuthMiddleware.cs | 12 +- .../Stripe/StripeModels/StripeAccount.cs | 14 +- .../Stripe/StripePaymentProcessor.cs | 27 +- .../Stripe/StripeWebhookService.cs | 32 +- .../EmployeeRolePresetServiceTests.cs | 288 ++ 52 files changed, 7536 insertions(+), 62 deletions(-) create mode 100644 JobFlow.API/Controllers/EmployeeRolePresetsController.cs create mode 100644 JobFlow.Business/ModelErrors/EmployeeRolePresetErrors.cs create mode 100644 JobFlow.Business/Models/DTOs/EmployeeRolePresetApplyResultDto.cs create mode 100644 JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs create mode 100644 JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs create mode 100644 JobFlow.Business/Models/DTOs/EmployeeRoleUsageDto.cs create mode 100644 JobFlow.Business/Models/DTOs/OrganizationIndustryUpdateDto.cs create mode 100644 JobFlow.Business/Services/EmployeeRolePresetService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IEmployeeRolePresetService.cs create mode 100644 JobFlow.Domain/Models/EmployeeRolePreset.cs create mode 100644 JobFlow.Domain/Models/EmployeeRolePresetItem.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetItemConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.cs create mode 100644 JobFlow.Tests/EmployeeRolePresetServiceTests.cs diff --git a/JobFlow.API/Controllers/AuthController.cs b/JobFlow.API/Controllers/AuthController.cs index 9140a19..8f40c4b 100644 --- a/JobFlow.API/Controllers/AuthController.cs +++ b/JobFlow.API/Controllers/AuthController.cs @@ -219,7 +219,7 @@ public async Task DeleteAccount(string uid) // ============================================================ public class TokenDto { - public string Token { get; set; } + public string Token { get; set; } = string.Empty; } public class CreateAccountRequest diff --git a/JobFlow.API/Controllers/EmailController.cs b/JobFlow.API/Controllers/EmailController.cs index 86da4df..b0f6742 100644 --- a/JobFlow.API/Controllers/EmailController.cs +++ b/JobFlow.API/Controllers/EmailController.cs @@ -50,6 +50,11 @@ public async Task SendContactForm( [FromServices] ICaptchaVerificationService captchaService, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(request.CaptchaToken)) + { + return BadRequest(new { message = "Captcha token is required." }); + } + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var verification = await captchaService.VerifyAsync( diff --git a/JobFlow.API/Controllers/EmployeeRoleController.cs b/JobFlow.API/Controllers/EmployeeRoleController.cs index a75b809..0ca0eb1 100644 --- a/JobFlow.API/Controllers/EmployeeRoleController.cs +++ b/JobFlow.API/Controllers/EmployeeRoleController.cs @@ -3,12 +3,15 @@ using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Mapster; namespace JobFlow.API.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class EmployeeRolesController : ControllerBase { private readonly IEmployeeRoleService employeeRoleService; @@ -25,6 +28,16 @@ public async Task GetByOrganization() { var organizationId = HttpContext.GetOrganizationId(); var result = await employeeRoleService.GetRolesByOrganizationAsync(organizationId); + return result.IsSuccess + ? Ok(result.Value.Adapt>()) + : BadRequest(result.Error); + } + + [HttpGet("organization/usage")] + public async Task GetUsageByOrganization() + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await employeeRoleService.GetRoleUsageByOrganizationAsync(organizationId); return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); } @@ -32,7 +45,9 @@ public async Task GetByOrganization() public async Task GetById(Guid id) { var result = await employeeRoleService.GetByIdAsync(id); - return result.IsSuccess ? Ok(result.Value) : NotFound(result.Error); + return result.IsSuccess + ? Ok(result.Value.Adapt()) + : NotFound(result.Error); } [HttpPost] @@ -42,20 +57,30 @@ public async Task Create(EmployeeRoleDto model) var employeeRole = new EmployeeRole { Name = model.Name.ToUpper(), + Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim(), OrganizationId = organizationId }; var result = await employeeRoleService.UpsertAsync(employeeRole); - return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + return result.IsSuccess + ? Results.Ok(result.Value.Adapt()) + : result.ToProblemDetails(); } [HttpPut("{id}")] - public async Task Update(Guid id, EmployeeRole model) + public async Task Update(Guid id, EmployeeRoleDto model) { var organizationId = HttpContext.GetOrganizationId(); - model.Id = id; - model.OrganizationId = organizationId; - var result = await employeeRoleService.UpsertAsync(model); - return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error); + var employeeRole = new EmployeeRole + { + Id = id, + OrganizationId = organizationId, + Name = model.Name.ToUpper(), + Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim() + }; + var result = await employeeRoleService.UpsertAsync(employeeRole); + return result.IsSuccess + ? Ok(result.Value.Adapt()) + : BadRequest(result.Error); } [HttpDelete("{id}")] diff --git a/JobFlow.API/Controllers/EmployeeRolePresetsController.cs b/JobFlow.API/Controllers/EmployeeRolePresetsController.cs new file mode 100644 index 0000000..552bc33 --- /dev/null +++ b/JobFlow.API/Controllers/EmployeeRolePresetsController.cs @@ -0,0 +1,108 @@ +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/employeerolepresets")] +public class EmployeeRolePresetsController : ControllerBase +{ + private readonly IEmployeeRolePresetService presetService; + private readonly IOrganizationService organizationService; + + public EmployeeRolePresetsController( + IEmployeeRolePresetService presetService, + IOrganizationService organizationService) + { + this.presetService = presetService; + this.organizationService = organizationService; + } + + [HttpGet("organization")] + public async Task GetByOrganization() + { + var organizationId = HttpContext.GetOrganizationId(); + var orgResult = await organizationService.GetOrganizationDtoById(organizationId); + var industryKey = orgResult.IsSuccess ? orgResult.Value.IndustryKey : null; + + var result = await presetService.GetAvailablePresetsAsync(organizationId, industryKey); + if (result.IsFailure) + { + return BadRequest(result.Error); + } + + var dtos = result.Value.Select(MapPresetToDto).ToList(); + return Ok(dtos); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await presetService.GetByIdAsync(organizationId, id); + return result.IsSuccess ? Ok(MapPresetToDto(result.Value)) : BadRequest(result.Error); + } + + [Authorize(Policy = "OrganizationAdminOnly")] + [HttpPost] + public async Task Create(EmployeeRolePresetDto model) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await presetService.CreateOrgPresetAsync(organizationId, model); + return result.IsSuccess ? Results.Ok(MapPresetToDto(result.Value)) : result.ToProblemDetails(); + } + + [Authorize(Policy = "OrganizationAdminOnly")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, EmployeeRolePresetDto model) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await presetService.UpdateOrgPresetAsync(organizationId, id, model); + return result.IsSuccess ? Results.Ok(MapPresetToDto(result.Value)) : result.ToProblemDetails(); + } + + [Authorize(Policy = "OrganizationAdminOnly")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await presetService.DeleteOrgPresetAsync(organizationId, id); + return result.IsSuccess ? Results.Ok() : result.ToProblemDetails(); + } + + [Authorize(Policy = "OrganizationAdminOnly")] + [HttpPost("{id:guid}/apply")] + public async Task ApplyPreset(Guid id, [FromQuery] bool overwriteExisting = true) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await presetService.ApplyPresetAsync(organizationId, id, overwriteExisting); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + private static EmployeeRolePresetDto MapPresetToDto(JobFlow.Domain.Models.EmployeeRolePreset preset) + { + return new EmployeeRolePresetDto + { + Id = preset.Id, + Name = preset.Name, + Description = preset.Description, + IndustryKey = preset.IndustryKey, + IsSystem = preset.IsSystem, + OrganizationId = preset.OrganizationId, + Items = preset.Items + .OrderBy(item => item.SortOrder) + .Select(item => new EmployeeRolePresetItemDto + { + Id = item.Id, + Name = item.Name, + Description = item.Description, + SortOrder = item.SortOrder + }) + .ToList() + }; + } +} diff --git a/JobFlow.API/Controllers/InvoiceComtroller.cs b/JobFlow.API/Controllers/InvoiceComtroller.cs index 9b2f06e..2b4d299 100644 --- a/JobFlow.API/Controllers/InvoiceComtroller.cs +++ b/JobFlow.API/Controllers/InvoiceComtroller.cs @@ -125,8 +125,15 @@ public async Task SendInvoiceReminder(Guid id) var invoice = result.Value; + if (invoice.OrganizationClient is null) + return BadRequest("Invoice client is missing."); + + if (invoice.OrganizationClient is null) + return BadRequest("Invoice client is missing."); + + var client = invoice.OrganizationClient; string? linkOverride = null; - var email = invoice.OrganizationClient?.EmailAddress; + var email = client.EmailAddress; if (!string.IsNullOrWhiteSpace(email)) { var returnUrl = $"/client-hub/invoices/{invoice.Id}"; @@ -143,7 +150,7 @@ public async Task SendInvoiceReminder(Guid id) } await notificationService.SendClientInvoiceReminderNotificationAsync( - invoice.OrganizationClient, + client, invoice, linkOverride ); diff --git a/JobFlow.API/Controllers/OrganizationController.cs b/JobFlow.API/Controllers/OrganizationController.cs index 961cba5..84c49a6 100644 --- a/JobFlow.API/Controllers/OrganizationController.cs +++ b/JobFlow.API/Controllers/OrganizationController.cs @@ -58,6 +58,15 @@ public async Task RegisterOrganization(OrganizationRegisterDto model) { try { + if (!model.Id.HasValue) + return Results.BadRequest("Organization id is required."); + + if (string.IsNullOrWhiteSpace(model.UserRole)) + return Results.BadRequest("User role is required."); + + if (string.IsNullOrWhiteSpace(model.FireBaseUid)) + return Results.BadRequest("Firebase uid is required."); + var user = new User { Email = model.EmailAddress, @@ -93,12 +102,33 @@ public async Task GetOrganizationById([FromBody] OrganizationRequest or return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + [HttpPut] + [Route("industry")] + public async Task UpdateIndustry([FromBody] OrganizationIndustryUpdateDto request) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _organizationService.UpdateIndustryAsync(organizationId, request.IndustryKey); + if (result.IsFailure) + { + return result.ToProblemDetails(); + } + + var dtoResult = await _organizationService.GetOrganizationDtoById(organizationId); + return dtoResult.IsSuccess ? Results.Ok(dtoResult.Value) : dtoResult.ToProblemDetails(); + } + [HttpPost] [Route("onboarding")] public async Task OrganizationOnboarding([FromBody] OnboardingDto onboarding) { var orgResult = await _organizationService.GetAllOrganizations(); var ownerId = orgResult.Value.FirstOrDefault(e => e.OrganizationType?.TypeName == "Master Account")?.Id; + if (!ownerId.HasValue) + return Results.Problem("Master account not found."); + + var paymentProfileDto = onboarding.PaymentProfile; + if (paymentProfileDto is null) + return Results.Problem("Payment profile is required."); var org = new Organization { Id = onboarding.OrganizationId, @@ -106,21 +136,25 @@ public async Task OrganizationOnboarding([FromBody] OnboardingDto onboa EnableTax = onboarding.EnableTax, OnBoardingComplete = onboarding.OnboardingComplete }; - var branding = new OrganizationBranding + OrganizationBranding? branding = null; + if (onboarding.Branding is not null) { - LogoUrl = onboarding?.Branding?.LogoUrl, - FooterNote = onboarding?.Branding?.FooterNote, - PrimaryColor = onboarding?.Branding?.PrimaryColor, - SecondaryColor = onboarding?.Branding?.SecondaryColor, - Tagline = onboarding?.Branding?.Tagline, - CreatedAt = DateTime.UtcNow - }; + branding = new OrganizationBranding + { + LogoUrl = onboarding.Branding.LogoUrl, + FooterNote = onboarding.Branding.FooterNote, + PrimaryColor = onboarding.Branding.PrimaryColor, + SecondaryColor = onboarding.Branding.SecondaryColor, + Tagline = onboarding.Branding.Tagline, + CreatedAt = DateTime.UtcNow + }; + } var paymentProfile = new CustomerPaymentProfile { OwnerType = PaymentEntityType.Organization, OwnerId = ownerId.Value, - Provider = onboarding.PaymentProfile.Provider, - ProviderCustomerId = onboarding.PaymentProfile.ProviderCustomerId, + Provider = paymentProfileDto.Provider, + ProviderCustomerId = paymentProfileDto.ProviderCustomerId, CreatedAt = DateTime.UtcNow }; diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index 5511e1c..60a6cb6 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -380,7 +380,7 @@ public async Task HandleStripeWebhook() _stripeSettings.WebhookKey ); } - catch (StripeException e) + catch (StripeException) { return BadRequest(); } diff --git a/JobFlow.API/Controllers/StripePaymentController.cs b/JobFlow.API/Controllers/StripePaymentController.cs index 5918501..967542c 100644 --- a/JobFlow.API/Controllers/StripePaymentController.cs +++ b/JobFlow.API/Controllers/StripePaymentController.cs @@ -112,5 +112,5 @@ public async Task CreateCheckoutSession() public class AccountLinkPostBody { - public string Account { get; set; } + public string Account { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs index f9fd586..9438674 100644 --- a/JobFlow.API/Mappings/InvoiceMappingExtensions.cs +++ b/JobFlow.API/Mappings/InvoiceMappingExtensions.cs @@ -12,7 +12,8 @@ public static Invoice ToInvoice(this CreateInvoiceRequest request, string invoic { Id = Guid.NewGuid(), OrganizationId = request.OrganizationId, - OrganizationClientId = request.OrganizationClientId.Value, + OrganizationClientId = request.OrganizationClientId + ?? throw new InvalidOperationException("Organization client is required."), JobId = request.JobId, InvoiceNumber = invoiceNumber, InvoiceDate = DateTime.UtcNow, diff --git a/JobFlow.API/Models/InvoiceDto.cs b/JobFlow.API/Models/InvoiceDto.cs index 504efc5..e30ac04 100644 --- a/JobFlow.API/Models/InvoiceDto.cs +++ b/JobFlow.API/Models/InvoiceDto.cs @@ -6,7 +6,7 @@ namespace JobFlow.API.Models; public class InvoiceDto { public Guid Id { get; set; } - public string InvoiceNumber { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; public Guid OrganizationId { get; set; } public Guid OrganizationClientId { get; set; } public Guid? JobId { get; set; } diff --git a/JobFlow.API/Models/MarkStepRequestDto.cs b/JobFlow.API/Models/MarkStepRequestDto.cs index e90da50..7ec7e0f 100644 --- a/JobFlow.API/Models/MarkStepRequestDto.cs +++ b/JobFlow.API/Models/MarkStepRequestDto.cs @@ -2,5 +2,5 @@ public class MarkStepRequestDto { - public string StepName { get; set; } + public string StepName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.API/Models/OnboardingStepDto.cs b/JobFlow.API/Models/OnboardingStepDto.cs index dbf9cd2..6f8bb21 100644 --- a/JobFlow.API/Models/OnboardingStepDto.cs +++ b/JobFlow.API/Models/OnboardingStepDto.cs @@ -3,7 +3,7 @@ public class OnboardingStepDto { public Guid Id { get; set; } - public string StepName { get; set; } + public string StepName { get; set; } = string.Empty; public bool IsCompleted { get; set; } public DateTimeOffset? CompletedAt { get; set; } } \ No newline at end of file diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index cdc4d1d..b177e40 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -24,7 +24,6 @@ using JobFlow.Infrastructure.Middleware; using JobFlow.Infrastructure.Persistence; using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.IdentityModel.Tokens; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -97,9 +96,10 @@ // Create the Firebase Admin default app instance so FirebaseAuth.DefaultInstance is available. if (FirebaseApp.DefaultInstance is null) { + var credential = CredentialFactory.FromFile(firebaseFilePath); FirebaseApp.Create(new AppOptions { - Credential = GoogleCredential.FromFile(firebaseFilePath) + Credential = credential.ToGoogleCredential() }); } diff --git a/JobFlow.Business/ModelErrors/EmployeeRolePresetErrors.cs b/JobFlow.Business/ModelErrors/EmployeeRolePresetErrors.cs new file mode 100644 index 0000000..e4ead63 --- /dev/null +++ b/JobFlow.Business/ModelErrors/EmployeeRolePresetErrors.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Business.ModelErrors; + +public static class EmployeeRolePresetErrors +{ + public static Error EmployeeRolePresetNotFound => Error.NotFound( + "EmployeeRolePreset.NotFound", + "Employee role preset not found."); + + public static Error EmployeeRolePresetForbidden => Error.Failure( + "EmployeeRolePreset.Forbidden", + "You do not have access to this preset."); +} diff --git a/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs index 523d1a7..841e743 100644 --- a/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs +++ b/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs @@ -4,5 +4,6 @@ public class EmployeeRoleDto { public Guid? Id { get; set; } public string Name { get; set; } + public string? Description { get; set; } public Guid OrganizationId { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/EmployeeRolePresetApplyResultDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRolePresetApplyResultDto.cs new file mode 100644 index 0000000..1b3db02 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EmployeeRolePresetApplyResultDto.cs @@ -0,0 +1,8 @@ +namespace JobFlow.Business.Models.DTOs; + +public class EmployeeRolePresetApplyResultDto +{ + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs new file mode 100644 index 0000000..e04656d --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Business.Models.DTOs; + +public class EmployeeRolePresetDto +{ + public Guid? Id { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public string? IndustryKey { get; set; } + public bool IsSystem { get; set; } + public Guid? OrganizationId { get; set; } + public List Items { get; set; } = new(); +} diff --git a/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs new file mode 100644 index 0000000..801de3c --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Business.Models.DTOs; + +public class EmployeeRolePresetItemDto +{ + public Guid? Id { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public int SortOrder { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/EmployeeRoleUsageDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRoleUsageDto.cs new file mode 100644 index 0000000..85d3c14 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/EmployeeRoleUsageDto.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Business.Models.DTOs; + +public class EmployeeRoleUsageDto +{ + public Guid RoleId { get; set; } + public int EmployeeCount { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/OrganizationDto.cs b/JobFlow.Business/Models/DTOs/OrganizationDto.cs index a301a13..086df56 100644 --- a/JobFlow.Business/Models/DTOs/OrganizationDto.cs +++ b/JobFlow.Business/Models/DTOs/OrganizationDto.cs @@ -37,4 +37,5 @@ public class OrganizationDto public bool? OnBoardingComplete { get; set; } public bool CanAcceptPayments { get; set; } public string? SubscriptionPlanName { get; set; } + public string? IndustryKey { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/OrganizationIndustryUpdateDto.cs b/JobFlow.Business/Models/DTOs/OrganizationIndustryUpdateDto.cs new file mode 100644 index 0000000..37aabb8 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/OrganizationIndustryUpdateDto.cs @@ -0,0 +1,6 @@ +namespace JobFlow.Business.Models.DTOs; + +public class OrganizationIndustryUpdateDto +{ + public string? IndustryKey { get; set; } +} diff --git a/JobFlow.Business/Services/EmployeeRolePresetService.cs b/JobFlow.Business/Services/EmployeeRolePresetService.cs new file mode 100644 index 0000000..36fd0a2 --- /dev/null +++ b/JobFlow.Business/Services/EmployeeRolePresetService.cs @@ -0,0 +1,215 @@ +using JobFlow.Business.DI; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class EmployeeRolePresetService : IEmployeeRolePresetService +{ + private readonly IRepository presets; + private readonly IRepository presetItems; + private readonly IRepository roles; + private readonly ILogger logger; + private readonly IUnitOfWork unitOfWork; + + public EmployeeRolePresetService(ILogger logger, IUnitOfWork unitOfWork) + { + this.logger = logger; + this.unitOfWork = unitOfWork; + presets = unitOfWork.RepositoryOf(); + presetItems = unitOfWork.RepositoryOf(); + roles = unitOfWork.RepositoryOf(); + } + + public async Task>> GetAvailablePresetsAsync(Guid organizationId, string? industryKey) + { + var query = presets.Query() + .Include(p => p.Items) + .Where(p => (p.OrganizationId == organizationId) || (p.IsSystem && p.IndustryKey == industryKey)); + + var data = await query + .OrderByDescending(p => p.IsSystem) + .ThenBy(p => p.Name) + .ToListAsync(); + + return Result.Success>(data); + } + + public async Task> GetByIdAsync(Guid organizationId, Guid presetId) + { + var preset = await presets.Query() + .Include(p => p.Items) + .FirstOrDefaultAsync(p => p.Id == presetId); + + if (preset == null) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetNotFound); + } + + if (preset.OrganizationId.HasValue && preset.OrganizationId != organizationId) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetForbidden); + } + + return Result.Success(preset); + } + + public async Task> CreateOrgPresetAsync(Guid organizationId, EmployeeRolePresetDto dto) + { + var preset = new EmployeeRolePreset + { + OrganizationId = organizationId, + Name = dto.Name.Trim(), + Description = string.IsNullOrWhiteSpace(dto.Description) ? null : dto.Description.Trim(), + IndustryKey = string.IsNullOrWhiteSpace(dto.IndustryKey) ? null : dto.IndustryKey.Trim(), + IsSystem = false + }; + + await presets.AddAsync(preset); + await unitOfWork.SaveChangesAsync(); + + await ReplacePresetItemsAsync(preset, dto.Items); + + return Result.Success(preset); + } + + public async Task> UpdateOrgPresetAsync(Guid organizationId, Guid presetId, EmployeeRolePresetDto dto) + { + var preset = await presets.Query().FirstOrDefaultAsync(p => p.Id == presetId); + if (preset == null) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetNotFound); + } + + if (preset.IsSystem || preset.OrganizationId != organizationId) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetForbidden); + } + + preset.Name = dto.Name.Trim(); + preset.Description = string.IsNullOrWhiteSpace(dto.Description) ? null : dto.Description.Trim(); + preset.IndustryKey = string.IsNullOrWhiteSpace(dto.IndustryKey) ? null : dto.IndustryKey.Trim(); + preset.UpdatedAt = DateTime.UtcNow; + + presets.Update(preset); + await unitOfWork.SaveChangesAsync(); + + await ReplacePresetItemsAsync(preset, dto.Items); + + return Result.Success(preset); + } + + public async Task DeleteOrgPresetAsync(Guid organizationId, Guid presetId) + { + var preset = await presets.Query().FirstOrDefaultAsync(p => p.Id == presetId); + if (preset == null) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetNotFound); + } + + if (preset.IsSystem || preset.OrganizationId != organizationId) + { + return Result.Failure(EmployeeRolePresetErrors.EmployeeRolePresetForbidden); + } + + presets.Remove(preset); + await unitOfWork.SaveChangesAsync(); + return Result.Success(); + } + + public async Task> ApplyPresetAsync(Guid organizationId, Guid presetId, bool overwriteExisting) + { + var presetResult = await GetByIdAsync(organizationId, presetId); + if (presetResult.IsFailure) + { + return Result.Failure(presetResult.Error); + } + + var preset = presetResult.Value; + var existingRoles = await roles.Query() + .Where(r => r.OrganizationId == organizationId) + .ToListAsync(); + + var created = 0; + var updated = 0; + var skipped = 0; + + foreach (var item in preset.Items.OrderBy(i => i.SortOrder)) + { + var normalizedName = item.Name.Trim(); + var match = existingRoles.FirstOrDefault(r => r.Name.Equals(normalizedName, StringComparison.OrdinalIgnoreCase)); + + if (match == null) + { + var newRole = new EmployeeRole + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Name = normalizedName.ToUpper(), + Description = string.IsNullOrWhiteSpace(item.Description) ? null : item.Description.Trim() + }; + await roles.AddAsync(newRole); + existingRoles.Add(newRole); + created += 1; + continue; + } + + if (!overwriteExisting) + { + skipped += 1; + continue; + } + + match.Description = string.IsNullOrWhiteSpace(item.Description) + ? match.Description + : item.Description.Trim(); + roles.Update(match); + updated += 1; + } + + await unitOfWork.SaveChangesAsync(); + + return Result.Success(new EmployeeRolePresetApplyResultDto + { + Created = created, + Updated = updated, + Skipped = skipped + }); + } + + private async Task ReplacePresetItemsAsync(EmployeeRolePreset preset, IEnumerable items) + { + var existing = await presetItems.Query() + .Where(item => item.PresetId == preset.Id) + .ToListAsync(); + + if (existing.Count > 0) + { + presetItems.RemoveRange(existing); + await unitOfWork.SaveChangesAsync(); + } + + var list = items + .Select((item, index) => new EmployeeRolePresetItem + { + PresetId = preset.Id, + Name = item.Name.Trim(), + Description = string.IsNullOrWhiteSpace(item.Description) ? null : item.Description.Trim(), + SortOrder = item.SortOrder > 0 ? item.SortOrder : index + 1 + }) + .ToList(); + + foreach (var item in list) + { + await presetItems.AddAsync(item); + } + + await unitOfWork.SaveChangesAsync(); + } +} diff --git a/JobFlow.Business/Services/EmployeeRoleService.cs b/JobFlow.Business/Services/EmployeeRoleService.cs index c86b0af..cec4696 100644 --- a/JobFlow.Business/Services/EmployeeRoleService.cs +++ b/JobFlow.Business/Services/EmployeeRoleService.cs @@ -1,5 +1,6 @@ using JobFlow.Business.DI; using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; @@ -12,6 +13,7 @@ namespace JobFlow.Business.Services; public class EmployeeRoleService : IEmployeeRoleService { private readonly IRepository employeeRoles; + private readonly IRepository employees; private readonly ILogger logger; private readonly IUnitOfWork unitOfWork; @@ -20,6 +22,7 @@ public EmployeeRoleService(ILogger logger, IUnitOfWork unit this.logger = logger; this.unitOfWork = unitOfWork; employeeRoles = unitOfWork.RepositoryOf(); + employees = unitOfWork.RepositoryOf(); } // 🔹 Get all roles for an organization @@ -57,6 +60,36 @@ public async Task> UpsertAsync(EmployeeRole model) return Result.Success(model); } + public async Task>> GetRoleUsageByOrganizationAsync(Guid organizationId) + { + var roles = await employeeRoles.Query() + .Where(role => role.OrganizationId == organizationId) + .Select(role => role.Id) + .ToListAsync(); + + if (roles.Count == 0) + { + return Result>.Success(Enumerable.Empty()); + } + + var counts = await employees.Query() + .Where(employee => employee.OrganizationId == organizationId) + .GroupBy(employee => employee.RoleId) + .Select(group => new EmployeeRoleUsageDto + { + RoleId = group.Key, + EmployeeCount = group.Count() + }) + .ToListAsync(); + + var usage = roles.Select(roleId => + counts.FirstOrDefault(count => count.RoleId == roleId) + ?? new EmployeeRoleUsageDto { RoleId = roleId, EmployeeCount = 0 }) + .ToList(); + + return Result>.Success(usage.AsEnumerable()); + } + // 🔹 Delete a role public async Task DeleteAsync(Guid id) { diff --git a/JobFlow.Business/Services/OrganizationService.cs b/JobFlow.Business/Services/OrganizationService.cs index 56b59e7..b4025c0 100644 --- a/JobFlow.Business/Services/OrganizationService.cs +++ b/JobFlow.Business/Services/OrganizationService.cs @@ -126,4 +126,19 @@ public async Task> UpsertOrganization(Organization model) return Result.Success(model); } + + public async Task> UpdateIndustryAsync(Guid organizationId, string? industryKey) + { + var organization = _organizations.FirstOrDefault(org => org.Id == organizationId); + if (organization == null) + { + return Result.Failure(OrganizationErrors.OrganizationNotFound); + } + + organization.IndustryKey = string.IsNullOrWhiteSpace(industryKey) ? null : industryKey.Trim(); + _unitOfWork.RepositoryOf().Update(organization); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(organization); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRolePresetService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRolePresetService.cs new file mode 100644 index 0000000..776db25 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRolePresetService.cs @@ -0,0 +1,14 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Models; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IEmployeeRolePresetService +{ + Task>> GetAvailablePresetsAsync(Guid organizationId, string? industryKey); + Task> GetByIdAsync(Guid organizationId, Guid presetId); + Task> CreateOrgPresetAsync(Guid organizationId, EmployeeRolePresetDto dto); + Task> UpdateOrgPresetAsync(Guid organizationId, Guid presetId, EmployeeRolePresetDto dto); + Task DeleteOrgPresetAsync(Guid organizationId, Guid presetId); + Task> ApplyPresetAsync(Guid organizationId, Guid presetId, bool overwriteExisting); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRoleService.cs b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRoleService.cs index 5dcdeab..612e0ff 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRoleService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IEmployeeRoleService.cs @@ -1,4 +1,5 @@ using JobFlow.Domain.Models; +using JobFlow.Business.Models.DTOs; namespace JobFlow.Business.Services.ServiceInterfaces; @@ -6,6 +7,7 @@ public interface IEmployeeRoleService { Task>> GetRolesByOrganizationAsync(Guid organizationId); Task> GetByIdAsync(Guid id); + Task>> GetRoleUsageByOrganizationAsync(Guid organizationId); Task> UpsertAsync(EmployeeRole model); Task DeleteAsync(Guid id); } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs index f6d356a..488fc2f 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs @@ -9,6 +9,7 @@ public interface IOrganizationService Task> GetOrganizationDtoById(Guid orgId); Task>> GetAllOrganizations(); Task> UpsertOrganization(Organization model); + Task> UpdateIndustryAsync(Guid organizationId, string? industryKey); Task MarkStripeConnectedAsync(string stripeAccountId); Task DeleteOrganization(Guid organizationId); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EmployeeRole.cs b/JobFlow.Domain/Models/EmployeeRole.cs index a93c8df..dda1a4c 100644 --- a/JobFlow.Domain/Models/EmployeeRole.cs +++ b/JobFlow.Domain/Models/EmployeeRole.cs @@ -4,6 +4,7 @@ public class EmployeeRole { public Guid Id { get; set; } public string Name { get; set; } + public string? Description { get; set; } public Guid OrganizationId { get; set; } public Organization Organization { get; set; } diff --git a/JobFlow.Domain/Models/EmployeeRolePreset.cs b/JobFlow.Domain/Models/EmployeeRolePreset.cs new file mode 100644 index 0000000..4c352fd --- /dev/null +++ b/JobFlow.Domain/Models/EmployeeRolePreset.cs @@ -0,0 +1,12 @@ +namespace JobFlow.Domain.Models; + +public class EmployeeRolePreset : Entity +{ + public Guid? OrganizationId { get; set; } + public string? IndustryKey { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsSystem { get; set; } + public Organization? Organization { get; set; } + public ICollection Items { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/EmployeeRolePresetItem.cs b/JobFlow.Domain/Models/EmployeeRolePresetItem.cs new file mode 100644 index 0000000..df7d787 --- /dev/null +++ b/JobFlow.Domain/Models/EmployeeRolePresetItem.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Models; + +public class EmployeeRolePresetItem : Entity +{ + public Guid PresetId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public int SortOrder { get; set; } + public EmployeeRolePreset Preset { get; set; } +} diff --git a/JobFlow.Domain/Models/Organization.cs b/JobFlow.Domain/Models/Organization.cs index 1dcc124..a2fd691 100644 --- a/JobFlow.Domain/Models/Organization.cs +++ b/JobFlow.Domain/Models/Organization.cs @@ -23,6 +23,7 @@ public class Organization : Entity public string? OnboardingPresetKey { get; set; } public DateTimeOffset? OnboardingTrackSelectedAt { get; set; } public DateTimeOffset? OnboardingPresetAppliedAt { get; set; } + public string? IndustryKey { get; set; } public bool CanAcceptPayments => PaymentProvider == PaymentProvider.Stripe && diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs index d0ee4fc..404cb4a 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRoleConfiguration.cs @@ -18,6 +18,9 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasMaxLength(100); + builder.Property(er => er.Description) + .HasMaxLength(240); + builder.HasQueryFilter(er => er.Organization.IsActive); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetConfiguration.cs new file mode 100644 index 0000000..6eb590a --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetConfiguration.cs @@ -0,0 +1,72 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal class EmployeeRolePresetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EmployeeRolePresets"); + builder.HasKey(preset => preset.Id); + builder.Property(preset => preset.Name) + .IsRequired() + .HasMaxLength(120); + builder.Property(preset => preset.Description) + .HasMaxLength(240); + builder.Property(preset => preset.IndustryKey) + .HasMaxLength(80); + builder.HasOne(preset => preset.Organization) + .WithMany() + .HasForeignKey(preset => preset.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasQueryFilter(preset => preset.IsActive); + + var createdAt = new DateTime(2026, 3, 23, 0, 0, 0, DateTimeKind.Utc); + + builder.HasData( + new EmployeeRolePreset + { + Id = Guid.Parse("1a2b3c4d-1111-1111-1111-111111111111"), + Name = "Home services", + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsSystem = true, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePreset + { + Id = Guid.Parse("1a2b3c4d-2222-2222-2222-222222222222"), + Name = "Creative", + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsSystem = true, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePreset + { + Id = Guid.Parse("1a2b3c4d-3333-3333-3333-333333333333"), + Name = "Consulting", + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsSystem = true, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePreset + { + Id = Guid.Parse("1a2b3c4d-4444-4444-4444-444444444444"), + Name = "Tech repair", + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsSystem = true, + CreatedAt = createdAt, + IsActive = true + } + ); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetItemConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetItemConfiguration.cs new file mode 100644 index 0000000..af96a17 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/EmployeeRolePresetItemConfiguration.cs @@ -0,0 +1,190 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal class EmployeeRolePresetItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EmployeeRolePresetItems"); + builder.HasKey(item => item.Id); + builder.Property(item => item.Name) + .IsRequired() + .HasMaxLength(120); + builder.Property(item => item.Description) + .HasMaxLength(240); + builder.HasOne(item => item.Preset) + .WithMany(preset => preset.Items) + .HasForeignKey(item => item.PresetId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasQueryFilter(item => item.IsActive); + + var createdAt = new DateTime(2026, 3, 23, 0, 0, 0, DateTimeKind.Utc); + + builder.HasData( + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-1111-1111-1111-111111111111"), + PresetId = Guid.Parse("1a2b3c4d-1111-1111-1111-111111111111"), + Name = "Technician", + Description = "Field technician for on-site work.", + SortOrder = 1, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-1111-1111-1111-111111111112"), + PresetId = Guid.Parse("1a2b3c4d-1111-1111-1111-111111111111"), + Name = "Supervisor", + Description = "Lead for quality checks and approvals.", + SortOrder = 2, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-1111-1111-1111-111111111113"), + PresetId = Guid.Parse("1a2b3c4d-1111-1111-1111-111111111111"), + Name = "Dispatcher", + Description = "Routes schedules and job assignments.", + SortOrder = 3, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-1111-1111-1111-111111111114"), + PresetId = Guid.Parse("1a2b3c4d-1111-1111-1111-111111111111"), + Name = "Admin", + Description = "Back-office support and billing.", + SortOrder = 4, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-2222-2222-2222-222222222221"), + PresetId = Guid.Parse("1a2b3c4d-2222-2222-2222-222222222222"), + Name = "Designer", + Description = "Primary creator and deliverable owner.", + SortOrder = 1, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-2222-2222-2222-222222222222"), + PresetId = Guid.Parse("1a2b3c4d-2222-2222-2222-222222222222"), + Name = "Producer", + Description = "Owns timelines, approvals, and client comms.", + SortOrder = 2, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-2222-2222-2222-222222222223"), + PresetId = Guid.Parse("1a2b3c4d-2222-2222-2222-222222222222"), + Name = "Coordinator", + Description = "Schedules tasks and supports delivery.", + SortOrder = 3, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-2222-2222-2222-222222222224"), + PresetId = Guid.Parse("1a2b3c4d-2222-2222-2222-222222222222"), + Name = "Admin", + Description = "Operations and billing support.", + SortOrder = 4, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-3333-3333-3333-333333333331"), + PresetId = Guid.Parse("1a2b3c4d-3333-3333-3333-333333333333"), + Name = "Consultant", + Description = "Client-facing delivery specialist.", + SortOrder = 1, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-3333-3333-3333-333333333332"), + PresetId = Guid.Parse("1a2b3c4d-3333-3333-3333-333333333333"), + Name = "Lead", + Description = "Owns engagement delivery and quality.", + SortOrder = 2, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-3333-3333-3333-333333333333"), + PresetId = Guid.Parse("1a2b3c4d-3333-3333-3333-333333333333"), + Name = "Coordinator", + Description = "Plans meetings and follow-ups.", + SortOrder = 3, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-3333-3333-3333-333333333334"), + PresetId = Guid.Parse("1a2b3c4d-3333-3333-3333-333333333333"), + Name = "Admin", + Description = "Back-office support and billing.", + SortOrder = 4, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-4444-4444-4444-444444444441"), + PresetId = Guid.Parse("1a2b3c4d-4444-4444-4444-444444444444"), + Name = "Repair Tech", + Description = "Executes diagnostics and repairs.", + SortOrder = 1, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-4444-4444-4444-444444444442"), + PresetId = Guid.Parse("1a2b3c4d-4444-4444-4444-444444444444"), + Name = "QA", + Description = "Final testing and release approvals.", + SortOrder = 2, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-4444-4444-4444-444444444443"), + PresetId = Guid.Parse("1a2b3c4d-4444-4444-4444-444444444444"), + Name = "Service Advisor", + Description = "Client intake and status updates.", + SortOrder = 3, + CreatedAt = createdAt, + IsActive = true + }, + new EmployeeRolePresetItem + { + Id = Guid.Parse("2a2b3c4d-4444-4444-4444-444444444444"), + PresetId = Guid.Parse("1a2b3c4d-4444-4444-4444-444444444444"), + Name = "Admin", + Description = "Operations and billing support.", + SortOrder = 4, + CreatedAt = createdAt, + IsActive = true + } + ); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationConfiguration.cs index ff7c22b..b44e487 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationConfiguration.cs @@ -12,6 +12,8 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(x => x.DefaultTaxRate) .HasPrecision(18, 2); + builder.Property(x => x.IndustryKey) + .HasMaxLength(80); builder.HasOne(e => e.OrganizationType) .WithMany() .HasForeignKey(e => e.OrganizationTypeId) diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index e2b7709..95ce179 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -20,6 +20,8 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet InvoiceSequences { get; set; } public DbSet Organizations { get; set; } public DbSet OrganizationTypes { get; set; } + public DbSet EmployeeRolePresets { get; set; } + public DbSet EmployeeRolePresetItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.Designer.cs new file mode 100644 index 0000000..6a658fc --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.Designer.cs @@ -0,0 +1,2750 @@ +// +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("20260323171218_AddRoleDescriptionAndIndustryKey")] + partial class AddRoleDescriptionAndIndustryKey + { + /// + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260323171218_AddRoleDescriptionAndIndustryKey.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.cs new file mode 100644 index 0000000..9ec1e53 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323171218_AddRoleDescriptionAndIndustryKey.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRoleDescriptionAndIndustryKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IndustryKey", + table: "Organization", + type: "nvarchar(80)", + maxLength: 80, + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "EmployeeRoles", + type: "nvarchar(240)", + maxLength: 240, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IndustryKey", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "Description", + table: "EmployeeRoles"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.Designer.cs new file mode 100644 index 0000000..efcd36e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.Designer.cs @@ -0,0 +1,3076 @@ +// +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("20260323173511_AddEmployeeRolePresets")] + partial class AddEmployeeRolePresets + { + /// + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260323173511_AddEmployeeRolePresets.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.cs new file mode 100644 index 0000000..ac12c6d --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323173511_AddEmployeeRolePresets.cs @@ -0,0 +1,126 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEmployeeRolePresets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmployeeRolePresets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: true), + IndustryKey = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: true), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Description = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: true), + IsSystem = table.Column(type: "bit", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmployeeRolePresets", x => x.Id); + table.ForeignKey( + name: "FK_EmployeeRolePresets_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "EmployeeRolePresetItems", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + PresetId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Description = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmployeeRolePresetItems", x => x.Id); + table.ForeignKey( + name: "FK_EmployeeRolePresetItems_EmployeeRolePresets_PresetId", + column: x => x.PresetId, + principalTable: "EmployeeRolePresets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "EmployeeRolePresets", + columns: new[] { "Id", "CreatedAt", "CreatedBy", "DeactivatedAtUtc", "Description", "IndustryKey", "IsActive", "IsSystem", "Name", "OrganizationId", "UpdatedAt", "UpdatedBy" }, + values: new object[,] + { + { new Guid("1a2b3c4d-1111-1111-1111-111111111111"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Default roles for field service teams.", "home-services", true, true, "Home services", null, null, null }, + { new Guid("1a2b3c4d-2222-2222-2222-222222222222"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Default roles for creative studios.", "creative", true, true, "Creative", null, null, null }, + { new Guid("1a2b3c4d-3333-3333-3333-333333333333"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Default roles for consulting teams.", "consulting", true, true, "Consulting", null, null, null }, + { new Guid("1a2b3c4d-4444-4444-4444-444444444444"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Default roles for repair shops.", "tech-repair", true, true, "Tech repair", null, null, null } + }); + + migrationBuilder.InsertData( + table: "EmployeeRolePresetItems", + columns: new[] { "Id", "CreatedAt", "CreatedBy", "DeactivatedAtUtc", "Description", "IsActive", "Name", "PresetId", "SortOrder", "UpdatedAt", "UpdatedBy" }, + values: new object[,] + { + { new Guid("2a2b3c4d-1111-1111-1111-111111111111"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Field technician for on-site work.", true, "Technician", new Guid("1a2b3c4d-1111-1111-1111-111111111111"), 1, null, null }, + { new Guid("2a2b3c4d-1111-1111-1111-111111111112"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Lead for quality checks and approvals.", true, "Supervisor", new Guid("1a2b3c4d-1111-1111-1111-111111111111"), 2, null, null }, + { new Guid("2a2b3c4d-1111-1111-1111-111111111113"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Routes schedules and job assignments.", true, "Dispatcher", new Guid("1a2b3c4d-1111-1111-1111-111111111111"), 3, null, null }, + { new Guid("2a2b3c4d-1111-1111-1111-111111111114"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Back-office support and billing.", true, "Admin", new Guid("1a2b3c4d-1111-1111-1111-111111111111"), 4, null, null }, + { new Guid("2a2b3c4d-2222-2222-2222-222222222221"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Primary creator and deliverable owner.", true, "Designer", new Guid("1a2b3c4d-2222-2222-2222-222222222222"), 1, null, null }, + { new Guid("2a2b3c4d-2222-2222-2222-222222222222"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Owns timelines, approvals, and client comms.", true, "Producer", new Guid("1a2b3c4d-2222-2222-2222-222222222222"), 2, null, null }, + { new Guid("2a2b3c4d-2222-2222-2222-222222222223"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Schedules tasks and supports delivery.", true, "Coordinator", new Guid("1a2b3c4d-2222-2222-2222-222222222222"), 3, null, null }, + { new Guid("2a2b3c4d-2222-2222-2222-222222222224"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Operations and billing support.", true, "Admin", new Guid("1a2b3c4d-2222-2222-2222-222222222222"), 4, null, null }, + { new Guid("2a2b3c4d-3333-3333-3333-333333333331"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Client-facing delivery specialist.", true, "Consultant", new Guid("1a2b3c4d-3333-3333-3333-333333333333"), 1, null, null }, + { new Guid("2a2b3c4d-3333-3333-3333-333333333332"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Owns engagement delivery and quality.", true, "Lead", new Guid("1a2b3c4d-3333-3333-3333-333333333333"), 2, null, null }, + { new Guid("2a2b3c4d-3333-3333-3333-333333333333"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Plans meetings and follow-ups.", true, "Coordinator", new Guid("1a2b3c4d-3333-3333-3333-333333333333"), 3, null, null }, + { new Guid("2a2b3c4d-3333-3333-3333-333333333334"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Back-office support and billing.", true, "Admin", new Guid("1a2b3c4d-3333-3333-3333-333333333333"), 4, null, null }, + { new Guid("2a2b3c4d-4444-4444-4444-444444444441"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Executes diagnostics and repairs.", true, "Repair Tech", new Guid("1a2b3c4d-4444-4444-4444-444444444444"), 1, null, null }, + { new Guid("2a2b3c4d-4444-4444-4444-444444444442"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Final testing and release approvals.", true, "QA", new Guid("1a2b3c4d-4444-4444-4444-444444444444"), 2, null, null }, + { new Guid("2a2b3c4d-4444-4444-4444-444444444443"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Client intake and status updates.", true, "Service Advisor", new Guid("1a2b3c4d-4444-4444-4444-444444444444"), 3, null, null }, + { new Guid("2a2b3c4d-4444-4444-4444-444444444444"), new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), null, null, "Operations and billing support.", true, "Admin", new Guid("1a2b3c4d-4444-4444-4444-444444444444"), 4, null, null } + }); + + migrationBuilder.CreateIndex( + name: "IX_EmployeeRolePresetItems_PresetId", + table: "EmployeeRolePresetItems", + column: "PresetId"); + + migrationBuilder.CreateIndex( + name: "IX_EmployeeRolePresets_OrganizationId", + table: "EmployeeRolePresets", + column: "OrganizationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmployeeRolePresetItems"); + + migrationBuilder.DropTable( + name: "EmployeeRolePresets"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 9f38e57..42f15a5 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -492,6 +492,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -507,6 +511,306 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EmployeeRoles", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => { b.Property("Id") @@ -1394,6 +1698,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HasFreeAccount") .HasColumnType("bit"); + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -2334,6 +2642,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => { b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") @@ -2659,6 +2988,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Employees"); }); + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Estimate", b => { b.Navigation("LineItems"); diff --git a/JobFlow.Infrastructure/ConfigurationModels/BrevoSettings.cs b/JobFlow.Infrastructure/ConfigurationModels/BrevoSettings.cs index 28664df..66c84a9 100644 --- a/JobFlow.Infrastructure/ConfigurationModels/BrevoSettings.cs +++ b/JobFlow.Infrastructure/ConfigurationModels/BrevoSettings.cs @@ -4,5 +4,5 @@ namespace JobFlow.Infrastructure.ExternalServices.ConfigurationModels; public class BrevoSettings : IBrevoSettings { - public string ApiKey { get; set; } + public string ApiKey { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ConfigurationModels/StripeSettings.cs b/JobFlow.Infrastructure/ConfigurationModels/StripeSettings.cs index 74c82a9..1bb2435 100644 --- a/JobFlow.Infrastructure/ConfigurationModels/StripeSettings.cs +++ b/JobFlow.Infrastructure/ConfigurationModels/StripeSettings.cs @@ -4,8 +4,8 @@ namespace JobFlow.Infrastructure.ExternalServices.ConfigurationModels; public class StripeSettings : IStripeSettings { - public string ApiKey { get; set; } - public string ReturnUrl { get; set; } - public string RefreshUrl { get; set; } - public string WebhookKey { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string ReturnUrl { get; set; } = string.Empty; + public string RefreshUrl { get; set; } = string.Empty; + public string WebhookKey { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ConfigurationModels/TwilioSettings.cs b/JobFlow.Infrastructure/ConfigurationModels/TwilioSettings.cs index 114b92e..3e3e227 100644 --- a/JobFlow.Infrastructure/ConfigurationModels/TwilioSettings.cs +++ b/JobFlow.Infrastructure/ConfigurationModels/TwilioSettings.cs @@ -4,8 +4,8 @@ namespace JobFlow.Infrastructure.ExternalServices.ConfigurationModels; public class TwilioSettings : ITwilioSettings { - public string AccountSId { get; set; } - public string AuthToken { get; set; } - public string SenderPhoneNumber { get; set; } - public string MessagingServiceSid { get; set; } + public string AccountSId { get; set; } = string.Empty; + public string AuthToken { get; set; } = string.Empty; + public string SenderPhoneNumber { get; set; } = string.Empty; + public string MessagingServiceSid { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs b/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs index 730e4fd..1557ba7 100644 --- a/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs +++ b/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs @@ -43,7 +43,7 @@ public async Task AddContactAsync(string email, int listId) Encoding.UTF8, "application/json"); - HttpResponseMessage response = null; + HttpResponseMessage? response = null; await _policy.ExecuteAsync(async () => { @@ -51,7 +51,7 @@ await _policy.ExecuteAsync(async () => response.EnsureSuccessStatusCode(); }); - return response.IsSuccessStatusCode; + return response is not null && response.IsSuccessStatusCode; } public async Task SendContactEmailAsync(ContactFormRequest request) @@ -77,7 +77,7 @@ public async Task SendContactEmailAsync(ContactFormRequest request) Encoding.UTF8, "application/json"); - HttpResponseMessage response = null; + HttpResponseMessage? response = null; await _policy.ExecuteAsync(async () => { @@ -85,6 +85,6 @@ await _policy.ExecuteAsync(async () => response.EnsureSuccessStatusCode(); }); - return response.IsSuccessStatusCode; + return response is not null && response.IsSuccessStatusCode; } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseTokenValidator.cs b/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseTokenValidator.cs index 4dc9367..a571b09 100644 --- a/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseTokenValidator.cs +++ b/JobFlow.Infrastructure/ExternalServices/Firebase/FirebaseTokenValidator.cs @@ -14,7 +14,11 @@ public class FirebaseTokenValidator : IFirebaseTokenValidator public FirebaseTokenValidator(IConfiguration config) { - _firebaseProjectId = config["Firebase:ProjectId"]; + var projectId = config["Firebase:ProjectId"]; + if (string.IsNullOrWhiteSpace(projectId)) + throw new InvalidOperationException("Firebase project id is missing."); + + _firebaseProjectId = projectId; } public async Task ValidateTokenAsync(string idToken) diff --git a/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj b/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj index 05229b2..d228318 100644 --- a/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj +++ b/JobFlow.Infrastructure/JobFlow.Infrastructure.csproj @@ -19,8 +19,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index bd4dce4..b606ebe 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -91,10 +91,18 @@ public async Task Invoke(HttpContext context, IUserService userService) }; if (decodedToken.Claims.TryGetValue("role", out var role)) - claims.Add(new Claim(ClaimTypes.Role, role.ToString())); + { + var roleValue = Convert.ToString(role); + if (!string.IsNullOrWhiteSpace(roleValue)) + claims.Add(new Claim(ClaimTypes.Role, roleValue)); + } if (decodedToken.Claims.TryGetValue("email", out var email)) - claims.Add(new Claim(ClaimTypes.Email, email.ToString())); + { + var emailValue = Convert.ToString(email); + if (!string.IsNullOrWhiteSpace(emailValue)) + claims.Add(new Claim(ClaimTypes.Email, emailValue)); + } var userResult = await userService.GetUserByFirebaseUid(decodedToken.Uid); diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeModels/StripeAccount.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeModels/StripeAccount.cs index e1bef2c..6f0d5ed 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeModels/StripeAccount.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeModels/StripeAccount.cs @@ -4,29 +4,29 @@ public class StripeAccount { public string? Country { get; set; } public string? Email { get; set; } - public AccountController Controller { get; set; } + public AccountController? Controller { get; set; } } public class AccountController { - public AccountControllerFees Fees { get; set; } - public AccountControllerLosses Loses { get; set; } - public AccountControllerStripeDashboard StripeDashboard { get; set; } + public AccountControllerFees? Fees { get; set; } + public AccountControllerLosses? Loses { get; set; } + public AccountControllerStripeDashboard? StripeDashboard { get; set; } } public class AccountControllerFees { - public string Payer { get; set; } + public string? Payer { get; set; } } public class AccountControllerLosses { - public string Payments { get; set; } + public string? Payments { get; set; } } public class AccountControllerStripeDashboard { - public string Type { get; set; } + public string? Type { get; set; } } public enum BusinessType diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 27fb30c..5a63c8b 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -168,7 +168,21 @@ public async Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionR { var customerId = request.StripeCustomerId; // If the user is subscribing for the first time, create a new Stripe customer - if (string.IsNullOrEmpty(customerId)) customerId = await CreateStripeCustomerAsync(request.Email); + if (string.IsNullOrEmpty(customerId)) + { + if (string.IsNullOrWhiteSpace(request.Email)) + throw new InvalidOperationException("Customer email is required for subscription checkout."); + + customerId = await CreateStripeCustomerAsync(request.Email); + } + + if (string.IsNullOrWhiteSpace(customerId)) + throw new InvalidOperationException("Stripe customer id is required for subscription checkout."); + + var resolvedCustomerId = request.StripeCustomerId ?? customerId + ?? throw new InvalidOperationException("Stripe customer id is required for subscription checkout."); + var ownerId = request.OrgId?.ToString() + ?? throw new InvalidOperationException("Organization id is required for subscription checkout."); var options = new SessionCreateOptions { @@ -188,16 +202,16 @@ public async Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionR { Metadata = new Dictionary { - { "ownerId", request.OrgId.ToString() }, + { "ownerId", ownerId }, { "ownerType", PaymentEntityType.Organization.ToString() }, - { "customerId", request.StripeCustomerId ?? customerId } + { "customerId", resolvedCustomerId } } }, Metadata = new Dictionary { - { "ownerId", request.OrgId.ToString() }, + { "ownerId", ownerId }, { "ownerType", PaymentEntityType.Organization.ToString() }, - { "customerId", customerId } + { "customerId", resolvedCustomerId } } }; @@ -215,6 +229,9 @@ public async Task CreateStripeCustomerAsync(string email) var service = new CustomerService(); var customer = await service.CreateAsync(options); + if (string.IsNullOrWhiteSpace(customer.Id)) + throw new InvalidOperationException("Stripe customer id was not returned."); + return customer.Id; } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index 2f21d59..f259602 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -219,7 +219,19 @@ private async Task HandleCheckoutSessionAsync(Session session) var ownerId = subscription.Metadata["ownerId"]; var ownerType = subscription.Metadata["ownerType"]; var paymentCustomerId = subscription.Metadata["customerId"]; - var planName = subscription.Items?.Data?.FirstOrDefault()?.Price.Metadata["plan-name"]; + var priceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id; + var planName = subscription.Items?.Data?.FirstOrDefault()?.Price?.Metadata?.GetValueOrDefault("plan-name"); + + if (string.IsNullOrWhiteSpace(priceId)) + { + _logger.LogWarning("Stripe subscription missing price id. SubscriptionId={SubscriptionId}", subscription.Id); + return; + } + + if (string.IsNullOrWhiteSpace(planName)) + { + planName = "Unknown"; + } var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), @@ -231,7 +243,7 @@ private async Task HandleCheckoutSessionAsync(Session session) await _subscriptionRecordService.CreateAsync( paymentProfileResult.Value.Id, subscription.Id, - subscription.Items.Data.First().Price.Id, + priceId, subscription.Status, planName ); @@ -356,7 +368,19 @@ private async Task HandleSubscriptionCreatedAsync(Subscription subscription) var ownerId = subscription.Metadata["ownerId"]; var ownerType = subscription.Metadata["ownerType"]; var paymentCustomerId = subscription.Metadata["customerId"]; - var planName = subscription.Items?.Data?.FirstOrDefault()?.Price.Metadata["plan-name"]; + var priceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id; + var planName = subscription.Items?.Data?.FirstOrDefault()?.Price?.Metadata?.GetValueOrDefault("plan-name"); + + if (string.IsNullOrWhiteSpace(priceId)) + { + _logger.LogWarning("Stripe subscription missing price id. SubscriptionId={SubscriptionId}", subscription.Id); + return; + } + + if (string.IsNullOrWhiteSpace(planName)) + { + planName = "Unknown"; + } var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), @@ -368,7 +392,7 @@ private async Task HandleSubscriptionCreatedAsync(Subscription subscription) await _subscriptionRecordService.CreateAsync( paymentProfileResult.Value.Id, subscription.Id, - subscription.Items.Data.First().Price.Id, + priceId, subscription.Status, planName ); diff --git a/JobFlow.Tests/EmployeeRolePresetServiceTests.cs b/JobFlow.Tests/EmployeeRolePresetServiceTests.cs new file mode 100644 index 0000000..da6ddfb --- /dev/null +++ b/JobFlow.Tests/EmployeeRolePresetServiceTests.cs @@ -0,0 +1,288 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JobFlow.Tests; + +public class EmployeeRolePresetServiceTests +{ + [Fact] + public async Task GetAvailablePresets_ReturnsOrgAndMatchingSystem() + { + var orgId = Guid.NewGuid(); + var otherOrgId = Guid.NewGuid(); + var unitOfWork = CreateUnitOfWork(nameof(GetAvailablePresets_ReturnsOrgAndMatchingSystem)); + await EnsureOrganizationAsync(unitOfWork, orgId, "Org One"); + await EnsureOrganizationAsync(unitOfWork, otherOrgId, "Org Two"); + await SeedPresetsAsync(unitOfWork, orgId, otherOrgId); + + var service = new EmployeeRolePresetService(NullLogger.Instance, unitOfWork); + + var result = await service.GetAvailablePresetsAsync(orgId, "home-services"); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Count()); + Assert.Contains(result.Value, preset => preset.Name == "Org Preset"); + Assert.Contains(result.Value, preset => preset.Name == "Home Services"); + Assert.DoesNotContain(result.Value, preset => preset.Name == "Creative"); + } + + [Fact] + public async Task ApplyPreset_CreatesRolesAndCounts() + { + var orgId = Guid.NewGuid(); + var unitOfWork = CreateUnitOfWork(nameof(ApplyPreset_CreatesRolesAndCounts)); + var presetId = Guid.NewGuid(); + + await EnsureOrganizationAsync(unitOfWork, orgId, "Preset Org"); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = presetId, + Name = "Starter", + IsSystem = true, + IndustryKey = "home-services" + }, + new List + { + new() + { + Id = Guid.NewGuid(), + PresetId = presetId, + Name = "Technician", + Description = "Field technician", + SortOrder = 1 + } + }); + + var service = new EmployeeRolePresetService(NullLogger.Instance, unitOfWork); + var result = await service.ApplyPresetAsync(orgId, presetId, true); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.Created); + Assert.Equal(0, result.Value.Updated); + Assert.Equal(0, result.Value.Skipped); + + var roles = await unitOfWork.RepositoryOf() + .Query() + .Where(role => role.OrganizationId == orgId) + .ToListAsync(); + + Assert.Single(roles); + Assert.Equal("TECHNICIAN", roles[0].Name); + Assert.Equal("Field technician", roles[0].Description); + } + + [Fact] + public async Task ApplyPreset_SkipsExisting_WhenOverwriteFalse() + { + var orgId = Guid.NewGuid(); + var unitOfWork = CreateUnitOfWork(nameof(ApplyPreset_SkipsExisting_WhenOverwriteFalse)); + var presetId = Guid.NewGuid(); + + await EnsureOrganizationAsync(unitOfWork, orgId, "Preset Org"); + await AddRoleAsync(unitOfWork, orgId, "TECHNICIAN", "Existing description"); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = presetId, + Name = "Starter", + IsSystem = true, + IndustryKey = "home-services" + }, + new List + { + new() + { + Id = Guid.NewGuid(), + PresetId = presetId, + Name = "Technician", + Description = "Updated description", + SortOrder = 1 + } + }); + + var service = new EmployeeRolePresetService(NullLogger.Instance, unitOfWork); + var result = await service.ApplyPresetAsync(orgId, presetId, false); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value.Created); + Assert.Equal(0, result.Value.Updated); + Assert.Equal(1, result.Value.Skipped); + + var roles = await unitOfWork.RepositoryOf() + .Query() + .Where(role => role.OrganizationId == orgId) + .ToListAsync(); + + Assert.Single(roles); + Assert.Equal("Existing description", roles[0].Description); + } + + [Fact] + public async Task ApplyPreset_UpdatesExisting_WhenOverwriteTrue() + { + var orgId = Guid.NewGuid(); + var unitOfWork = CreateUnitOfWork(nameof(ApplyPreset_UpdatesExisting_WhenOverwriteTrue)); + var presetId = Guid.NewGuid(); + + await EnsureOrganizationAsync(unitOfWork, orgId, "Preset Org"); + await AddRoleAsync(unitOfWork, orgId, "TECHNICIAN", "Existing description"); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = presetId, + Name = "Starter", + IsSystem = true, + IndustryKey = "home-services" + }, + new List + { + new() + { + Id = Guid.NewGuid(), + PresetId = presetId, + Name = "Technician", + Description = "Updated description", + SortOrder = 1 + } + }); + + var service = new EmployeeRolePresetService(NullLogger.Instance, unitOfWork); + var result = await service.ApplyPresetAsync(orgId, presetId, true); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value.Created); + Assert.Equal(1, result.Value.Updated); + Assert.Equal(0, result.Value.Skipped); + + var roles = await unitOfWork.RepositoryOf() + .Query() + .Where(role => role.OrganizationId == orgId) + .ToListAsync(); + + Assert.Single(roles); + Assert.Equal("Updated description", roles[0].Description); + } + + private static async Task SeedPresetsAsync(JobFlowUnitOfWork unitOfWork, Guid orgId, Guid otherOrgId) + { + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = Guid.NewGuid(), + Name = "Home Services", + IsSystem = true, + IndustryKey = "home-services" + }, + new List()); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = Guid.NewGuid(), + Name = "Creative", + IsSystem = true, + IndustryKey = "creative" + }, + new List()); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = Guid.NewGuid(), + Name = "Org Preset", + IsSystem = false, + OrganizationId = orgId + }, + new List()); + + await AddPresetAsync(unitOfWork, + new EmployeeRolePreset + { + Id = Guid.NewGuid(), + Name = "Other Org Preset", + IsSystem = false, + OrganizationId = otherOrgId + }, + new List()); + } + + private static async Task AddPresetAsync( + JobFlowUnitOfWork unitOfWork, + EmployeeRolePreset preset, + List items) + { + preset.IsActive = true; + for (var index = 0; index < items.Count; index += 1) + { + var item = items[index]; + item.PresetId = item.PresetId == Guid.Empty ? preset.Id : item.PresetId; + item.SortOrder = item.SortOrder == 0 ? index + 1 : item.SortOrder; + item.IsActive = true; + } + + preset.Items = items; + await unitOfWork.RepositoryOf().AddAsync(preset); + await unitOfWork.SaveChangesAsync(); + } + + private static async Task EnsureOrganizationAsync(JobFlowUnitOfWork unitOfWork, Guid organizationId, string name) + { + var organization = new Organization + { + Id = organizationId, + OrganizationTypeId = Guid.NewGuid(), + OrganizationName = name, + IsActive = true + }; + + await unitOfWork.RepositoryOf().AddAsync(organization); + await unitOfWork.SaveChangesAsync(); + } + + private static async Task AddRoleAsync(JobFlowUnitOfWork unitOfWork, Guid organizationId, string name, string? description) + { + var role = new EmployeeRole + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Name = name, + Description = description + }; + + await unitOfWork.RepositoryOf().AddAsync(role); + await unitOfWork.SaveChangesAsync(); + } + + private static JobFlowUnitOfWork CreateUnitOfWork(string databaseName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + var factory = new TestDbContextFactory(options); + return new JobFlowUnitOfWork(NullLogger.Instance, factory); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public JobFlowDbContext CreateDbContext() + { + return new JobFlowDbContext(_options); + } + } +} From cd71819d8934ca701f7a8f7f315f6a2350494279 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Mon, 23 Mar 2026 16:12:49 -0400 Subject: [PATCH 15/26] chore(touchup): UI touch up --- JobFlow.API/Controllers/AuthController.cs | 5 ++++- JobFlow.Business/Models/DTOs/OrganizationDto.cs | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/JobFlow.API/Controllers/AuthController.cs b/JobFlow.API/Controllers/AuthController.cs index 8f40c4b..2170eee 100644 --- a/JobFlow.API/Controllers/AuthController.cs +++ b/JobFlow.API/Controllers/AuthController.cs @@ -3,8 +3,10 @@ using System.Text; using FirebaseAdmin.Auth; using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Models; +using Mapster; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; @@ -58,7 +60,8 @@ public async Task LoginWithFirebase([FromBody] TokenDto model) else user = userInfo.Value; - return Ok(new { user.Organization }); + var organizationDto = user.Organization?.Adapt(); + return Ok(new { organization = organizationDto }); } catch (Exception ex) { diff --git a/JobFlow.Business/Models/DTOs/OrganizationDto.cs b/JobFlow.Business/Models/DTOs/OrganizationDto.cs index 086df56..eec4b1b 100644 --- a/JobFlow.Business/Models/DTOs/OrganizationDto.cs +++ b/JobFlow.Business/Models/DTOs/OrganizationDto.cs @@ -1,4 +1,6 @@ -namespace JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; public class OrganizationClientDto @@ -38,4 +40,6 @@ public class OrganizationDto public bool CanAcceptPayments { get; set; } public string? SubscriptionPlanName { get; set; } public string? IndustryKey { get; set; } + public PaymentProvider PaymentProvider { get; set; } + public string? StripeConnectAccountId { get; set; } } \ No newline at end of file From 0d7df3423ee31ed003550aebc07c0fa659546a2d Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Mon, 23 Mar 2026 21:26:32 -0400 Subject: [PATCH 16/26] feat(support): Initial Implementation for the Support Hub --- .../Controllers/SupportHubController.cs | 201 +++++++++++++ .../Extensions/HttpContextExtensions.cs | 17 ++ .../Models/DTOs/SupportHubDtos.cs | 56 ++++ .../ISupportHubInviteService.cs | 11 + .../ServiceInterfaces/ISupportHubService.cs | 13 + .../Services/SupportHubInviteService.cs | 169 +++++++++++ .../Services/SupportHubService.cs | 284 ++++++++++++++++++ JobFlow.Domain/Enums/SupportHubInviteRole.cs | 7 + .../Enums/SupportHubSessionStatus.cs | 9 + .../Enums/SupportHubTicketStatus.cs | 10 + JobFlow.Domain/Models/SupportHubInvite.cs | 12 + JobFlow.Domain/Models/SupportHubSession.cs | 14 + JobFlow.Domain/Models/SupportHubTicket.cs | 14 + .../SupportHubInviteConfiguration.cs | 21 ++ .../SupportHubSessionConfiguration.cs | 25 ++ .../SupportHubTicketConfiguration.cs | 26 ++ .../JobFlowDbContext.cs | 3 + .../20260323235900_AddSupportHubTables.cs | 126 ++++++++ .../JobFlowDbContextModelSnapshot.cs | 169 +++++++++++ .../Middleware/FirebaseAuthMiddleware.cs | 23 +- 20 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 JobFlow.API/Controllers/SupportHubController.cs create mode 100644 JobFlow.Business/Models/DTOs/SupportHubDtos.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs create mode 100644 JobFlow.Business/Services/SupportHubInviteService.cs create mode 100644 JobFlow.Business/Services/SupportHubService.cs create mode 100644 JobFlow.Domain/Enums/SupportHubInviteRole.cs create mode 100644 JobFlow.Domain/Enums/SupportHubSessionStatus.cs create mode 100644 JobFlow.Domain/Enums/SupportHubTicketStatus.cs create mode 100644 JobFlow.Domain/Models/SupportHubInvite.cs create mode 100644 JobFlow.Domain/Models/SupportHubSession.cs create mode 100644 JobFlow.Domain/Models/SupportHubTicket.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs diff --git a/JobFlow.API/Controllers/SupportHubController.cs b/JobFlow.API/Controllers/SupportHubController.cs new file mode 100644 index 0000000..1989026 --- /dev/null +++ b/JobFlow.API/Controllers/SupportHubController.cs @@ -0,0 +1,201 @@ +using FirebaseAdmin.Auth; +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/supporthub")] +public class SupportHubController : ControllerBase +{ + private readonly ISupportHubInviteService _inviteService; + private readonly ISupportHubService _supportHubService; + private readonly IUserService _userService; + private readonly IOrganizationService _organizationService; + + public SupportHubController( + ISupportHubService supportHubService, + ISupportHubInviteService inviteService, + IUserService userService, + IOrganizationService organizationService) + { + _supportHubService = supportHubService; + _inviteService = inviteService; + _userService = userService; + _organizationService = organizationService; + } + + [HttpPost("register")] + [Authorize] + public async Task RegisterSupportHubUser() + { + var firebaseUid = HttpContext.GetFirebaseUid(); + if (string.IsNullOrWhiteSpace(firebaseUid)) + { + return Results.Unauthorized(); + } + + var orgResult = await _organizationService.GetAllOrganizations(); + if (orgResult.IsFailure) + { + return orgResult.ToProblemDetails(); + } + + var masterOrg = orgResult.Value + .FirstOrDefault(o => o.OrganizationType?.TypeName == "Master Account"); + if (masterOrg == null) + { + return Results.Problem("Master account organization not found."); + } + + var userResult = await _userService.GetUserByFirebaseUid(firebaseUid); + if (userResult.IsFailure) + { + var email = HttpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; + var newUser = new User + { + Email = email, + FirebaseUid = firebaseUid, + OrganizationId = masterOrg.Id + }; + + var createResult = await _userService.UpsertUser(newUser); + if (createResult.IsFailure) + { + return createResult.ToProblemDetails(); + } + + await _userService.AssignRole(createResult.Value.Id, UserRoles.KatharixEmployee); + } + else + { + var existingUser = userResult.Value; + if (existingUser.OrganizationId == Guid.Empty) + { + existingUser.OrganizationId = masterOrg.Id; + var updateResult = await _userService.UpsertUser(existingUser); + if (updateResult.IsFailure) + { + return updateResult.ToProblemDetails(); + } + } + + await _userService.AssignRole(existingUser.Id, UserRoles.KatharixEmployee); + } + + await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync( + firebaseUid, + new Dictionary + { + { "role", UserRoles.KatharixEmployee } + }); + + return Results.Ok(); + } + + [HttpGet("tickets")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task GetTickets() + { + var result = await _supportHubService.GetTicketsAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("sessions")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task GetSessions() + { + var result = await _supportHubService.GetSessionsAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("sessions/{sessionId:guid}/screen")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task StartScreenView([FromRoute] Guid sessionId) + { + var result = await _supportHubService.CreateScreenViewAsync(sessionId); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("tickets")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task CreateTicket([FromBody] SupportHubTicketCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.CreateTicketAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("sessions")] + [Authorize(Roles = $"{UserRoles.KatharixAdmin},{UserRoles.KatharixEmployee}")] + public async Task CreateSession([FromBody] SupportHubSessionCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.CreateSessionAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("seed")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task SeedDemo([FromBody] SupportHubSeedRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _supportHubService.SeedDemoAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("invites")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task GetInvites() + { + var result = await _inviteService.GetActiveInvitesAsync(); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("invites")] + [Authorize(Roles = UserRoles.KatharixAdmin)] + public async Task CreateInvite([FromBody] SupportHubInviteCreateRequest request) + { + var createdBy = HttpContext.GetFirebaseUid(); + var result = await _inviteService.CreateInviteAsync(request, createdBy); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpGet("invites/validate/{code}")] + [AllowAnonymous] + public async Task ValidateInvite([FromRoute] string code) + { + var result = await _inviteService.ValidateInviteAsync(code); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [HttpPost("invites/redeem")] + [Authorize] + public async Task RedeemInvite([FromBody] SupportHubInviteRedeemRequest request) + { + var firebaseUid = HttpContext.GetFirebaseUid(); + var result = await _inviteService.RedeemInviteAsync(request.Code, firebaseUid); + if (result.IsFailure) + { + return result.ToProblemDetails(); + } + + if (!string.IsNullOrWhiteSpace(firebaseUid)) + { + await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync( + firebaseUid, + new Dictionary + { + { "role", result.Value.Role.ToString() } + }); + } + + return Results.Ok(result.Value); + } +} diff --git a/JobFlow.API/Extensions/HttpContextExtensions.cs b/JobFlow.API/Extensions/HttpContextExtensions.cs index fdd01ab..f0637f5 100644 --- a/JobFlow.API/Extensions/HttpContextExtensions.cs +++ b/JobFlow.API/Extensions/HttpContextExtensions.cs @@ -23,4 +23,21 @@ public static Guid GetUserId(this HttpContext context) return userId; } + + public static string? GetFirebaseUid(this HttpContext context) + { + var uid = context.User.FindFirst("user_id")?.Value; + if (!string.IsNullOrWhiteSpace(uid)) + { + return uid; + } + + uid = context.User.FindFirst("sub")?.Value; + if (!string.IsNullOrWhiteSpace(uid)) + { + return uid; + } + + return context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/SupportHubDtos.cs b/JobFlow.Business/Models/DTOs/SupportHubDtos.cs new file mode 100644 index 0000000..a62df66 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/SupportHubDtos.cs @@ -0,0 +1,56 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public record SupportHubTicketDto( + Guid Id, + string Title, + SupportHubTicketStatus Status, + string OrganizationName, + DateTimeOffset CreatedAt); + +public record SupportHubSessionDto( + Guid Id, + string OrganizationName, + string AgentName, + SupportHubSessionStatus Status, + DateTimeOffset? StartedAt); + +public record SupportHubScreenResponseDto( + Guid SessionId, + string ViewerUrl); + +public record SupportHubTicketCreateRequest( + Guid OrganizationId, + string Title, + string? Summary, + SupportHubTicketStatus Status); + +public record SupportHubSessionCreateRequest( + Guid OrganizationId, + string AgentName, + SupportHubSessionStatus Status); + +public record SupportHubSeedRequest(Guid OrganizationId); + +public record SupportHubSeedResponse(int TicketsCreated, int SessionsCreated); + +public record SupportHubInviteDto( + Guid Id, + string Code, + SupportHubInviteRole Role, + DateTimeOffset CreatedAt, + string? CreatedBy, + DateTimeOffset ExpiresAt, + DateTimeOffset? RedeemedAt, + string? RedeemedBy); + +public record SupportHubInviteCreateRequest( + SupportHubInviteRole Role, + DateTimeOffset? ExpiresAt); + +public record SupportHubInviteRedeemRequest(string Code); + +public record SupportHubInviteValidationDto( + SupportHubInviteDto? Invite, + string? Error); diff --git a/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs new file mode 100644 index 0000000..1e82f4f --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubInviteService.cs @@ -0,0 +1,11 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface ISupportHubInviteService +{ + Task> CreateInviteAsync(SupportHubInviteCreateRequest request, string? createdBy); + Task>> GetActiveInvitesAsync(); + Task> ValidateInviteAsync(string code); + Task> RedeemInviteAsync(string code, string? redeemedBy); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs new file mode 100644 index 0000000..6ee4c60 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/ISupportHubService.cs @@ -0,0 +1,13 @@ +using JobFlow.Business.Models.DTOs; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface ISupportHubService +{ + Task>> GetTicketsAsync(); + Task>> GetSessionsAsync(); + Task> CreateScreenViewAsync(Guid sessionId); + Task> CreateTicketAsync(SupportHubTicketCreateRequest request, string? createdBy); + Task> CreateSessionAsync(SupportHubSessionCreateRequest request, string? createdBy); + Task> SeedDemoAsync(SupportHubSeedRequest request, string? createdBy); +} diff --git a/JobFlow.Business/Services/SupportHubInviteService.cs b/JobFlow.Business/Services/SupportHubInviteService.cs new file mode 100644 index 0000000..5fc60d5 --- /dev/null +++ b/JobFlow.Business/Services/SupportHubInviteService.cs @@ -0,0 +1,169 @@ +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class SupportHubInviteService : ISupportHubInviteService +{ + private const int MaxCodeAttempts = 5; + private readonly IRepository _invites; + private readonly IUnitOfWork _unitOfWork; + + public SupportHubInviteService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + _invites = unitOfWork.RepositoryOf(); + } + + public async Task> CreateInviteAsync( + SupportHubInviteCreateRequest request, + string? createdBy) + { + var code = await GenerateUniqueCodeAsync(); + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Failure( + Error.Failure("SupportHub.InviteGenerationFailed", "Unable to generate invite code.")); + } + + var invite = new SupportHubInvite + { + Id = Guid.NewGuid(), + Code = code, + Role = request.Role, + CreatedBy = createdBy, + ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddDays(7), + }; + + await _invites.AddAsync(invite); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(invite)); + } + + public async Task>> GetActiveInvitesAsync() + { + var invites = await _invites.Query() + .AsNoTracking() + .Where(x => x.RedeemedAt == null && x.ExpiresAt > DateTimeOffset.UtcNow) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + + var results = invites.Select(ToDto).ToList(); + return Result.Success(results); + } + + public async Task> ValidateInviteAsync(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code is required.")); + } + + var normalized = code.Trim().ToUpperInvariant(); + var invite = await _invites.Query() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Code == normalized); + + if (invite is null) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code not found.")); + } + + if (invite.RedeemedAt.HasValue) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code already redeemed.")); + } + + if (invite.ExpiresAt <= DateTimeOffset.UtcNow) + { + return Result.Success(new SupportHubInviteValidationDto(null, "Invite code expired.")); + } + + return Result.Success(new SupportHubInviteValidationDto(ToDto(invite), null)); + } + + public async Task> RedeemInviteAsync(string code, string? redeemedBy) + { + if (string.IsNullOrWhiteSpace(code)) + { + return Result.Failure( + Error.Validation("SupportHub.InviteRequired", "Invite code is required.")); + } + + var normalized = code.Trim().ToUpperInvariant(); + var invite = await _invites.Query().FirstOrDefaultAsync(x => x.Code == normalized); + if (invite is null) + { + return Result.Failure( + Error.NotFound("SupportHub.InviteNotFound", "Invite code not found.")); + } + + if (invite.RedeemedAt.HasValue) + { + return Result.Failure( + Error.Conflict("SupportHub.InviteAlreadyRedeemed", "Invite code already redeemed.")); + } + + if (invite.ExpiresAt <= DateTimeOffset.UtcNow) + { + return Result.Failure( + Error.Validation("SupportHub.InviteExpired", "Invite code expired.")); + } + + invite.RedeemedAt = DateTimeOffset.UtcNow; + invite.RedeemedByUid = redeemedBy; + + _invites.Update(invite); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(ToDto(invite)); + } + + private async Task GenerateUniqueCodeAsync() + { + for (var attempt = 0; attempt < MaxCodeAttempts; attempt += 1) + { + var code = GenerateCode(); + var exists = await _invites.ExistsAsync(x => x.Code == code); + if (!exists) + { + return code; + } + } + + return null; + } + + private static string GenerateCode() + { + const string alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + Span chars = stackalloc char[8]; + var random = new Random(); + for (var i = 0; i < chars.Length; i += 1) + { + chars[i] = alphabet[random.Next(alphabet.Length)]; + } + + return new string(chars); + } + + private static SupportHubInviteDto ToDto(SupportHubInvite invite) + { + return new SupportHubInviteDto( + invite.Id, + invite.Code, + invite.Role, + invite.CreatedAt, + invite.CreatedBy, + invite.ExpiresAt, + invite.RedeemedAt, + invite.RedeemedByUid); + } +} diff --git a/JobFlow.Business/Services/SupportHubService.cs b/JobFlow.Business/Services/SupportHubService.cs new file mode 100644 index 0000000..90f1fd1 --- /dev/null +++ b/JobFlow.Business/Services/SupportHubService.cs @@ -0,0 +1,284 @@ +using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; +using JobFlow.Business.DI; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class SupportHubService : ISupportHubService +{ + private readonly IRepository _tickets; + private readonly IRepository _sessions; + private readonly IRepository _organizations; + private readonly IFrontendSettings _frontendSettings; + private readonly IUnitOfWork _unitOfWork; + + public SupportHubService( + IUnitOfWork unitOfWork, + IFrontendSettings frontendSettings) + { + _unitOfWork = unitOfWork; + _frontendSettings = frontendSettings; + _tickets = unitOfWork.RepositoryOf(); + _sessions = unitOfWork.RepositoryOf(); + _organizations = unitOfWork.RepositoryOf(); + } + + public async Task>> GetTicketsAsync() + { + var tickets = await _tickets.Query() + .AsNoTracking() + .Include(x => x.Organization) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new SupportHubTicketDto( + x.Id, + x.Title, + x.Status, + x.Organization != null ? x.Organization.OrganizationName ?? "Unknown" : "Unknown", + new DateTimeOffset(x.CreatedAt, TimeSpan.Zero))) + .ToListAsync(); + + return Result.Success(tickets); + } + + public async Task>> GetSessionsAsync() + { + var sessions = await _sessions.Query() + .AsNoTracking() + .Include(x => x.Organization) + .OrderByDescending(x => x.StartedAt ?? DateTimeOffset.MinValue) + .Select(x => new SupportHubSessionDto( + x.Id, + x.Organization != null ? x.Organization.OrganizationName ?? "Unknown" : "Unknown", + x.AgentName, + x.Status, + x.StartedAt)) + .ToListAsync(); + + return Result.Success(sessions); + } + + public async Task> CreateScreenViewAsync(Guid sessionId) + { + var session = await _sessions.Query().AsNoTracking().FirstOrDefaultAsync(x => x.Id == sessionId); + if (session is null) + { + return Result.Failure( + Error.NotFound("SupportHub.SessionNotFound", "Support session not found.")); + } + + var baseUrl = _frontendSettings.BaseUrl?.TrimEnd('/') ?? string.Empty; + var viewerUrl = string.IsNullOrWhiteSpace(baseUrl) + ? $"/support-hub/sessions/{sessionId}" + : $"{baseUrl}/support-hub/sessions/{sessionId}"; + + var response = new SupportHubScreenResponseDto(sessionId, viewerUrl); + return Result.Success(response); + } + + public async Task> CreateTicketAsync( + SupportHubTicketCreateRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + return Result.Failure( + Error.Validation("SupportHub.TitleRequired", "Ticket title is required.")); + } + + if (request.Title.Length > 160) + { + return Result.Failure( + Error.Validation("SupportHub.TitleTooLong", "Ticket title is too long.")); + } + + if (!string.IsNullOrWhiteSpace(request.Summary) && request.Summary.Length > 500) + { + return Result.Failure( + Error.Validation("SupportHub.SummaryTooLong", "Ticket summary is too long.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var ticket = new SupportHubTicket + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = request.Title.Trim(), + Summary = request.Summary?.Trim(), + Status = request.Status, + LastActivityAt = DateTimeOffset.UtcNow, + CreatedBy = createdBy + }; + + await _tickets.AddAsync(ticket); + await _unitOfWork.SaveChangesAsync(); + + var orgName = await _organizations.Query() + .Where(x => x.Id == request.OrganizationId) + .Select(x => x.OrganizationName) + .FirstOrDefaultAsync(); + + var dto = new SupportHubTicketDto( + ticket.Id, + ticket.Title, + ticket.Status, + orgName ?? "Unknown", + new DateTimeOffset(ticket.CreatedAt, TimeSpan.Zero)); + + return Result.Success(dto); + } + + public async Task> CreateSessionAsync( + SupportHubSessionCreateRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + if (string.IsNullOrWhiteSpace(request.AgentName)) + { + return Result.Failure( + Error.Validation("SupportHub.AgentRequired", "Agent name is required.")); + } + + if (request.AgentName.Length > 120) + { + return Result.Failure( + Error.Validation("SupportHub.AgentTooLong", "Agent name is too long.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var session = new SupportHubSession + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = request.AgentName.Trim(), + Status = request.Status, + StartedAt = request.Status == Domain.Enums.SupportHubSessionStatus.Live + ? DateTimeOffset.UtcNow + : null, + CreatedBy = createdBy + }; + + await _sessions.AddAsync(session); + await _unitOfWork.SaveChangesAsync(); + + var orgName = await _organizations.Query() + .Where(x => x.Id == request.OrganizationId) + .Select(x => x.OrganizationName) + .FirstOrDefaultAsync(); + + var dto = new SupportHubSessionDto( + session.Id, + orgName ?? "Unknown", + session.AgentName, + session.Status, + session.StartedAt); + + return Result.Success(dto); + } + + public async Task> SeedDemoAsync( + SupportHubSeedRequest request, + string? createdBy) + { + if (request.OrganizationId == Guid.Empty) + { + return Result.Failure( + Error.Validation("SupportHub.OrganizationRequired", "Organization is required.")); + } + + var orgExists = await _organizations.ExistsAsync(x => x.Id == request.OrganizationId); + if (!orgExists) + { + return Result.Failure( + Error.NotFound("SupportHub.OrganizationNotFound", "Organization not found.")); + } + + var tickets = new List + { + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Invoice payment failed", + Summary = "Customer card failed on invoice payment.", + Status = Domain.Enums.SupportHubTicketStatus.Urgent, + LastActivityAt = DateTimeOffset.UtcNow, + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Crew schedule not saving", + Summary = "Dispatch cannot persist schedule edits.", + Status = Domain.Enums.SupportHubTicketStatus.High, + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-45), + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + Title = "Branding logo upload error", + Summary = "Admin sees error when updating logo.", + Status = Domain.Enums.SupportHubTicketStatus.Normal, + LastActivityAt = DateTimeOffset.UtcNow.AddHours(-2), + CreatedBy = createdBy + } + }; + + var sessions = new List + { + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = "Support Agent", + Status = Domain.Enums.SupportHubSessionStatus.Live, + StartedAt = DateTimeOffset.UtcNow.AddMinutes(-12), + CreatedBy = createdBy + }, + new() + { + Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, + AgentName = "Support Agent", + Status = Domain.Enums.SupportHubSessionStatus.Queued, + CreatedBy = createdBy + } + }; + + await _tickets.AddRangeAsync(tickets); + await _sessions.AddRangeAsync(sessions); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(new SupportHubSeedResponse(tickets.Count, sessions.Count)); + } +} diff --git a/JobFlow.Domain/Enums/SupportHubInviteRole.cs b/JobFlow.Domain/Enums/SupportHubInviteRole.cs new file mode 100644 index 0000000..1cf05e6 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubInviteRole.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubInviteRole +{ + KatharixAdmin = 0, + KatharixEmployee = 1 +} diff --git a/JobFlow.Domain/Enums/SupportHubSessionStatus.cs b/JobFlow.Domain/Enums/SupportHubSessionStatus.cs new file mode 100644 index 0000000..6218509 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubSessionStatus.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubSessionStatus +{ + Live = 0, + Queued = 1, + FollowUp = 2, + Ended = 3 +} diff --git a/JobFlow.Domain/Enums/SupportHubTicketStatus.cs b/JobFlow.Domain/Enums/SupportHubTicketStatus.cs new file mode 100644 index 0000000..2466fd7 --- /dev/null +++ b/JobFlow.Domain/Enums/SupportHubTicketStatus.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Enums; + +public enum SupportHubTicketStatus +{ + Urgent = 0, + High = 1, + Normal = 2, + Low = 3, + Resolved = 4 +} diff --git a/JobFlow.Domain/Models/SupportHubInvite.cs b/JobFlow.Domain/Models/SupportHubInvite.cs new file mode 100644 index 0000000..b56fbe6 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubInvite.cs @@ -0,0 +1,12 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubInvite : Entity +{ + public string Code { get; set; } = string.Empty; + public SupportHubInviteRole Role { get; set; } = SupportHubInviteRole.KatharixEmployee; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RedeemedAt { get; set; } + public string? RedeemedByUid { get; set; } +} diff --git a/JobFlow.Domain/Models/SupportHubSession.cs b/JobFlow.Domain/Models/SupportHubSession.cs new file mode 100644 index 0000000..9dfbea5 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubSession.cs @@ -0,0 +1,14 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubSession : Entity +{ + public Guid OrganizationId { get; set; } + public string AgentName { get; set; } = string.Empty; + public SupportHubSessionStatus Status { get; set; } = SupportHubSessionStatus.Queued; + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? EndedAt { get; set; } + + public Organization? Organization { get; set; } +} diff --git a/JobFlow.Domain/Models/SupportHubTicket.cs b/JobFlow.Domain/Models/SupportHubTicket.cs new file mode 100644 index 0000000..89554e8 --- /dev/null +++ b/JobFlow.Domain/Models/SupportHubTicket.cs @@ -0,0 +1,14 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class SupportHubTicket : Entity +{ + public Guid OrganizationId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + public SupportHubTicketStatus Status { get; set; } = SupportHubTicketStatus.Normal; + public DateTimeOffset? LastActivityAt { get; set; } + + public Organization? Organization { get; set; } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs new file mode 100644 index 0000000..f001b49 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubInviteConfiguration.cs @@ -0,0 +1,21 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubInviteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Code).HasMaxLength(12).IsRequired(); + builder.Property(x => x.Role).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.RedeemedByUid).HasMaxLength(128); + + builder.HasIndex(x => x.Code).IsUnique(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs new file mode 100644 index 0000000..1bbd74f --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubSessionConfiguration.cs @@ -0,0 +1,25 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.OrganizationId).IsRequired(); + builder.Property(x => x.AgentName).HasMaxLength(120).IsRequired(); + builder.Property(x => x.Status).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.OrganizationId); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs new file mode 100644 index 0000000..506a978 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/SupportHubTicketConfiguration.cs @@ -0,0 +1,26 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class SupportHubTicketConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.OrganizationId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(160).IsRequired(); + builder.Property(x => x.Summary).HasMaxLength(500); + builder.Property(x => x.Status).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.OrganizationId); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 95ce179..d8b2196 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -22,6 +22,9 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet OrganizationTypes { get; set; } public DbSet EmployeeRolePresets { get; set; } public DbSet EmployeeRolePresetItems { get; set; } + public DbSet SupportHubTickets { get; set; } + public DbSet SupportHubSessions { get; set; } + public DbSet SupportHubInvites { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs new file mode 100644 index 0000000..ca8d8f4 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260323235900_AddSupportHubTables.cs @@ -0,0 +1,126 @@ +using System; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(JobFlowDbContext))] + [Migration("20260323235900_AddSupportHubTables")] + public partial class AddSupportHubTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SupportHubInvites", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(12)", maxLength: 12, nullable: false), + Role = table.Column(type: "int", nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: false), + RedeemedAt = table.Column(type: "datetimeoffset", nullable: true), + RedeemedByUid = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubInvites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SupportHubSessions", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + AgentName = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Status = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetimeoffset", nullable: true), + EndedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubSessions", x => x.Id); + table.ForeignKey( + name: "FK_SupportHubSessions_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SupportHubTickets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: false), + Summary = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "int", nullable: false), + LastActivityAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SupportHubTickets", x => x.Id); + table.ForeignKey( + name: "FK_SupportHubTickets_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubInvites_Code", + table: "SupportHubInvites", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubSessions_OrganizationId", + table: "SupportHubSessions", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_SupportHubTickets_OrganizationId", + table: "SupportHubTickets", + column: "OrganizationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SupportHubInvites"); + + migrationBuilder.DropTable( + name: "SupportHubSessions"); + + migrationBuilder.DropTable( + name: "SupportHubTickets"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 42f15a5..2b2f34e 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -2345,6 +2345,153 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PriceBookItems", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => { b.Property("Id") @@ -2896,6 +3043,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => { b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index b606ebe..929033f 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using FirebaseAdmin.Auth; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -90,9 +91,10 @@ public async Task Invoke(HttpContext context, IUserService userService) new Claim(ClaimTypes.NameIdentifier, decodedToken.Uid) }; + var roleValue = string.Empty; if (decodedToken.Claims.TryGetValue("role", out var role)) { - var roleValue = Convert.ToString(role); + roleValue = Convert.ToString(role) ?? string.Empty; if (!string.IsNullOrWhiteSpace(roleValue)) claims.Add(new Claim(ClaimTypes.Role, roleValue)); } @@ -104,6 +106,25 @@ public async Task Invoke(HttpContext context, IUserService userService) claims.Add(new Claim(ClaimTypes.Email, emailValue)); } + var isSupportHubUser = string.Equals(roleValue, UserRoles.KatharixAdmin, StringComparison.Ordinal) + || string.Equals(roleValue, UserRoles.KatharixEmployee, StringComparison.Ordinal); + + if (path is not null && (path.StartsWith("/api/supporthub/invites/redeem") || path.StartsWith("/api/supporthub/register"))) + { + var redeemIdentity = new ClaimsIdentity(claims, "Firebase"); + context.User.AddIdentity(redeemIdentity); + await _next(context); + return; + } + + if (isSupportHubUser) + { + var supportIdentity = new ClaimsIdentity(claims, "Firebase"); + context.User.AddIdentity(supportIdentity); + await _next(context); + return; + } + var userResult = await userService.GetUserByFirebaseUid(decodedToken.Uid); if (!userResult.IsSuccess) From 2a12444e4b787bf08b02c9e6fb79a8ce570d2e87 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Tue, 24 Mar 2026 09:09:00 -0400 Subject: [PATCH 17/26] feat(language): Implement Language Translation --- JobFlow.API/Controllers/ChatController.cs | 170 +- .../OrganizationClientController.cs | 12 +- JobFlow.API/Controllers/UserController.cs | 28 +- JobFlow.API/Mappings/MapsterConfig.cs | 4 + .../OrganizationBrandingMappingExtension.cs | 2 + JobFlow.API/Models/BrandingDto.cs | 1 + .../Models/DTOs/UserProfileDto.cs | 9 + .../Models/DTOs/UserProfileUpdateRequest.cs | 8 + .../Services/OrganizationBrandingService.cs | 2 +- .../ServiceInterfaces/IUserService.cs | 3 + JobFlow.Business/Services/UserService.cs | 46 + JobFlow.Domain/Models/User.cs | 1 + .../OrganizationBrandingConfiguration.cs | 22 + .../Configurations/UserConfiguration.cs | 2 + .../JobFlowDbContext.cs | 1 + ...238_AddPreferredLanguageToUser.Designer.cs | 3249 ++++++++++++++++ ...260324022238_AddPreferredLanguageToUser.cs | 29 + ...041623_AddOrganizationBranding.Designer.cs | 3313 +++++++++++++++++ .../20260324041623_AddOrganizationBranding.cs | 58 + .../JobFlowDbContextModelSnapshot.cs | 248 +- 20 files changed, 7096 insertions(+), 112 deletions(-) create mode 100644 JobFlow.Business/Models/DTOs/UserProfileDto.cs create mode 100644 JobFlow.Business/Models/DTOs/UserProfileUpdateRequest.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/OrganizationBrandingConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.cs diff --git a/JobFlow.API/Controllers/ChatController.cs b/JobFlow.API/Controllers/ChatController.cs index bd3f654..a7e2112 100644 --- a/JobFlow.API/Controllers/ChatController.cs +++ b/JobFlow.API/Controllers/ChatController.cs @@ -43,13 +43,83 @@ public async Task GetConversations() if (currentUser is null) return firebaseUidResult ?? Unauthorized(); + var isOrganizationMember = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(u => u.Id == currentUser.Id && u.OrganizationId == organizationId); + + var orgUserIds = isOrganizationMember + ? await _unitOfWork.RepositoryOf() + .Query() + .Where(u => u.OrganizationId == organizationId) + .Select(u => u.Id) + .Distinct() + .ToListAsync() + : new List(); + + var orgClientConversationIds = isOrganizationMember + ? await _unitOfWork.RepositoryOf() + .Query() + .Where(c => c.OrganizationClientId.HasValue + && c.OrganizationClient != null + && c.OrganizationClient.OrganizationId == organizationId) + .Select(c => c.Id) + .ToListAsync() + : new List(); + + var legacyClientInitiatedConversationIds = isOrganizationMember + ? await _unitOfWork.RepositoryOf() + .Query() + .Where(c => !c.OrganizationClientId.HasValue + && c.Messages.Any(m => m.ExternalSenderType == "client" + || (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName))) + && c.Participants.Any(p => orgUserIds.Contains(p.UserId))) + .Select(c => c.Id) + .ToListAsync() + : new List(); + var conversations = await _unitOfWork.RepositoryOf() .Query() .Include(c => c.Participants) .Include(c => c.Messages) - .Where(c => c.Participants.Any(p => p.UserId == currentUser.Id)) + .Where(c => c.Participants.Any(p => p.UserId == currentUser.Id) + || orgClientConversationIds.Contains(c.Id) + || legacyClientInitiatedConversationIds.Contains(c.Id)) .ToListAsync(); + if (isOrganizationMember) + { + var missingParticipantConversationIds = conversations + .Where(c => (c.OrganizationClientId.HasValue + || c.Messages.Any(m => m.ExternalSenderType == "client" + || (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName)))) + && !c.Participants.Any(p => p.UserId == currentUser.Id)) + .Select(c => c.Id) + .ToList(); + + if (missingParticipantConversationIds.Count > 0) + { + foreach (var conversationId in missingParticipantConversationIds) + { + await _unitOfWork.RepositoryOf().AddAsync(new ConversationParticipant + { + ConversationId = conversationId, + UserId = currentUser.Id + }); + } + + await _unitOfWork.SaveChangesAsync(); + + conversations = await _unitOfWork.RepositoryOf() + .Query() + .Include(c => c.Participants) + .Include(c => c.Messages) + .Where(c => c.Participants.Any(p => p.UserId == currentUser.Id) + || orgClientConversationIds.Contains(c.Id) + || legacyClientInitiatedConversationIds.Contains(c.Id)) + .ToListAsync(); + } + } + var participantIds = conversations .SelectMany(c => c.Participants) .Select(p => p.UserId) @@ -102,11 +172,8 @@ public async Task GetMessages( if (pageSize < 1) pageSize = 50; if (pageSize > 200) pageSize = 200; - var isParticipant = await _unitOfWork.RepositoryOf() - .Query() - .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); - - if (!isParticipant) + var canAccessConversation = await EnsureConversationAccessAsync(conversationId, currentUser.Id, organizationId); + if (!canAccessConversation) return Forbid(); var messageQuery = _unitOfWork.RepositoryOf() @@ -157,11 +224,8 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r if (string.IsNullOrWhiteSpace(request.Content) && string.IsNullOrWhiteSpace(request.AttachmentUrl)) return BadRequest("Message content or attachment is required."); - var isParticipant = await _unitOfWork.RepositoryOf() - .Query() - .AnyAsync(p => p.ConversationId == request.ConversationId && p.UserId == currentUser.Id); - - if (!isParticipant) + var canAccessConversation = await EnsureConversationAccessAsync(request.ConversationId, currentUser.Id, organizationId); + if (!canAccessConversation) return Forbid(); var message = new Message @@ -199,15 +263,12 @@ public async Task CreateMessage([FromBody] CreateMessageRequest r [HttpPost("conversations/{conversationId:guid}/read")] public async Task MarkConversationRead(Guid conversationId) { - var (currentUser, _, firebaseUidResult) = await ResolveCurrentUserAsync(); + var (currentUser, organizationId, firebaseUidResult) = await ResolveCurrentUserAsync(); if (currentUser is null) return firebaseUidResult ?? Unauthorized(); - var isParticipant = await _unitOfWork.RepositoryOf() - .Query() - .AnyAsync(p => p.ConversationId == conversationId && p.UserId == currentUser.Id); - - if (!isParticipant) + var canAccessConversation = await EnsureConversationAccessAsync(conversationId, currentUser.Id, organizationId); + if (!canAccessConversation) return Forbid(); var messages = await _unitOfWork.RepositoryOf() @@ -517,6 +578,81 @@ private static (string? name, string? role, string? avatarUrl) ResolveParticipan return (userResult.Value, organizationId, null); } + private async Task EnsureConversationAccessAsync(Guid conversationId, Guid userId, Guid organizationId) + { + var participantExists = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && p.UserId == userId); + + if (participantExists) + return true; + + var conversation = await _unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(c => c.Id == conversationId); + + if (conversation is null) + return false; + + if (!conversation.OrganizationClientId.HasValue) + { + var orgUserIds = await _unitOfWork.RepositoryOf() + .Query() + .Where(u => u.OrganizationId == organizationId) + .Select(u => u.Id) + .Distinct() + .ToListAsync(); + + var isLegacyClientInitiated = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(m => m.ConversationId == conversationId + && (m.ExternalSenderType == "client" + || (!m.SenderId.HasValue && !string.IsNullOrWhiteSpace(m.ExternalSenderName)))); + + if (!isLegacyClientInitiated) + return false; + + var hasOrgParticipant = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(p => p.ConversationId == conversationId && orgUserIds.Contains(p.UserId)); + + if (!hasOrgParticipant) + return false; + + await _unitOfWork.RepositoryOf().AddAsync(new ConversationParticipant + { + ConversationId = conversationId, + UserId = userId + }); + await _unitOfWork.SaveChangesAsync(); + + return true; + } + + var isOrganizationMember = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(u => u.Id == userId && u.OrganizationId == organizationId); + + if (!isOrganizationMember) + return false; + + var belongsToOrganization = await _unitOfWork.RepositoryOf() + .Query() + .AnyAsync(c => c.Id == conversation.OrganizationClientId.Value && c.OrganizationId == organizationId); + + if (!belongsToOrganization) + return false; + + await _unitOfWork.RepositoryOf().AddAsync(new ConversationParticipant + { + ConversationId = conversationId, + UserId = userId + }); + await _unitOfWork.SaveChangesAsync(); + + return true; + } + private async Task SendToClientHubAsync(Guid conversationId, Message message) { var conversation = await _unitOfWork.RepositoryOf() diff --git a/JobFlow.API/Controllers/OrganizationClientController.cs b/JobFlow.API/Controllers/OrganizationClientController.cs index 6c409be..65b1312 100644 --- a/JobFlow.API/Controllers/OrganizationClientController.cs +++ b/JobFlow.API/Controllers/OrganizationClientController.cs @@ -1,5 +1,6 @@ using JobFlow.API.Extensions; using JobFlow.API.Mappings; +using JobFlow.Business; using JobFlow.API.Models; using JobFlow.Business.Extensions; using JobFlow.Business.Models.DTOs; @@ -64,14 +65,19 @@ public async Task UpsertClient( if (organizationId == Guid.Empty) return Results.BadRequest("OrganizationId is required."); + model.Organization = null; model.OrganizationId = organizationId; var entity = _mapper.Map(model); var result = await organizationClientService.UpsertClient(entity); - return result.IsSuccess - ? Results.Ok(result) - : result.ToProblemDetails(); + if (!result.IsSuccess) + return result.ToProblemDetails(); + + var responseDto = _mapper.Map(result.Value); + responseDto.Organization = null; + + return Results.Ok(Result.Success(responseDto)); } diff --git a/JobFlow.API/Controllers/UserController.cs b/JobFlow.API/Controllers/UserController.cs index 51b8438..4e12fc6 100644 --- a/JobFlow.API/Controllers/UserController.cs +++ b/JobFlow.API/Controllers/UserController.cs @@ -1,4 +1,6 @@ -using JobFlow.Business.Extensions; +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Models; using Microsoft.AspNetCore.Authorization; @@ -62,4 +64,28 @@ public async Task GetByFirebaseUid(string uid) var result = await _userService.GetUserByFirebaseUid(uid); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } + + [Authorize] + [HttpGet("me")] + public async Task GetMe() + { + var firebaseUid = HttpContext.GetFirebaseUid(); + if (string.IsNullOrWhiteSpace(firebaseUid)) + return Results.Unauthorized(); + + var result = await _userService.GetProfileByFirebaseUid(firebaseUid); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + + [Authorize] + [HttpPut("me")] + public async Task UpdateMe([FromBody] UserProfileUpdateRequest request) + { + var firebaseUid = HttpContext.GetFirebaseUid(); + if (string.IsNullOrWhiteSpace(firebaseUid)) + return Results.Unauthorized(); + + var result = await _userService.UpdateProfile(firebaseUid, request); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } } \ No newline at end of file diff --git a/JobFlow.API/Mappings/MapsterConfig.cs b/JobFlow.API/Mappings/MapsterConfig.cs index 8073cba..e78ef9d 100644 --- a/JobFlow.API/Mappings/MapsterConfig.cs +++ b/JobFlow.API/Mappings/MapsterConfig.cs @@ -35,6 +35,10 @@ public void Register(TypeAdapterConfig config) //OrganizationClient → DTO config.NewConfig(); + //DTO → OrganizationClient + config.NewConfig() + .Ignore(dest => dest.Organization); + //Invoice → DTO config.NewConfig(); diff --git a/JobFlow.API/Mappings/OrganizationBrandingMappingExtension.cs b/JobFlow.API/Mappings/OrganizationBrandingMappingExtension.cs index 965254a..3098033 100644 --- a/JobFlow.API/Mappings/OrganizationBrandingMappingExtension.cs +++ b/JobFlow.API/Mappings/OrganizationBrandingMappingExtension.cs @@ -13,6 +13,7 @@ public static BrandingDto ToDto(OrganizationBranding entity) LogoUrl = entity.LogoUrl, PrimaryColor = entity.PrimaryColor, SecondaryColor = entity.SecondaryColor, + BusinessName = entity.BusinessName, Tagline = entity.Tagline, FooterNote = entity.FooterNote }; @@ -26,6 +27,7 @@ public static OrganizationBranding ToEntity(BrandingDto dto) LogoUrl = dto.LogoUrl, PrimaryColor = dto.PrimaryColor, SecondaryColor = dto.SecondaryColor, + BusinessName = dto.BusinessName, Tagline = dto.Tagline, FooterNote = dto.FooterNote }; diff --git a/JobFlow.API/Models/BrandingDto.cs b/JobFlow.API/Models/BrandingDto.cs index 11e9619..6596956 100644 --- a/JobFlow.API/Models/BrandingDto.cs +++ b/JobFlow.API/Models/BrandingDto.cs @@ -7,6 +7,7 @@ public class BrandingDto public string? LogoUrl { get; set; } public string? PrimaryColor { get; set; } public string? SecondaryColor { get; set; } + public string? BusinessName { get; set; } public string? Tagline { get; set; } public string? FooterNote { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/UserProfileDto.cs b/JobFlow.Business/Models/DTOs/UserProfileDto.cs new file mode 100644 index 0000000..2f3fa5d --- /dev/null +++ b/JobFlow.Business/Models/DTOs/UserProfileDto.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Business.Models.DTOs; + +public class UserProfileDto +{ + public Guid Id { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? PreferredLanguage { get; set; } +} diff --git a/JobFlow.Business/Models/DTOs/UserProfileUpdateRequest.cs b/JobFlow.Business/Models/DTOs/UserProfileUpdateRequest.cs new file mode 100644 index 0000000..91e997f --- /dev/null +++ b/JobFlow.Business/Models/DTOs/UserProfileUpdateRequest.cs @@ -0,0 +1,8 @@ +namespace JobFlow.Business.Models.DTOs; + +public class UserProfileUpdateRequest +{ + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? PreferredLanguage { get; set; } +} diff --git a/JobFlow.Business/Services/OrganizationBrandingService.cs b/JobFlow.Business/Services/OrganizationBrandingService.cs index 8af3b2e..a6ad94f 100644 --- a/JobFlow.Business/Services/OrganizationBrandingService.cs +++ b/JobFlow.Business/Services/OrganizationBrandingService.cs @@ -32,7 +32,7 @@ public async Task> GetByOrganizationIdAsync(Guid or .FirstOrDefaultAsync(b => b.OrganizationId == organizationId); return branding is null - ? Result.Failure(Error.NotFound("", "Branding not found.")) + ? Result.Success(new OrganizationBranding { OrganizationId = organizationId }) : Result.Success(branding); } catch (Exception ex) diff --git a/JobFlow.Business/Services/ServiceInterfaces/IUserService.cs b/JobFlow.Business/Services/ServiceInterfaces/IUserService.cs index 48414cc..ecd6f22 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IUserService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IUserService.cs @@ -1,4 +1,5 @@ using JobFlow.Domain.Models; +using JobFlow.Business.Models.DTOs; namespace JobFlow.Business.Services.ServiceInterfaces; @@ -11,4 +12,6 @@ public interface IUserService Task DeleteUser(Guid userId); Task> GetUserByEmail(string email); Task AssignRole(Guid userId, string role); + Task> GetProfileByFirebaseUid(string uid); + Task> UpdateProfile(string uid, UserProfileUpdateRequest request); } \ No newline at end of file diff --git a/JobFlow.Business/Services/UserService.cs b/JobFlow.Business/Services/UserService.cs index ae9a954..8351bd8 100644 --- a/JobFlow.Business/Services/UserService.cs +++ b/JobFlow.Business/Services/UserService.cs @@ -1,5 +1,6 @@ using JobFlow.Business.DI; using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; @@ -122,6 +123,51 @@ public async Task> GetUserByFirebaseUid(string uid) return Result.Success(user); } + public async Task> GetProfileByFirebaseUid(string uid) + { + var user = await unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(u => u.FirebaseUid == uid); + + if (user == null) + return Result.Failure(UserErrors.UserNotFound); + + return Result.Success(ToProfileDto(user)); + } + + public async Task> UpdateProfile(string uid, UserProfileUpdateRequest request) + { + var user = await unitOfWork.RepositoryOf() + .Query() + .FirstOrDefaultAsync(u => u.FirebaseUid == uid); + + if (user == null) + return Result.Failure(UserErrors.UserNotFound); + + if (request.Email != null) + user.Email = request.Email; + if (request.PhoneNumber != null) + user.PhoneNumber = request.PhoneNumber; + if (request.PreferredLanguage != null) + user.PreferredLanguage = request.PreferredLanguage; + + unitOfWork.RepositoryOf().Update(user); + await unitOfWork.SaveChangesAsync(); + + return Result.Success(ToProfileDto(user)); + } + + private static UserProfileDto ToProfileDto(User user) + { + return new UserProfileDto + { + Id = user.Id, + Email = user.Email, + PhoneNumber = user.PhoneNumber, + PreferredLanguage = user.PreferredLanguage + }; + } + private static string ResolvePrimaryRole(User user) { diff --git a/JobFlow.Domain/Models/User.cs b/JobFlow.Domain/Models/User.cs index 9748976..e6e33a8 100644 --- a/JobFlow.Domain/Models/User.cs +++ b/JobFlow.Domain/Models/User.cs @@ -4,6 +4,7 @@ public class User : Entity { public string? Email { get; set; } public string? PhoneNumber { get; set; } + public string? PreferredLanguage { get; set; } public Guid OrganizationId { get; set; } public string? FirebaseUid { get; set; } public Guid? ClientId { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationBrandingConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationBrandingConfiguration.cs new file mode 100644 index 0000000..5b4cc81 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationBrandingConfiguration.cs @@ -0,0 +1,22 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal class OrganizationBrandingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OrganizationBranding"); + builder.HasKey(x => x.Id); + + builder.HasIndex(x => x.OrganizationId) + .IsUnique(); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/UserConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/UserConfiguration.cs index 5b4e994..0c83db7 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/UserConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/UserConfiguration.cs @@ -9,5 +9,7 @@ internal class UserConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.ToTable("Users"); + builder.Property(u => u.PreferredLanguage) + .HasMaxLength(10); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index d8b2196..1efdf76 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -19,6 +19,7 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet JobUpdateAttachments { get; set; } public DbSet InvoiceSequences { get; set; } public DbSet Organizations { get; set; } + public DbSet OrganizationBrandings { get; set; } public DbSet OrganizationTypes { get; set; } public DbSet EmployeeRolePresets { get; set; } public DbSet EmployeeRolePresetItems { get; set; } diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.Designer.cs new file mode 100644 index 0000000..cd272fd --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.Designer.cs @@ -0,0 +1,3249 @@ +// +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("20260324022238_AddPreferredLanguageToUser")] + partial class AddPreferredLanguageToUser + { + /// + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + 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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260324022238_AddPreferredLanguageToUser.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.cs new file mode 100644 index 0000000..71a5593 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260324022238_AddPreferredLanguageToUser.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPreferredLanguageToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreferredLanguage", + table: "Users", + type: "nvarchar(10)", + maxLength: 10, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PreferredLanguage", + table: "Users"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.Designer.cs new file mode 100644 index 0000000..7e09693 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.Designer.cs @@ -0,0 +1,3313 @@ +// +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("20260324041623_AddOrganizationBranding")] + partial class AddOrganizationBranding + { + /// + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + 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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260324041623_AddOrganizationBranding.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.cs new file mode 100644 index 0000000..38f16e2 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260324041623_AddOrganizationBranding.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddOrganizationBranding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationBranding", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + LogoUrl = table.Column(type: "nvarchar(max)", nullable: true), + PrimaryColor = table.Column(type: "nvarchar(max)", nullable: true), + SecondaryColor = table.Column(type: "nvarchar(max)", nullable: true), + BusinessName = table.Column(type: "nvarchar(max)", nullable: true), + Tagline = table.Column(type: "nvarchar(max)", nullable: true), + FooterNote = table.Column(type: "nvarchar(max)", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationBranding", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationBranding_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationBranding_OrganizationId", + table: "OrganizationBranding", + column: "OrganizationId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationBranding"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 2b2f34e..0d852e9 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1757,6 +1757,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Organization", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => { b.Property("Id") @@ -2345,16 +2398,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PriceBookItems", (string)null); }); - modelBuilder.Entity("JobFlow.Domain.Models.SupportHubInvite", b => + modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("Code") - .IsRequired() - .HasMaxLength(12) - .HasColumnType("nvarchar(12)"); + b.Property("CanceledAt") + .HasColumnType("datetime2"); b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -2365,22 +2416,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); - b.Property("ExpiresAt") - .HasColumnType("datetimeoffset"); - b.Property("IsActive") .HasColumnType("bit"); - b.Property("Role") + b.Property("PaymentProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlanName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Provider") .HasColumnType("int"); - b.Property("RedeemedAt") - .HasColumnType("datetimeoffset"); + b.Property("ProviderPriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("RedeemedByUid") + 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"); @@ -2389,22 +2455,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("Code") - .IsUnique(); + b.HasIndex("PaymentProfileId"); - b.ToTable("SupportHubInvites", (string)null); + b.ToTable("SubscriptionRecord", "payment"); }); - modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubInvite", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("AgentName") + b.Property("Code") .IsRequired() - .HasMaxLength(120) - .HasColumnType("nvarchar(120)"); + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -2415,19 +2480,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); - b.Property("EndedAt") + b.Property("ExpiresAt") .HasColumnType("datetimeoffset"); b.Property("IsActive") .HasColumnType("bit"); - b.Property("OrganizationId") - .HasColumnType("uniqueidentifier"); - - b.Property("StartedAt") + b.Property("RedeemedAt") .HasColumnType("datetimeoffset"); - b.Property("Status") + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") .HasColumnType("int"); b.Property("UpdatedAt") @@ -2438,17 +2504,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("OrganizationId"); + b.HasIndex("Code") + .IsUnique(); - b.ToTable("SupportHubSessions", (string)null); + b.ToTable("SupportHubInvites"); }); - modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -2458,27 +2530,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + b.Property("IsActive") .HasColumnType("bit"); - b.Property("LastActivityAt") - .HasColumnType("datetimeoffset"); - b.Property("OrganizationId") .HasColumnType("uniqueidentifier"); + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + b.Property("Status") .HasColumnType("int"); - b.Property("Summary") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(160) - .HasColumnType("nvarchar(160)"); - b.Property("UpdatedAt") .HasColumnType("datetime2"); @@ -2489,18 +2555,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId"); - b.ToTable("SupportHubTickets", (string)null); + b.ToTable("SupportHubSessions"); }); - modelBuilder.Entity("JobFlow.Domain.Models.SubscriptionRecord", b => + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("CanceledAt") - .HasColumnType("datetime2"); - b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -2513,33 +2576,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("bit"); - b.Property("PaymentProfileId") - .HasColumnType("uniqueidentifier"); + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); - b.Property("PlanName") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); - b.Property("Provider") + b.Property("Status") .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("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); - b.Property("Status") + b.Property("Title") .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); b.Property("UpdatedAt") .HasColumnType("datetime2"); @@ -2549,9 +2602,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("PaymentProfileId"); + b.HasIndex("OrganizationId"); - b.ToTable("SubscriptionRecord", "payment"); + b.ToTable("SupportHubTickets"); }); modelBuilder.Entity("JobFlow.Domain.Models.SystemRole", b => @@ -2603,6 +2656,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PhoneNumber") .HasColumnType("nvarchar(max)"); + b.Property("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + b.Property("UpdatedAt") .HasColumnType("datetime2"); @@ -2999,6 +3056,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OrganizationType"); }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.OrganizationClient", b => { b.HasOne("JobFlow.Domain.Models.Organization", "Organization") @@ -3043,28 +3111,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => - { - b.HasOne("JobFlow.Domain.Models.Organization", "Organization") - .WithMany() - .HasForeignKey("OrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Organization"); - }); - - modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => - { - b.HasOne("JobFlow.Domain.Models.Organization", "Organization") - .WithMany() - .HasForeignKey("OrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Organization"); - }); - modelBuilder.Entity("JobFlow.Domain.Models.PaymentHistory", b => { b.HasOne("JobFlow.Domain.Models.Invoice", "Invoice") @@ -3102,6 +3148,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PaymentProfile"); }); + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.User", b => { b.HasOne("JobFlow.Domain.Models.OrganizationClient", "Client") From 07032441379ebc72f710b35908e5e15d7730185c Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 26 Mar 2026 15:20:19 -0400 Subject: [PATCH 18/26] feat(followup): Initial Follow Up Implementation --- JobFlow.API/Controllers/ChatSmsController.cs | 11 +- .../FollowUpAutomationController.cs | 66 + JobFlow.API/Program.cs | 1 + JobFlow.API/Services/FollowUpJobScheduler.cs | 23 + .../ModelErrors/FollowUpAutomationErrors.cs | 22 + .../Models/DTOs/FollowUpAutomationDtos.cs | 65 + .../Builders/INotificationMessageBuilder.cs | 1 + .../Builders/NotificationMessageBuilder.cs | 23 + .../NotificationService.Estimates.cs | 6 + JobFlow.Business/Services/EstimateService.cs | 20 +- .../Services/FollowUpAutomationService.cs | 523 +++ .../IFollowUpAutomationService.cs | 16 + .../IFollowUpJobScheduler.cs | 6 + .../ServiceInterfaces/INotificationService.cs | 1 + JobFlow.Domain/Enums/FollowUpChannel.cs | 7 + JobFlow.Domain/Enums/FollowUpRunStatus.cs | 10 + JobFlow.Domain/Enums/FollowUpSequenceType.cs | 8 + JobFlow.Domain/Enums/FollowUpStopReason.cs | 13 + JobFlow.Domain/Models/FollowUpExecutionLog.cs | 16 + JobFlow.Domain/Models/FollowUpRun.cs | 21 + JobFlow.Domain/Models/FollowUpSequence.cs | 15 + JobFlow.Domain/Models/FollowUpStep.cs | 15 + .../FollowUpExecutionLogConfiguration.cs | 17 + .../FollowUpRunConfiguration.cs | 22 + .../FollowUpSequenceConfiguration.cs | 22 + .../FollowUpStepConfiguration.cs | 17 + .../JobFlowDbContext.cs | 4 + ...26184716_AddFollowUpAutomation.Designer.cs | 3578 +++++++++++++++++ .../20260326184716_AddFollowUpAutomation.cs | 173 + .../JobFlowDbContextModelSnapshot.cs | 265 ++ .../FollowUpAutomationServiceTests.cs | 280 ++ 31 files changed, 5265 insertions(+), 2 deletions(-) create mode 100644 JobFlow.API/Controllers/FollowUpAutomationController.cs create mode 100644 JobFlow.API/Services/FollowUpJobScheduler.cs create mode 100644 JobFlow.Business/ModelErrors/FollowUpAutomationErrors.cs create mode 100644 JobFlow.Business/Models/DTOs/FollowUpAutomationDtos.cs create mode 100644 JobFlow.Business/Services/FollowUpAutomationService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IFollowUpAutomationService.cs create mode 100644 JobFlow.Business/Services/ServiceInterfaces/IFollowUpJobScheduler.cs create mode 100644 JobFlow.Domain/Enums/FollowUpChannel.cs create mode 100644 JobFlow.Domain/Enums/FollowUpRunStatus.cs create mode 100644 JobFlow.Domain/Enums/FollowUpSequenceType.cs create mode 100644 JobFlow.Domain/Enums/FollowUpStopReason.cs create mode 100644 JobFlow.Domain/Models/FollowUpExecutionLog.cs create mode 100644 JobFlow.Domain/Models/FollowUpRun.cs create mode 100644 JobFlow.Domain/Models/FollowUpSequence.cs create mode 100644 JobFlow.Domain/Models/FollowUpStep.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/FollowUpExecutionLogConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/FollowUpRunConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/FollowUpSequenceConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/FollowUpStepConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.cs create mode 100644 JobFlow.Tests/FollowUpAutomationServiceTests.cs diff --git a/JobFlow.API/Controllers/ChatSmsController.cs b/JobFlow.API/Controllers/ChatSmsController.cs index 51b6beb..cd95aee 100644 --- a/JobFlow.API/Controllers/ChatSmsController.cs +++ b/JobFlow.API/Controllers/ChatSmsController.cs @@ -1,5 +1,6 @@ using JobFlow.API.Hubs; using JobFlow.API.Models; +using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain; using JobFlow.Domain.Models; using Microsoft.AspNetCore.Authorization; @@ -16,15 +17,18 @@ public class ChatSmsController : ControllerBase private readonly IUnitOfWork _unitOfWork; private readonly IHubContext _hubContext; private readonly IHubContext _clientHubContext; + private readonly IFollowUpAutomationService? _followUpAutomation; public ChatSmsController( IUnitOfWork unitOfWork, IHubContext hubContext, - IHubContext clientHubContext) + IHubContext clientHubContext, + IFollowUpAutomationService? followUpAutomation = null) { _unitOfWork = unitOfWork; _hubContext = hubContext; _clientHubContext = clientHubContext; + _followUpAutomation = followUpAutomation; } [HttpPost("inbound")] @@ -47,6 +51,11 @@ public async Task Inbound([FromForm] TwilioInboundSmsRequest requ if (client is null) return TwilioOk(); + if (_followUpAutomation != null) + { + await _followUpAutomation.StopEstimateSequencesOnClientReplyAsync(client.OrganizationId, client.Id); + } + var conversation = await _unitOfWork.RepositoryOf() .Query() .Include(c => c.Participants) diff --git a/JobFlow.API/Controllers/FollowUpAutomationController.cs b/JobFlow.API/Controllers/FollowUpAutomationController.cs new file mode 100644 index 0000000..3c9eedd --- /dev/null +++ b/JobFlow.API/Controllers/FollowUpAutomationController.cs @@ -0,0 +1,66 @@ +using JobFlow.API.Extensions; +using JobFlow.Business.Extensions; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/follow-up-automation")] +[Authorize] +public class FollowUpAutomationController : ControllerBase +{ + private readonly IFollowUpAutomationService _followUpAutomation; + + public FollowUpAutomationController(IFollowUpAutomationService followUpAutomation) + { + _followUpAutomation = followUpAutomation; + } + + [HttpGet("sequences")] + public async Task GetSequences([FromQuery] FollowUpSequenceType? sequenceType) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _followUpAutomation.GetSequencesAsync(organizationId, sequenceType); + return result.IsSuccess ? Ok(result.Value) : ProblemFrom(result); + } + + [HttpPut("sequences")] + public async Task UpsertSequence([FromBody] FollowUpSequenceUpsertRequestDto request) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _followUpAutomation.UpsertSequenceAsync(organizationId, request); + return result.IsSuccess ? Ok(result.Value) : ProblemFrom(result); + } + + [HttpGet("estimates/{estimateId:guid}/runs")] + public async Task GetEstimateRuns(Guid estimateId) + { + var organizationId = HttpContext.GetOrganizationId(); + var result = await _followUpAutomation.GetEstimateRunsAsync(organizationId, estimateId); + return result.IsSuccess ? Ok(result.Value) : ProblemFrom(result); + } + + [HttpPost("estimates/{estimateId:guid}/stop")] + public async Task StopEstimateRun(Guid estimateId) + { + var result = await _followUpAutomation.StopEstimateSequenceAsync(estimateId, FollowUpStopReason.ManuallyStopped); + return result.IsSuccess ? NoContent() : ProblemFrom(result); + } + + private ObjectResult ProblemFrom(JobFlow.Business.Result result) + { + var problem = result.ToProblemDetails() as Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult; + + if (problem == null) + return Problem(statusCode: 500); + + return new ObjectResult(problem.ProblemDetails) + { + StatusCode = problem.StatusCode + }; + } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index b177e40..53f1ead 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -324,6 +324,7 @@ builder.Services.AddMapsterConfiguration(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddJobFlowHttpClients(); builder.Services.AddAttributedServices(typeof(IJobFlowHttpClientFactory).Assembly, typeof(IUserService).Assembly); diff --git a/JobFlow.API/Services/FollowUpJobScheduler.cs b/JobFlow.API/Services/FollowUpJobScheduler.cs new file mode 100644 index 0000000..a6070d9 --- /dev/null +++ b/JobFlow.API/Services/FollowUpJobScheduler.cs @@ -0,0 +1,23 @@ +using Hangfire; +using JobFlow.Business.Services.ServiceInterfaces; + +namespace JobFlow.API.Services; + +public class FollowUpJobScheduler : IFollowUpJobScheduler +{ + private readonly IBackgroundJobClient _backgroundJobClient; + + public FollowUpJobScheduler(IBackgroundJobClient backgroundJobClient) + { + _backgroundJobClient = backgroundJobClient; + } + + public Task ScheduleRunStepAsync(Guid runId, TimeSpan delay) + { + _backgroundJobClient.Schedule( + svc => svc.ExecuteRunStepAsync(runId), + delay); + + return Task.CompletedTask; + } +} diff --git a/JobFlow.Business/ModelErrors/FollowUpAutomationErrors.cs b/JobFlow.Business/ModelErrors/FollowUpAutomationErrors.cs new file mode 100644 index 0000000..a195b78 --- /dev/null +++ b/JobFlow.Business/ModelErrors/FollowUpAutomationErrors.cs @@ -0,0 +1,22 @@ +namespace JobFlow.Business.ModelErrors; + +public static class FollowUpAutomationErrors +{ + public static readonly Error SequenceNotFound = + Error.NotFound("FollowUp.SequenceNotFound", "The follow-up sequence was not found."); + + public static readonly Error RunNotFound = + Error.NotFound("FollowUp.RunNotFound", "The follow-up run was not found."); + + public static readonly Error EstimateNotFound = + Error.NotFound("FollowUp.EstimateNotFound", "The estimate was not found."); + + public static readonly Error ClientNotFound = + Error.NotFound("FollowUp.ClientNotFound", "The follow-up client was not found."); + + public static readonly Error InvalidSteps = + Error.Validation("FollowUp.InvalidSteps", "At least one follow-up step is required."); + + public static readonly Error DuplicateStepOrder = + Error.Validation("FollowUp.DuplicateStepOrder", "Step order values must be unique."); +} diff --git a/JobFlow.Business/Models/DTOs/FollowUpAutomationDtos.cs b/JobFlow.Business/Models/DTOs/FollowUpAutomationDtos.cs new file mode 100644 index 0000000..a24f586 --- /dev/null +++ b/JobFlow.Business/Models/DTOs/FollowUpAutomationDtos.cs @@ -0,0 +1,65 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Models.DTOs; + +public sealed record FollowUpStepUpsertRequestDto( + int StepOrder, + int DelayHours, + FollowUpChannel? ChannelOverride, + string MessageTemplate, + bool IsEscalation +); + +public sealed record FollowUpSequenceUpsertRequestDto( + Guid? Id, + FollowUpSequenceType SequenceType, + string Name, + bool IsEnabled, + bool StopOnClientReply, + FollowUpChannel DefaultChannel, + IReadOnlyList Steps +); + +public sealed record FollowUpStepDto( + Guid Id, + int StepOrder, + int DelayHours, + FollowUpChannel? ChannelOverride, + string MessageTemplate, + bool IsEscalation +); + +public sealed record FollowUpSequenceDto( + Guid Id, + Guid OrganizationId, + FollowUpSequenceType SequenceType, + string Name, + bool IsEnabled, + bool StopOnClientReply, + FollowUpChannel DefaultChannel, + IReadOnlyList Steps +); + +public sealed record FollowUpExecutionLogDto( + Guid Id, + int StepOrder, + FollowUpChannel Channel, + DateTimeOffset ScheduledFor, + DateTimeOffset AttemptedAt, + bool WasSent, + string? FailureReason +); + +public sealed record FollowUpRunDto( + Guid Id, + Guid FollowUpSequenceId, + Guid TriggerEntityId, + FollowUpSequenceType SequenceType, + FollowUpRunStatus Status, + FollowUpStopReason StopReason, + int NextStepOrder, + DateTimeOffset StartedAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? CompletedAt, + IReadOnlyList Logs +); diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index d45e45c..7f811b4 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -29,6 +29,7 @@ NotificationMessage BuildClientJobRescheduled( NotificationMessage BuildEmployeeInvite(EmployeeInvite invite); NotificationMessage BuildClientEstimateSent(OrganizationClient client, Estimate estimate); + NotificationMessage BuildClientEstimateFollowUp(OrganizationClient client, Estimate estimate, string message); NotificationMessage BuildOrganizationEstimateRevisionRequested(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink); diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index d0aed12..23394dd 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -241,6 +241,29 @@ Your estimate is ready. }; } + public NotificationMessage BuildClientEstimateFollowUp(OrganizationClient client, Estimate estimate, string message) + { + var link = $"{baseUrl}/estimate/view/{estimate.PublicToken}"; + + return new NotificationMessage + { + Name = client.ClientFullName(), + Email = client.EmailAddress, + Phone = client.PhoneNumber, + Subject = $"Quick Follow-Up: {estimate.EstimateNumber}", + Body = $""" + Hello {client.ClientFullName()}, + + {message} + + View estimate: {link} + """, + Sms = $"{message} ", + Link = link, + TemplateId = EmailTemplate.Default + }; + } + public NotificationMessage BuildOrganizationEstimateRevisionRequested( Organization organization, OrganizationClient client, diff --git a/JobFlow.Business/Notifications/NotificationService.Estimates.cs b/JobFlow.Business/Notifications/NotificationService.Estimates.cs index d892ef7..7b9097e 100644 --- a/JobFlow.Business/Notifications/NotificationService.Estimates.cs +++ b/JobFlow.Business/Notifications/NotificationService.Estimates.cs @@ -9,4 +9,10 @@ public async Task SendClientEstimateSentNotificationAsync(OrganizationClient cli var message = _builder.BuildClientEstimateSent(client, estimate); await SendNotificationAsync(message); } + + public async Task SendClientEstimateFollowUpNotificationAsync(OrganizationClient client, Estimate estimate, string followUpMessage) + { + var message = _builder.BuildClientEstimateFollowUp(client, estimate, followUpMessage); + await SendNotificationAsync(message); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/EstimateService.cs b/JobFlow.Business/Services/EstimateService.cs index 7508443..5c7b1e7 100644 --- a/JobFlow.Business/Services/EstimateService.cs +++ b/JobFlow.Business/Services/EstimateService.cs @@ -17,6 +17,7 @@ public class EstimateService : IEstimateService private readonly INotificationService notificationService; private readonly IPdfGenerator pdfGenerator; private readonly IOrganizationClientPortalService clientPortalService; + private readonly IFollowUpAutomationService? _followUpAutomation; private readonly IRepository estimates; private readonly IRepository clients; @@ -25,12 +26,14 @@ public EstimateService( IUnitOfWork unitOfWork, INotificationService notificationService, IPdfGenerator pdfGenerator, - IOrganizationClientPortalService clientPortalService) + IOrganizationClientPortalService clientPortalService, + IFollowUpAutomationService? followUpAutomation = null) { this.unitOfWork = unitOfWork; this.notificationService = notificationService; this.pdfGenerator = pdfGenerator; this.clientPortalService = clientPortalService; + _followUpAutomation = followUpAutomation; estimates = unitOfWork.RepositoryOf(); clients = unitOfWork.RepositoryOf(); @@ -179,6 +182,12 @@ public async Task> SendAsync(Guid id, SendEstimateRequest re estimates.Update(estimate); await unitOfWork.SaveChangesAsync(); + if (_followUpAutomation != null) + await _followUpAutomation.StartEstimateSequenceAsync( + estimate.OrganizationId, + estimate.Id, + estimate.OrganizationClientId); + await clientPortalService.SendMagicLinkAsync(estimate.OrganizationId, client.Id, client.EmailAddress ?? string.Empty); var full = await estimates.Query() @@ -263,6 +272,15 @@ private async Task> RespondAsync( estimates.Update(estimate); await unitOfWork.SaveChangesAsync(); + if (_followUpAutomation != null && newStatus is EstimateStatus.Accepted or EstimateStatus.Declined) + { + var reason = newStatus == EstimateStatus.Accepted + ? FollowUpStopReason.EstimateAccepted + : FollowUpStopReason.EstimateDeclined; + + await _followUpAutomation.StopEstimateSequenceAsync(estimate.Id, reason); + } + return Result.Success(ToDto(estimate)); } diff --git a/JobFlow.Business/Services/FollowUpAutomationService.cs b/JobFlow.Business/Services/FollowUpAutomationService.cs new file mode 100644 index 0000000..5173322 --- /dev/null +++ b/JobFlow.Business/Services/FollowUpAutomationService.cs @@ -0,0 +1,523 @@ +using JobFlow.Business.DI; +using JobFlow.Business.Extensions; +using JobFlow.Business.ModelErrors; +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JobFlow.Business.Services; + +[ScopedService] +public class FollowUpAutomationService : IFollowUpAutomationService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly INotificationService _notifications; + private readonly IFollowUpJobScheduler? _scheduler; + + private readonly IRepository _sequences; + private readonly IRepository _steps; + private readonly IRepository _runs; + private readonly IRepository _logs; + private readonly IRepository _estimates; + private readonly IRepository _clients; + + public FollowUpAutomationService( + IUnitOfWork unitOfWork, + INotificationService notifications, + ILogger logger, + IFollowUpJobScheduler? scheduler = null) + { + _unitOfWork = unitOfWork; + _notifications = notifications; + _logger = logger; + _scheduler = scheduler; + + _sequences = unitOfWork.RepositoryOf(); + _steps = unitOfWork.RepositoryOf(); + _runs = unitOfWork.RepositoryOf(); + _logs = unitOfWork.RepositoryOf(); + _estimates = unitOfWork.RepositoryOf(); + _clients = unitOfWork.RepositoryOf(); + } + + public async Task>> GetSequencesAsync(Guid organizationId, FollowUpSequenceType? sequenceType = null) + { + var query = _sequences.Query() + .Where(x => x.OrganizationId == organizationId) + .Include(x => x.Steps) + .AsSplitQuery(); + + if (sequenceType.HasValue) + { + query = query.Where(x => x.SequenceType == sequenceType.Value); + } + + var list = await query + .OrderBy(x => x.SequenceType) + .ThenBy(x => x.Name) + .ToListAsync(); + + return Result.Success>(list.Select(ToDto).ToList()); + } + + public async Task> UpsertSequenceAsync(Guid organizationId, FollowUpSequenceUpsertRequestDto request) + { + if (request.Steps is null || request.Steps.Count == 0) + { + return Result.Failure(FollowUpAutomationErrors.InvalidSteps); + } + + if (request.Steps.GroupBy(x => x.StepOrder).Any(g => g.Count() > 1)) + { + return Result.Failure(FollowUpAutomationErrors.DuplicateStepOrder); + } + + FollowUpSequence? sequence; + if (request.Id.HasValue && request.Id.Value != Guid.Empty) + { + sequence = await _sequences.Query() + .Include(x => x.Steps) + .FirstOrDefaultAsync(x => x.Id == request.Id.Value && x.OrganizationId == organizationId); + + if (sequence is null) + { + return Result.Failure(FollowUpAutomationErrors.SequenceNotFound); + } + + sequence.Name = request.Name.Trim(); + sequence.IsEnabled = request.IsEnabled; + sequence.StopOnClientReply = request.StopOnClientReply; + sequence.DefaultChannel = request.DefaultChannel; + + _steps.RemoveRange(sequence.Steps); + sequence.Steps.Clear(); + } + else + { + sequence = new FollowUpSequence + { + OrganizationId = organizationId, + SequenceType = request.SequenceType, + Name = request.Name.Trim(), + IsEnabled = request.IsEnabled, + StopOnClientReply = request.StopOnClientReply, + DefaultChannel = request.DefaultChannel + }; + + await _sequences.AddAsync(sequence); + } + + if (sequence is null) + { + return Result.Failure(FollowUpAutomationErrors.SequenceNotFound); + } + + foreach (var step in request.Steps.OrderBy(x => x.StepOrder)) + { + sequence.Steps.Add(new FollowUpStep + { + StepOrder = step.StepOrder, + DelayHours = Math.Max(0, step.DelayHours), + ChannelOverride = step.ChannelOverride, + MessageTemplate = step.MessageTemplate.Trim(), + IsEscalation = step.IsEscalation + }); + } + + await _unitOfWork.SaveChangesAsync(); + + var refreshed = await _sequences.Query() + .Include(x => x.Steps) + .FirstAsync(x => x.Id == sequence.Id); + + return Result.Success(ToDto(refreshed)); + } + + public async Task>> GetEstimateRunsAsync(Guid organizationId, Guid estimateId) + { + var runs = await _runs.Query() + .Where(x => x.OrganizationId == organizationId + && x.SequenceType == FollowUpSequenceType.Estimate + && x.TriggerEntityId == estimateId) + .Include(x => x.ExecutionLogs) + .OrderByDescending(x => x.StartedAt) + .ToListAsync(); + + return Result.Success>(runs.Select(ToDto).ToList()); + } + + public async Task StartEstimateSequenceAsync(Guid organizationId, Guid estimateId, Guid organizationClientId) + { + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == estimateId && x.OrganizationId == organizationId); + + if (estimate is null) + { + return Result.Failure(FollowUpAutomationErrors.EstimateNotFound); + } + + if (estimate.Status != EstimateStatus.Sent) + { + return Result.Success(); + } + + var sequence = await EnsureEstimateDefaultSequenceAsync(organizationId); + + if (!sequence.IsEnabled) + { + return Result.Success(); + } + + var hasActiveRun = await _runs.Query().AnyAsync(x => + x.TriggerEntityId == estimateId + && x.SequenceType == FollowUpSequenceType.Estimate + && (x.Status == FollowUpRunStatus.Scheduled || x.Status == FollowUpRunStatus.InProgress)); + + if (hasActiveRun) + { + return Result.Success(); + } + + var firstStep = sequence.Steps.OrderBy(x => x.StepOrder).FirstOrDefault(); + if (firstStep is null) + { + return Result.Success(); + } + + var run = new FollowUpRun + { + FollowUpSequenceId = sequence.Id, + OrganizationId = organizationId, + OrganizationClientId = organizationClientId, + TriggerEntityId = estimateId, + SequenceType = FollowUpSequenceType.Estimate, + Status = FollowUpRunStatus.Scheduled, + NextStepOrder = firstStep.StepOrder, + StartedAt = DateTimeOffset.UtcNow + }; + + await _runs.AddAsync(run); + await _unitOfWork.SaveChangesAsync(); + + await ScheduleRunStepAsync(run.Id, TimeSpan.FromHours(Math.Max(0, firstStep.DelayHours))); + return Result.Success(); + } + + public async Task StopEstimateSequenceAsync(Guid estimateId, FollowUpStopReason reason) + { + var activeRuns = await _runs.Query() + .Where(x => x.SequenceType == FollowUpSequenceType.Estimate + && x.TriggerEntityId == estimateId + && (x.Status == FollowUpRunStatus.Scheduled || x.Status == FollowUpRunStatus.InProgress)) + .ToListAsync(); + + if (activeRuns.Count == 0) + { + return Result.Success(); + } + + foreach (var run in activeRuns) + { + run.Status = FollowUpRunStatus.Stopped; + run.StopReason = reason; + run.CompletedAt = DateTimeOffset.UtcNow; + } + + _runs.UpdateRange(activeRuns); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + + public async Task StopEstimateSequencesOnClientReplyAsync(Guid organizationId, Guid organizationClientId) + { + var activeRuns = await _runs.Query() + .Include(x => x.Sequence) + .Where(x => x.OrganizationId == organizationId + && x.OrganizationClientId == organizationClientId + && x.SequenceType == FollowUpSequenceType.Estimate + && (x.Status == FollowUpRunStatus.Scheduled || x.Status == FollowUpRunStatus.InProgress)) + .ToListAsync(); + + if (activeRuns.Count == 0) + { + return Result.Success(); + } + + var runsToStop = activeRuns + .Where(x => x.Sequence?.StopOnClientReply == true) + .ToList(); + + if (runsToStop.Count == 0) + { + return Result.Success(); + } + + foreach (var run in runsToStop) + { + run.Status = FollowUpRunStatus.Stopped; + run.StopReason = FollowUpStopReason.ClientReplied; + run.CompletedAt = DateTimeOffset.UtcNow; + } + + _runs.UpdateRange(runsToStop); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + + public async Task ExecuteRunStepAsync(Guid runId) + { + var run = await _runs.Query() + .Include(x => x.Sequence!) + .ThenInclude(x => x.Steps) + .FirstOrDefaultAsync(x => x.Id == runId); + + if (run is null) + { + return Result.Failure(FollowUpAutomationErrors.RunNotFound); + } + + if (run.Status is FollowUpRunStatus.Completed or FollowUpRunStatus.Stopped) + { + return Result.Success(); + } + + if (run.Sequence is null) + { + await StopRunAsync(run, FollowUpStopReason.NotEligible); + return Result.Success(); + } + + var estimate = await _estimates.Query() + .FirstOrDefaultAsync(x => x.Id == run.TriggerEntityId); + + if (estimate is null) + { + await StopRunAsync(run, FollowUpStopReason.NotEligible); + return Result.Success(); + } + + if (estimate.Status == EstimateStatus.Accepted) + { + await StopRunAsync(run, FollowUpStopReason.EstimateAccepted); + return Result.Success(); + } + + if (estimate.Status == EstimateStatus.Declined) + { + await StopRunAsync(run, FollowUpStopReason.EstimateDeclined); + return Result.Success(); + } + + if (estimate.Status != EstimateStatus.Sent) + { + await StopRunAsync(run, FollowUpStopReason.NotEligible); + return Result.Success(); + } + + var step = run.Sequence.Steps + .OrderBy(x => x.StepOrder) + .FirstOrDefault(x => x.StepOrder == run.NextStepOrder); + + if (step is null) + { + run.Status = FollowUpRunStatus.Completed; + run.CompletedAt = DateTimeOffset.UtcNow; + _runs.Update(run); + await _unitOfWork.SaveChangesAsync(); + return Result.Success(); + } + + var client = await _clients.Query().FirstOrDefaultAsync(x => x.Id == run.OrganizationClientId); + if (client is null) + { + return Result.Failure(FollowUpAutomationErrors.ClientNotFound); + } + + var message = BuildEstimateMessage(step.MessageTemplate, client, estimate); + var channel = step.ChannelOverride ?? run.Sequence.DefaultChannel; + + var log = new FollowUpExecutionLog + { + FollowUpRunId = run.Id, + StepOrder = step.StepOrder, + Channel = channel, + ScheduledFor = DateTimeOffset.UtcNow, + AttemptedAt = DateTimeOffset.UtcNow, + WasSent = false + }; + + try + { + await _notifications.SendClientEstimateFollowUpNotificationAsync(client, estimate, message); + log.WasSent = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Follow-up execution failed for run {RunId} step {StepOrder}", run.Id, step.StepOrder); + log.WasSent = false; + log.FailureReason = ex.Message; + } + + await _logs.AddAsync(log); + + run.Status = FollowUpRunStatus.InProgress; + run.LastAttemptAt = DateTimeOffset.UtcNow; + + var nextStep = run.Sequence.Steps + .OrderBy(x => x.StepOrder) + .FirstOrDefault(x => x.StepOrder > step.StepOrder); + + if (nextStep is null) + { + run.Status = FollowUpRunStatus.Completed; + run.CompletedAt = DateTimeOffset.UtcNow; + } + else + { + run.NextStepOrder = nextStep.StepOrder; + } + + _runs.Update(run); + await _unitOfWork.SaveChangesAsync(); + + if (nextStep is not null) + { + await ScheduleRunStepAsync(run.Id, TimeSpan.FromHours(Math.Max(0, nextStep.DelayHours))); + } + + return Result.Success(); + } + + private async Task EnsureEstimateDefaultSequenceAsync(Guid organizationId) + { + var existing = await _sequences.Query() + .Include(x => x.Steps) + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId && x.SequenceType == FollowUpSequenceType.Estimate); + + if (existing is not null) + { + return existing; + } + + var sequence = new FollowUpSequence + { + OrganizationId = organizationId, + SequenceType = FollowUpSequenceType.Estimate, + Name = "Estimate Follow-Up", + IsEnabled = true, + StopOnClientReply = true, + DefaultChannel = FollowUpChannel.Email, + Steps = new List + { + new() + { + StepOrder = 1, + DelayHours = 48, + MessageTemplate = "Hi {ClientFirstName}, just checking in on estimate {EstimateNumber}. Let us know if you have any questions.", + IsEscalation = false + }, + new() + { + StepOrder = 2, + DelayHours = 72, + MessageTemplate = "Friendly follow-up on estimate {EstimateNumber}. We have availability this week if you want to move forward.", + IsEscalation = false + } + } + }; + + await _sequences.AddAsync(sequence); + await _unitOfWork.SaveChangesAsync(); + + return sequence; + } + + private async Task StopRunAsync(FollowUpRun run, FollowUpStopReason reason) + { + run.Status = FollowUpRunStatus.Stopped; + run.StopReason = reason; + run.CompletedAt = DateTimeOffset.UtcNow; + _runs.Update(run); + await _unitOfWork.SaveChangesAsync(); + } + + private async Task ScheduleRunStepAsync(Guid runId, TimeSpan delay) + { + if (_scheduler is not null) + { + await _scheduler.ScheduleRunStepAsync(runId, delay); + return; + } + + if (delay <= TimeSpan.Zero) + { + await ExecuteRunStepAsync(runId); + } + } + + private static string BuildEstimateMessage(string template, OrganizationClient client, Estimate estimate) + { + var firstName = string.IsNullOrWhiteSpace(client.FirstName) ? "there" : client.FirstName.Trim(); + + return template + .Replace("{ClientFirstName}", firstName, StringComparison.OrdinalIgnoreCase) + .Replace("{ClientFullName}", client.ClientFullName(), StringComparison.OrdinalIgnoreCase) + .Replace("{EstimateNumber}", estimate.EstimateNumber, StringComparison.OrdinalIgnoreCase); + } + + private static FollowUpSequenceDto ToDto(FollowUpSequence sequence) + { + return new FollowUpSequenceDto( + sequence.Id, + sequence.OrganizationId, + sequence.SequenceType, + sequence.Name, + sequence.IsEnabled, + sequence.StopOnClientReply, + sequence.DefaultChannel, + sequence.Steps + .OrderBy(x => x.StepOrder) + .Select(x => new FollowUpStepDto( + x.Id, + x.StepOrder, + x.DelayHours, + x.ChannelOverride, + x.MessageTemplate, + x.IsEscalation)) + .ToList()); + } + + private static FollowUpRunDto ToDto(FollowUpRun run) + { + return new FollowUpRunDto( + run.Id, + run.FollowUpSequenceId, + run.TriggerEntityId, + run.SequenceType, + run.Status, + run.StopReason, + run.NextStepOrder, + run.StartedAt, + run.LastAttemptAt, + run.CompletedAt, + run.ExecutionLogs + .OrderBy(x => x.StepOrder) + .ThenBy(x => x.AttemptedAt) + .Select(x => new FollowUpExecutionLogDto( + x.Id, + x.StepOrder, + x.Channel, + x.ScheduledFor, + x.AttemptedAt, + x.WasSent, + x.FailureReason)) + .ToList()); + } +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IFollowUpAutomationService.cs b/JobFlow.Business/Services/ServiceInterfaces/IFollowUpAutomationService.cs new file mode 100644 index 0000000..8f2cb22 --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IFollowUpAutomationService.cs @@ -0,0 +1,16 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Domain.Enums; + +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IFollowUpAutomationService +{ + Task>> GetSequencesAsync(Guid organizationId, FollowUpSequenceType? sequenceType = null); + Task> UpsertSequenceAsync(Guid organizationId, FollowUpSequenceUpsertRequestDto request); + Task>> GetEstimateRunsAsync(Guid organizationId, Guid estimateId); + + Task StartEstimateSequenceAsync(Guid organizationId, Guid estimateId, Guid organizationClientId); + Task StopEstimateSequencesOnClientReplyAsync(Guid organizationId, Guid organizationClientId); + Task StopEstimateSequenceAsync(Guid estimateId, FollowUpStopReason reason); + Task ExecuteRunStepAsync(Guid runId); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/IFollowUpJobScheduler.cs b/JobFlow.Business/Services/ServiceInterfaces/IFollowUpJobScheduler.cs new file mode 100644 index 0000000..a7f43cc --- /dev/null +++ b/JobFlow.Business/Services/ServiceInterfaces/IFollowUpJobScheduler.cs @@ -0,0 +1,6 @@ +namespace JobFlow.Business.Services.ServiceInterfaces; + +public interface IFollowUpJobScheduler +{ + Task ScheduleRunStepAsync(Guid runId, TimeSpan delay); +} diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index edf29e0..614b705 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -26,6 +26,7 @@ Task SendClientJobRescheduledNotificationAsync( Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes); Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job); Task SendClientEstimateSentNotificationAsync(OrganizationClient client, Estimate estimate); + Task SendClientEstimateFollowUpNotificationAsync(OrganizationClient client, Estimate estimate, string message); Task SendOrganizationEstimateRevisionRequestedNotificationAsync(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage); Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink); diff --git a/JobFlow.Domain/Enums/FollowUpChannel.cs b/JobFlow.Domain/Enums/FollowUpChannel.cs new file mode 100644 index 0000000..6c3d9e1 --- /dev/null +++ b/JobFlow.Domain/Enums/FollowUpChannel.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Domain.Enums; + +public enum FollowUpChannel +{ + Email = 1, + Sms = 2 +} diff --git a/JobFlow.Domain/Enums/FollowUpRunStatus.cs b/JobFlow.Domain/Enums/FollowUpRunStatus.cs new file mode 100644 index 0000000..60ca8ca --- /dev/null +++ b/JobFlow.Domain/Enums/FollowUpRunStatus.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Enums; + +public enum FollowUpRunStatus +{ + Scheduled = 1, + InProgress = 2, + Completed = 3, + Stopped = 4, + Failed = 5 +} diff --git a/JobFlow.Domain/Enums/FollowUpSequenceType.cs b/JobFlow.Domain/Enums/FollowUpSequenceType.cs new file mode 100644 index 0000000..19e8bb0 --- /dev/null +++ b/JobFlow.Domain/Enums/FollowUpSequenceType.cs @@ -0,0 +1,8 @@ +namespace JobFlow.Domain.Enums; + +public enum FollowUpSequenceType +{ + Estimate = 1, + Invoice = 2, + Lead = 3 +} diff --git a/JobFlow.Domain/Enums/FollowUpStopReason.cs b/JobFlow.Domain/Enums/FollowUpStopReason.cs new file mode 100644 index 0000000..28a5e24 --- /dev/null +++ b/JobFlow.Domain/Enums/FollowUpStopReason.cs @@ -0,0 +1,13 @@ +namespace JobFlow.Domain.Enums; + +public enum FollowUpStopReason +{ + None = 0, + ClientReplied = 1, + EstimateAccepted = 2, + EstimateDeclined = 3, + InvoicePaid = 4, + ManuallyStopped = 5, + SequenceDisabled = 6, + NotEligible = 7 +} diff --git a/JobFlow.Domain/Models/FollowUpExecutionLog.cs b/JobFlow.Domain/Models/FollowUpExecutionLog.cs new file mode 100644 index 0000000..082fe92 --- /dev/null +++ b/JobFlow.Domain/Models/FollowUpExecutionLog.cs @@ -0,0 +1,16 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class FollowUpExecutionLog : Entity +{ + public Guid FollowUpRunId { get; set; } + public int StepOrder { get; set; } + public FollowUpChannel Channel { get; set; } + public DateTimeOffset ScheduledFor { get; set; } + public DateTimeOffset AttemptedAt { get; set; } = DateTimeOffset.UtcNow; + public bool WasSent { get; set; } + public string? FailureReason { get; set; } + + public FollowUpRun? Run { get; set; } +} diff --git a/JobFlow.Domain/Models/FollowUpRun.cs b/JobFlow.Domain/Models/FollowUpRun.cs new file mode 100644 index 0000000..5726ec2 --- /dev/null +++ b/JobFlow.Domain/Models/FollowUpRun.cs @@ -0,0 +1,21 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class FollowUpRun : Entity +{ + public Guid FollowUpSequenceId { get; set; } + public Guid OrganizationId { get; set; } + public Guid OrganizationClientId { get; set; } + public Guid TriggerEntityId { get; set; } + public FollowUpSequenceType SequenceType { get; set; } + public FollowUpRunStatus Status { get; set; } = FollowUpRunStatus.Scheduled; + public FollowUpStopReason StopReason { get; set; } = FollowUpStopReason.None; + public int NextStepOrder { get; set; } = 1; + public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastAttemptAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + + public FollowUpSequence? Sequence { get; set; } + public ICollection ExecutionLogs { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/FollowUpSequence.cs b/JobFlow.Domain/Models/FollowUpSequence.cs new file mode 100644 index 0000000..19d755f --- /dev/null +++ b/JobFlow.Domain/Models/FollowUpSequence.cs @@ -0,0 +1,15 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class FollowUpSequence : Entity +{ + public Guid OrganizationId { get; set; } + public FollowUpSequenceType SequenceType { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public bool StopOnClientReply { get; set; } = true; + public FollowUpChannel DefaultChannel { get; set; } = FollowUpChannel.Email; + + public ICollection Steps { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/FollowUpStep.cs b/JobFlow.Domain/Models/FollowUpStep.cs new file mode 100644 index 0000000..08994f5 --- /dev/null +++ b/JobFlow.Domain/Models/FollowUpStep.cs @@ -0,0 +1,15 @@ +using JobFlow.Domain.Enums; + +namespace JobFlow.Domain.Models; + +public class FollowUpStep : Entity +{ + public Guid FollowUpSequenceId { get; set; } + public int StepOrder { get; set; } + public int DelayHours { get; set; } + public FollowUpChannel? ChannelOverride { get; set; } + public string MessageTemplate { get; set; } = string.Empty; + public bool IsEscalation { get; set; } + + public FollowUpSequence? Sequence { get; set; } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/FollowUpExecutionLogConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpExecutionLogConfiguration.cs new file mode 100644 index 0000000..e5de72e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpExecutionLogConfiguration.cs @@ -0,0 +1,17 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class FollowUpExecutionLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FollowUpExecutionLogs"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.FailureReason).HasMaxLength(500); + builder.HasIndex(x => new { x.FollowUpRunId, x.StepOrder }); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/FollowUpRunConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpRunConfiguration.cs new file mode 100644 index 0000000..8b38a50 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpRunConfiguration.cs @@ -0,0 +1,22 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class FollowUpRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FollowUpRuns"); + builder.HasKey(x => x.Id); + + builder.HasIndex(x => new { x.OrganizationId, x.SequenceType, x.TriggerEntityId, x.Status }); + builder.HasIndex(x => new { x.FollowUpSequenceId, x.OrganizationClientId, x.Status }); + + builder.HasMany(x => x.ExecutionLogs) + .WithOne(x => x.Run!) + .HasForeignKey(x => x.FollowUpRunId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/FollowUpSequenceConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpSequenceConfiguration.cs new file mode 100644 index 0000000..c696010 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpSequenceConfiguration.cs @@ -0,0 +1,22 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class FollowUpSequenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FollowUpSequences"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).HasMaxLength(120).IsRequired(); + builder.HasIndex(x => new { x.OrganizationId, x.SequenceType, x.IsActive }); + + builder.HasMany(x => x.Steps) + .WithOne(x => x.Sequence!) + .HasForeignKey(x => x.FollowUpSequenceId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/FollowUpStepConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpStepConfiguration.cs new file mode 100644 index 0000000..55c5713 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/FollowUpStepConfiguration.cs @@ -0,0 +1,17 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +public class FollowUpStepConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FollowUpSteps"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.MessageTemplate).HasMaxLength(2000).IsRequired(); + builder.HasIndex(x => new { x.FollowUpSequenceId, x.StepOrder }).IsUnique(); + } +} diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 1efdf76..29dc8fa 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -26,6 +26,10 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet SupportHubTickets { get; set; } public DbSet SupportHubSessions { get; set; } public DbSet SupportHubInvites { get; set; } + public DbSet FollowUpSequences { get; set; } + public DbSet FollowUpSteps { get; set; } + public DbSet FollowUpRuns { get; set; } + public DbSet FollowUpExecutionLogs { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.Designer.cs new file mode 100644 index 0000000..0e2271e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.Designer.cs @@ -0,0 +1,3578 @@ +// +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("20260326184716_AddFollowUpAutomation")] + partial class AddFollowUpAutomation + { + /// + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + 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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260326184716_AddFollowUpAutomation.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.cs new file mode 100644 index 0000000..05abfa2 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260326184716_AddFollowUpAutomation.cs @@ -0,0 +1,173 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddFollowUpAutomation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FollowUpSequences", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + SequenceType = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + IsEnabled = table.Column(type: "bit", nullable: false), + StopOnClientReply = table.Column(type: "bit", nullable: false), + DefaultChannel = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FollowUpSequences", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FollowUpRuns", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FollowUpSequenceId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationClientId = table.Column(type: "uniqueidentifier", nullable: false), + TriggerEntityId = table.Column(type: "uniqueidentifier", nullable: false), + SequenceType = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + StopReason = table.Column(type: "int", nullable: false), + NextStepOrder = table.Column(type: "int", nullable: false), + StartedAt = table.Column(type: "datetimeoffset", nullable: false), + LastAttemptAt = table.Column(type: "datetimeoffset", nullable: true), + CompletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FollowUpRuns", x => x.Id); + table.ForeignKey( + name: "FK_FollowUpRuns_FollowUpSequences_FollowUpSequenceId", + column: x => x.FollowUpSequenceId, + principalTable: "FollowUpSequences", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FollowUpSteps", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FollowUpSequenceId = table.Column(type: "uniqueidentifier", nullable: false), + StepOrder = table.Column(type: "int", nullable: false), + DelayHours = table.Column(type: "int", nullable: false), + ChannelOverride = table.Column(type: "int", nullable: true), + MessageTemplate = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + IsEscalation = table.Column(type: "bit", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FollowUpSteps", x => x.Id); + table.ForeignKey( + name: "FK_FollowUpSteps_FollowUpSequences_FollowUpSequenceId", + column: x => x.FollowUpSequenceId, + principalTable: "FollowUpSequences", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FollowUpExecutionLogs", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FollowUpRunId = table.Column(type: "uniqueidentifier", nullable: false), + StepOrder = table.Column(type: "int", nullable: false), + Channel = table.Column(type: "int", nullable: false), + ScheduledFor = table.Column(type: "datetimeoffset", nullable: false), + AttemptedAt = table.Column(type: "datetimeoffset", nullable: false), + WasSent = table.Column(type: "bit", nullable: false), + FailureReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FollowUpExecutionLogs", x => x.Id); + table.ForeignKey( + name: "FK_FollowUpExecutionLogs_FollowUpRuns_FollowUpRunId", + column: x => x.FollowUpRunId, + principalTable: "FollowUpRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FollowUpExecutionLogs_FollowUpRunId_StepOrder", + table: "FollowUpExecutionLogs", + columns: new[] { "FollowUpRunId", "StepOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_FollowUpRuns_FollowUpSequenceId_OrganizationClientId_Status", + table: "FollowUpRuns", + columns: new[] { "FollowUpSequenceId", "OrganizationClientId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_FollowUpRuns_OrganizationId_SequenceType_TriggerEntityId_Status", + table: "FollowUpRuns", + columns: new[] { "OrganizationId", "SequenceType", "TriggerEntityId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_FollowUpSequences_OrganizationId_SequenceType_IsActive", + table: "FollowUpSequences", + columns: new[] { "OrganizationId", "SequenceType", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_FollowUpSteps_FollowUpSequenceId_StepOrder", + table: "FollowUpSteps", + columns: new[] { "FollowUpSequenceId", "StepOrder" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FollowUpExecutionLogs"); + + migrationBuilder.DropTable( + name: "FollowUpSteps"); + + migrationBuilder.DropTable( + name: "FollowUpRuns"); + + migrationBuilder.DropTable( + name: "FollowUpSequences"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 0d852e9..9ab4d21 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1060,6 +1060,228 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EstimateRevisionRequests", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.InventoryItem", b => { b.Property("Id") @@ -2919,6 +3141,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("OrganizationClient"); }); + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => { b.HasOne("JobFlow.Domain.Models.Job", "Job") @@ -3242,6 +3497,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Attachments"); }); + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Invoice", b => { b.Navigation("LineItems"); diff --git a/JobFlow.Tests/FollowUpAutomationServiceTests.cs b/JobFlow.Tests/FollowUpAutomationServiceTests.cs new file mode 100644 index 0000000..e9deed9 --- /dev/null +++ b/JobFlow.Tests/FollowUpAutomationServiceTests.cs @@ -0,0 +1,280 @@ +using JobFlow.Business.Models.DTOs; +using JobFlow.Business.Services; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JobFlow.Tests; + +public class FollowUpAutomationServiceTests +{ + [Fact] + public async Task StartEstimateSequenceAsync_CreatesDefaultSequenceAndSingleActiveRun() + { + var orgId = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var estimateId = Guid.NewGuid(); + var unitOfWork = CreateUnitOfWork(nameof(StartEstimateSequenceAsync_CreatesDefaultSequenceAndSingleActiveRun)); + + await SeedOrganizationClientEstimateAsync(unitOfWork, orgId, clientId, estimateId, EstimateStatus.Sent); + + var service = CreateService(unitOfWork); + + var first = await service.StartEstimateSequenceAsync(orgId, estimateId, clientId); + var second = await service.StartEstimateSequenceAsync(orgId, estimateId, clientId); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + + var sequenceCount = await unitOfWork.RepositoryOf() + .Query() + .CountAsync(x => x.OrganizationId == orgId && x.SequenceType == FollowUpSequenceType.Estimate); + + var runCount = await unitOfWork.RepositoryOf() + .Query() + .CountAsync(x => x.OrganizationId == orgId + && x.TriggerEntityId == estimateId + && (x.Status == FollowUpRunStatus.Scheduled || x.Status == FollowUpRunStatus.InProgress)); + + Assert.Equal(1, sequenceCount); + Assert.Equal(1, runCount); + } + + [Fact] + public async Task StopEstimateSequencesOnClientReplyAsync_StopsOnlySequencesConfiguredToStop() + { + var orgId = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var estimateIdA = Guid.NewGuid(); + var estimateIdB = Guid.NewGuid(); + var estimateIdC = Guid.NewGuid(); + var estimateIdD = Guid.NewGuid(); + + var unitOfWork = CreateUnitOfWork(nameof(StopEstimateSequencesOnClientReplyAsync_StopsOnlySequencesConfiguredToStop)); + + await EnsureOrganizationAsync(unitOfWork, orgId, "Reply Org"); + await EnsureClientAsync(unitOfWork, orgId, clientId); + + var stopSequence = new FollowUpSequence + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + SequenceType = FollowUpSequenceType.Estimate, + Name = "Stop On Reply", + IsEnabled = true, + StopOnClientReply = true + }; + + var keepSequence = new FollowUpSequence + { + Id = Guid.NewGuid(), + OrganizationId = orgId, + SequenceType = FollowUpSequenceType.Estimate, + Name = "Do Not Stop", + IsEnabled = true, + StopOnClientReply = false + }; + + await unitOfWork.RepositoryOf().AddRangeAsync(new[] { stopSequence, keepSequence }); + await unitOfWork.SaveChangesAsync(); + + var runShouldStopScheduled = new FollowUpRun + { + Id = Guid.NewGuid(), + FollowUpSequenceId = stopSequence.Id, + OrganizationId = orgId, + OrganizationClientId = clientId, + TriggerEntityId = estimateIdA, + SequenceType = FollowUpSequenceType.Estimate, + Status = FollowUpRunStatus.Scheduled, + NextStepOrder = 1 + }; + + var runShouldStopInProgress = new FollowUpRun + { + Id = Guid.NewGuid(), + FollowUpSequenceId = stopSequence.Id, + OrganizationId = orgId, + OrganizationClientId = clientId, + TriggerEntityId = estimateIdB, + SequenceType = FollowUpSequenceType.Estimate, + Status = FollowUpRunStatus.InProgress, + NextStepOrder = 2 + }; + + var runAlreadyCompleted = new FollowUpRun + { + Id = Guid.NewGuid(), + FollowUpSequenceId = stopSequence.Id, + OrganizationId = orgId, + OrganizationClientId = clientId, + TriggerEntityId = estimateIdC, + SequenceType = FollowUpSequenceType.Estimate, + Status = FollowUpRunStatus.Completed, + StopReason = FollowUpStopReason.None, + CompletedAt = DateTimeOffset.UtcNow, + NextStepOrder = 3 + }; + + var runShouldRemainActive = new FollowUpRun + { + Id = Guid.NewGuid(), + FollowUpSequenceId = keepSequence.Id, + OrganizationId = orgId, + OrganizationClientId = clientId, + TriggerEntityId = estimateIdD, + SequenceType = FollowUpSequenceType.Estimate, + Status = FollowUpRunStatus.Scheduled, + NextStepOrder = 1 + }; + + await unitOfWork.RepositoryOf().AddRangeAsync(new[] + { + runShouldStopScheduled, + runShouldStopInProgress, + runAlreadyCompleted, + runShouldRemainActive + }); + await unitOfWork.SaveChangesAsync(); + + var service = CreateService(unitOfWork); + var result = await service.StopEstimateSequencesOnClientReplyAsync(orgId, clientId); + + Assert.True(result.IsSuccess); + + var runs = await unitOfWork.RepositoryOf() + .QueryWithNoTracking() + .Where(x => x.OrganizationId == orgId && x.OrganizationClientId == clientId) + .ToListAsync(); + + var stoppedScheduled = runs.Single(x => x.Id == runShouldStopScheduled.Id); + var stoppedInProgress = runs.Single(x => x.Id == runShouldStopInProgress.Id); + var unchangedCompleted = runs.Single(x => x.Id == runAlreadyCompleted.Id); + var unchangedActive = runs.Single(x => x.Id == runShouldRemainActive.Id); + + Assert.Equal(FollowUpRunStatus.Stopped, stoppedScheduled.Status); + Assert.Equal(FollowUpStopReason.ClientReplied, stoppedScheduled.StopReason); + Assert.NotNull(stoppedScheduled.CompletedAt); + + Assert.Equal(FollowUpRunStatus.Stopped, stoppedInProgress.Status); + Assert.Equal(FollowUpStopReason.ClientReplied, stoppedInProgress.StopReason); + Assert.NotNull(stoppedInProgress.CompletedAt); + + Assert.Equal(FollowUpRunStatus.Completed, unchangedCompleted.Status); + Assert.Equal(FollowUpRunStatus.Scheduled, unchangedActive.Status); + Assert.Equal(FollowUpStopReason.None, unchangedActive.StopReason); + } + + private static FollowUpAutomationService CreateService(JobFlowUnitOfWork unitOfWork) + { + return new FollowUpAutomationService( + unitOfWork, + new NoOpNotificationService(), + NullLogger.Instance, + scheduler: null); + } + + private static async Task SeedOrganizationClientEstimateAsync( + JobFlowUnitOfWork unitOfWork, + Guid orgId, + Guid clientId, + Guid estimateId, + EstimateStatus estimateStatus) + { + await EnsureOrganizationAsync(unitOfWork, orgId, "Estimate Org"); + await EnsureClientAsync(unitOfWork, orgId, clientId); + + var estimate = new Estimate + { + Id = estimateId, + OrganizationId = orgId, + OrganizationClientId = clientId, + EstimateNumber = "EST-0001", + PublicToken = Guid.NewGuid().ToString("N"), + Status = estimateStatus, + SentAt = estimateStatus == EstimateStatus.Sent ? DateTimeOffset.UtcNow : null + }; + + await unitOfWork.RepositoryOf().AddAsync(estimate); + await unitOfWork.SaveChangesAsync(); + } + + private static async Task EnsureOrganizationAsync(JobFlowUnitOfWork unitOfWork, Guid organizationId, string name) + { + var organization = new Organization + { + Id = organizationId, + OrganizationTypeId = Guid.NewGuid(), + OrganizationName = name, + IsActive = true + }; + + await unitOfWork.RepositoryOf().AddAsync(organization); + await unitOfWork.SaveChangesAsync(); + } + + private static async Task EnsureClientAsync(JobFlowUnitOfWork unitOfWork, Guid organizationId, Guid clientId) + { + var client = new OrganizationClient + { + Id = clientId, + OrganizationId = organizationId, + FirstName = "Pat", + LastName = "Client", + PhoneNumber = "+15555555555", + IsActive = true + }; + + await unitOfWork.RepositoryOf().AddAsync(client); + await unitOfWork.SaveChangesAsync(); + } + + private static JobFlowUnitOfWork CreateUnitOfWork(string databaseName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + var factory = new TestDbContextFactory(options); + return new JobFlowUnitOfWork(NullLogger.Instance, factory); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public JobFlowDbContext CreateDbContext() + { + return new JobFlowDbContext(_options); + } + } + + private sealed class NoOpNotificationService : INotificationService + { + public Task SendOrganizationWelcomeNotificationAsync(Organization organization) => Task.CompletedTask; + public Task SendOrganizationSubsciptionPaymentFailedNotificationAsync(Organization organization) => Task.CompletedTask; + public Task SendOrganizationPaymentReceivedNotificationAsync(Organization organization) => Task.CompletedTask; + public Task SendClientWelcomeNotificationAsync(OrganizationClient client) => Task.CompletedTask; + public Task SendClientJobCreatedNotificationAsync(OrganizationClient client, Job job) => Task.CompletedTask; + public Task SendClientJobScheduledNotificationAsync(OrganizationClient client, Job job) => Task.CompletedTask; + public Task SendClientJobRescheduledNotificationAsync(OrganizationClient client, Job job, DateTimeOffset previousStart, DateTimeOffset? previousEnd, DateTimeOffset newStart, DateTimeOffset? newEnd) => Task.CompletedTask; + public Task SendClientInvoiceCreatedNotificationAsync(OrganizationClient client, Invoice invoice, string? linkOverride = null) => Task.CompletedTask; + public Task SendClientInvoiceReminderNotificationAsync(OrganizationClient client, Invoice invoice, string? linkOverride = null) => Task.CompletedTask; + public Task SendClientPaymentReceivedNotificationAsync(OrganizationClient client, Invoice invoice) => Task.CompletedTask; + public Task SendClientJobTrackingEtaNotificationAsync(OrganizationClient client, Job job, int etaMinutes) => Task.CompletedTask; + public Task SendClientJobTrackingArrivalNotificationAsync(OrganizationClient client, Job job) => Task.CompletedTask; + public Task SendClientEstimateSentNotificationAsync(OrganizationClient client, Estimate estimate) => Task.CompletedTask; + public Task SendClientEstimateFollowUpNotificationAsync(OrganizationClient client, Estimate estimate, string message) => Task.CompletedTask; + public Task SendOrganizationEstimateRevisionRequestedNotificationAsync(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage) => Task.CompletedTask; + public Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink) => Task.CompletedTask; + public Task SendEmployeeInviteNotificationAsync(EmployeeInvite invite) => Task.CompletedTask; + } +} From 9a8d7db4c0b2e3e475b2fe95b8bc00b370728b03 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Fri, 27 Mar 2026 10:49:14 -0400 Subject: [PATCH 19/26] feat(followup): Add FollowUp and Square --- JobFlow.API/Controllers/PaymentController.cs | 49 +- .../Controllers/StripePaymentController.cs | 116 - .../IPaymentProcessorFactory.cs | 1 + .../Services/OrganizationService.cs | 22 + .../Services/PaymentProfileService.cs | 77 + .../ServiceInterfaces/IOrganizationService.cs | 2 + .../IPaymentProfileService.cs | 9 + .../Models/CustomerPaymentProfile.cs | 12 + JobFlow.Domain/Models/Organization.cs | 9 +- ...327131838_AddSquareTokenFields.Designer.cs | 3596 +++++++++++++++++ .../20260327131838_AddSquareTokenFields.cs | 88 + .../JobFlowDbContextModelSnapshot.cs | 18 + .../PaymentProcessorFactory.cs | 18 + .../Square/SquarePaymentProcessor.cs | 59 +- .../Square/SquareTokenEncryptionService.cs | 25 + .../Square/SquareTokenRefreshService.cs | 121 + .../Square/SquareWebhookService.cs | 90 +- .../Stripe/StripePaymentProcessor.cs | 13 +- .../Stripe/StripeWebhookService.cs | 72 +- 19 files changed, 4228 insertions(+), 169 deletions(-) delete mode 100644 JobFlow.API/Controllers/StripePaymentController.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.cs create mode 100644 JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenEncryptionService.cs create mode 100644 JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenRefreshService.cs diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index 60a6cb6..ed2d277 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -35,6 +35,7 @@ public class PaymentController : ControllerBase private readonly IStripeSettings _stripeSettings; private readonly ISquareSettings _squareSettings; private readonly ISquareWebhookService _squareWebhookService; + private readonly ISquareTokenEncryptionService _squareTokenEncryption; private readonly IHostEnvironment _hostEnvironment; private readonly IFrontendSettings _frontEndSettings; @@ -48,6 +49,7 @@ public PaymentController( IInvoiceService invoiceService, IStripeSettings stripeSettings, ISquareSettings squareSettings, + ISquareTokenEncryptionService squareTokenEncryption, IHostEnvironment hostEnvironment, IFrontendSettings frontEndSettings) { @@ -60,6 +62,7 @@ public PaymentController( _invoiceService = invoiceService; _stripeSettings = stripeSettings; _squareSettings = squareSettings; + _squareTokenEncryption = squareTokenEncryption; _hostEnvironment = hostEnvironment; _frontEndSettings = frontEndSettings; } @@ -114,7 +117,9 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque } } - var processor = _processorFactory.GetProcessor(provider); + var processor = provider == PaymentProvider.Square + ? await _processorFactory.GetProcessorForOrgAsync(orgId, provider) + : _processorFactory.GetProcessor(provider); string checkoutUrl; if (request.Mode == "subscription") @@ -231,7 +236,9 @@ public async Task RefundPayment([FromBody] PaymentRefundRequestDt if (orgResult.IsFailure) return NotFound(orgResult.Error); - var processor = _processorFactory.GetProcessor(request.Provider); + var processor = request.Provider == PaymentProvider.Square + ? await _processorFactory.GetProcessorForOrgAsync(orgId, request.Provider) + : _processorFactory.GetProcessor(request.Provider); if (processor is not IPaymentOperationsProcessor ops) return BadRequest("Processor does not support refund operations."); @@ -257,7 +264,9 @@ public async Task AdjustPayment([FromBody] PaymentAdjustmentReque if (orgResult.IsFailure) return NotFound(orgResult.Error); - var processor = _processorFactory.GetProcessor(request.Provider); + var processor = request.Provider == PaymentProvider.Square + ? await _processorFactory.GetProcessorForOrgAsync(orgId, request.Provider) + : _processorFactory.GetProcessor(request.Provider); if (processor is not IPaymentOperationsProcessor ops) return BadRequest("Processor does not support adjustment operations."); @@ -285,7 +294,9 @@ public async Task CreateDeposit([FromBody] DepositPaymentRequestD if (orgResult.IsFailure) return NotFound(orgResult.Error); - var processor = _processorFactory.GetProcessor(request.Provider); + var processor = request.Provider == PaymentProvider.Square + ? await _processorFactory.GetProcessorForOrgAsync(orgId, request.Provider) + : _processorFactory.GetProcessor(request.Provider); if (processor is not IPaymentOperationsProcessor ops) return BadRequest("Processor does not support deposit operations."); @@ -454,25 +465,49 @@ public async Task HandleSquareCallback( } using var tokenDocument = await JsonDocument.ParseAsync(await tokenResponse.Content.ReadAsStreamAsync()); - var merchantId = tokenDocument.RootElement.TryGetProperty("merchant_id", out var merchantElement) + var root = tokenDocument.RootElement; + + var merchantId = root.TryGetProperty("merchant_id", out var merchantElement) ? merchantElement.GetString() : null; + var accessToken = root.TryGetProperty("access_token", out var atEl) + ? atEl.GetString() + : null; + var refreshToken = root.TryGetProperty("refresh_token", out var rtEl) + ? rtEl.GetString() + : null; + var expiresAt = root.TryGetProperty("expires_at", out var expEl) + ? expEl.GetString() + : null; if (string.IsNullOrWhiteSpace(merchantId)) return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString("Square merchant id was not returned.")}"); + if (string.IsNullOrWhiteSpace(accessToken)) + return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString("Square access token was not returned.")}"); + var orgResult = await _organizationService.GetOrganiztionById(organizationId); if (orgResult.IsFailure) return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString("Organization not found.")}"); var organization = orgResult.Value; organization.PaymentProvider = PaymentProvider.Square; + organization.SquareMerchantId = merchantId; + organization.IsSquareConnected = true; - var profileResult = await _paymentProfileService.UpsertAsync( + var tokenExpiresAtUtc = !string.IsNullOrWhiteSpace(expiresAt) + ? DateTime.Parse(expiresAt).ToUniversalTime() + : DateTime.UtcNow.AddDays(30); + + var profileResult = await _paymentProfileService.UpsertWithTokensAsync( organizationId, PaymentEntityType.Organization, PaymentProvider.Square, - merchantId); + merchantId, + _squareTokenEncryption.Encrypt(accessToken), + string.IsNullOrWhiteSpace(refreshToken) ? string.Empty : _squareTokenEncryption.Encrypt(refreshToken), + tokenExpiresAtUtc, + null); if (profileResult.IsFailure) return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString(profileResult.Error?.ToString() ?? "Unable to save Square payment profile.")}"); diff --git a/JobFlow.API/Controllers/StripePaymentController.cs b/JobFlow.API/Controllers/StripePaymentController.cs deleted file mode 100644 index 967542c..0000000 --- a/JobFlow.API/Controllers/StripePaymentController.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Stripe; -using Stripe.Checkout; - -namespace JobFlow.API.Controllers; - -[Route("api/stripe/")] -[ApiController] -public class StripePaymentController : ControllerBase -{ - [HttpPost] - [Route("create")] - public IActionResult CreateAccount() - { - try - { - var service = new AccountService(); - var options = new AccountCreateOptions - { - Type = "express", - Country = "US", - Capabilities = new AccountCapabilitiesOptions - { - CardPayments = new AccountCapabilitiesCardPaymentsOptions { Requested = true }, - Transfers = new AccountCapabilitiesTransfersOptions { Requested = true } - } - }; - - var account = service.Create(options); - - // Store account.Id in your DB here - - return Ok(new { accountId = account.Id }); - } - catch (StripeException stripeEx) - { - return StatusCode(500, new { error = stripeEx.Message }); - } - catch (Exception) - { - return StatusCode(500, new { error = "An unexpected error occurred." }); - } - } - - [HttpPost] - [Route("generate-account-link")] - public ActionResult GenerateAccountLink([FromBody] AccountLinkPostBody accountLinkPostBody) - { - try - { - var connectedAccountId = accountLinkPostBody.Account; - var service = new AccountLinkService(); - - var accountLink = service.Create(new AccountLinkCreateOptions - { - Account = connectedAccountId, - ReturnUrl = $"http://localhost:4200/dashboard/stripe-success/{connectedAccountId}", - RefreshUrl = $"http://localhost:4200/dashboard/stripe-failed/{connectedAccountId}", - Type = "account_onboarding" - }); - - return Ok(new { url = accountLink.Url }); - } - catch (Exception ex) - { - Console.WriteLine("Stripe error: " + ex.Message); - return StatusCode(500, new { error = ex.Message }); - } - } - - [HttpPost] - [Route("create-checkout-session")] - public async Task CreateCheckoutSession() - { - var options = new SessionCreateOptions - { - LineItems = new List - { - new() - { - PriceData = new SessionLineItemPriceDataOptions - { - Currency = "usd", - ProductData = new SessionLineItemPriceDataProductDataOptions - { - Name = "T-shirt" - }, - UnitAmount = 1000 // Amount in cents ($10.00) - }, - Quantity = 1 - } - }, - PaymentIntentData = new SessionPaymentIntentDataOptions - { - ApplicationFeeAmount = 75 // Platform fee in cents ($1.23) - }, - Mode = "payment", - SuccessUrl = "https://example.com/success?session_id={CHECKOUT_SESSION_ID}" - }; - - var requestOptions = new RequestOptions - { - StripeAccount = "{{CONNECTED_ACCOUNT_ID}}" // Set this dynamically if needed - }; - - var service = new SessionService(); - var session = await service.CreateAsync(options, requestOptions); - - return Ok(new { url = session.Url }); - } -} - -public class AccountLinkPostBody -{ - public string Account { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/JobFlow.Business/PaymentGateways/IPaymentProcessorFactory.cs b/JobFlow.Business/PaymentGateways/IPaymentProcessorFactory.cs index 4780009..80afcb3 100644 --- a/JobFlow.Business/PaymentGateways/IPaymentProcessorFactory.cs +++ b/JobFlow.Business/PaymentGateways/IPaymentProcessorFactory.cs @@ -6,4 +6,5 @@ public interface IPaymentProcessorFactory { IPaymentProcessor GetProcessor(string provider); IPaymentProcessor GetProcessor(PaymentProvider provider); + Task GetProcessorForOrgAsync(Guid organizationId, PaymentProvider provider); } \ No newline at end of file diff --git a/JobFlow.Business/Services/OrganizationService.cs b/JobFlow.Business/Services/OrganizationService.cs index b4025c0..2c5c458 100644 --- a/JobFlow.Business/Services/OrganizationService.cs +++ b/JobFlow.Business/Services/OrganizationService.cs @@ -109,6 +109,28 @@ await _onboardingService.MarkStepCompleteAsync( ); } + public async Task> GetBySquareMerchantIdAsync(string squareMerchantId) + { + var org = await _organizations + .FirstOrDefaultAsync(o => o.SquareMerchantId == squareMerchantId); + + return org is null + ? Result.Failure(OrganizationErrors.OrganizationNotFound) + : Result.Success(org); + } + + public async Task MarkSquareDisconnectedAsync(string squareMerchantId) + { + var org = await _organizations + .FirstOrDefaultAsync(o => o.SquareMerchantId == squareMerchantId); + + if (org == null) + return; + + org.IsSquareConnected = false; + await _unitOfWork.SaveChangesAsync(); + } + public async Task> UpsertOrganization(Organization model) { if (model.Id == Guid.Empty) diff --git a/JobFlow.Business/Services/PaymentProfileService.cs b/JobFlow.Business/Services/PaymentProfileService.cs index 281bd05..cf240d3 100644 --- a/JobFlow.Business/Services/PaymentProfileService.cs +++ b/JobFlow.Business/Services/PaymentProfileService.cs @@ -36,6 +36,21 @@ public async Task> GetForOrganizationAsync(Guid o : Result.Success(profile); } + public async Task> GetForOrganizationAsync(Guid organizationId, PaymentProvider provider) + { + if (organizationId == Guid.Empty) + return Result.Failure(OrganizationErrors.NullOrEmptyId); + + var profile = await paymentProfiles.Query() + .FirstOrDefaultAsync(p => p.OwnerId == organizationId + && p.OwnerType == PaymentEntityType.Organization + && p.Provider == provider); + + return profile is null + ? Result.Failure(PaymentProfileErrors.NotFound) + : Result.Success(profile); + } + public async Task> GetForClientAsync(Guid clientId) { if (clientId == Guid.Empty) @@ -108,4 +123,66 @@ public async Task SetDefaultPaymentMethodAsync(Guid profileId, string pa return Result.Success(); } + + public async Task> UpsertWithTokensAsync( + Guid ownerId, PaymentEntityType ownerType, PaymentProvider provider, string providerCustomerId, + string encryptedAccessToken, string encryptedRefreshToken, DateTime tokenExpiresAtUtc, + string? squareLocationId) + { + if (ownerId == Guid.Empty) + return Result.Failure(PaymentProfileErrors.NullOrEmptyOwnerId); + + if (string.IsNullOrWhiteSpace(providerCustomerId)) + return Result.Failure(PaymentProfileErrors.ProviderCustomerIdMissing); + + var existing = await paymentProfiles.Query() + .FirstOrDefaultAsync(p => p.OwnerId == ownerId && p.OwnerType == ownerType && p.Provider == provider); + + if (existing != null) + { + existing.ProviderCustomerId = providerCustomerId; + existing.EncryptedAccessToken = encryptedAccessToken; + existing.EncryptedRefreshToken = encryptedRefreshToken; + existing.TokenExpiresAtUtc = tokenExpiresAtUtc; + existing.SquareLocationId = squareLocationId; + existing.UpdatedAt = DateTime.UtcNow; + await unitOfWork.SaveChangesAsync(); + return Result.Success(existing); + } + + var profile = new CustomerPaymentProfile + { + Id = Guid.NewGuid(), + OwnerId = ownerId, + OwnerType = ownerType, + Provider = provider, + ProviderCustomerId = providerCustomerId, + EncryptedAccessToken = encryptedAccessToken, + EncryptedRefreshToken = encryptedRefreshToken, + TokenExpiresAtUtc = tokenExpiresAtUtc, + SquareLocationId = squareLocationId, + CreatedAt = DateTime.UtcNow + }; + + paymentProfiles.Add(profile); + await unitOfWork.SaveChangesAsync(); + + return Result.Success(profile); + } + + public async Task UpdateTokensAsync(Guid profileId, string encryptedAccessToken, + string encryptedRefreshToken, DateTime tokenExpiresAtUtc) + { + var profile = await paymentProfiles.Query().FirstOrDefaultAsync(p => p.Id == profileId); + if (profile == null) + return Result.Failure(PaymentProfileErrors.NotFound); + + profile.EncryptedAccessToken = encryptedAccessToken; + profile.EncryptedRefreshToken = encryptedRefreshToken; + profile.TokenExpiresAtUtc = tokenExpiresAtUtc; + profile.UpdatedAt = DateTime.UtcNow; + await unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs index 488fc2f..82b02b0 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IOrganizationService.cs @@ -11,5 +11,7 @@ public interface IOrganizationService Task> UpsertOrganization(Organization model); Task> UpdateIndustryAsync(Guid organizationId, string? industryKey); Task MarkStripeConnectedAsync(string stripeAccountId); + Task> GetBySquareMerchantIdAsync(string squareMerchantId); + Task MarkSquareDisconnectedAsync(string squareMerchantId); Task DeleteOrganization(Guid organizationId); } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs index 612b925..e1e9ff6 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs @@ -6,6 +6,7 @@ namespace JobFlow.Business.Services.ServiceInterfaces; public interface IPaymentProfileService { Task> GetForOrganizationAsync(Guid organizationId); + Task> GetForOrganizationAsync(Guid organizationId, PaymentProvider provider); Task> GetForClientAsync(Guid clientId); Task> CreateAsync(Guid ownerId, PaymentEntityType ownerType, @@ -14,5 +15,13 @@ Task> CreateAsync(Guid ownerId, PaymentEntityType Task> UpsertAsync(Guid ownerId, PaymentEntityType ownerType, PaymentProvider provider, string providerCustomerId); + Task> UpsertWithTokensAsync( + Guid ownerId, PaymentEntityType ownerType, PaymentProvider provider, string providerCustomerId, + string encryptedAccessToken, string encryptedRefreshToken, DateTime tokenExpiresAtUtc, + string? squareLocationId); + Task SetDefaultPaymentMethodAsync(Guid profileId, string paymentMethodId); + + Task UpdateTokensAsync(Guid profileId, string encryptedAccessToken, + string encryptedRefreshToken, DateTime tokenExpiresAtUtc); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/CustomerPaymentProfile.cs b/JobFlow.Domain/Models/CustomerPaymentProfile.cs index 83c39f1..27ddeca 100644 --- a/JobFlow.Domain/Models/CustomerPaymentProfile.cs +++ b/JobFlow.Domain/Models/CustomerPaymentProfile.cs @@ -11,6 +11,18 @@ public class CustomerPaymentProfile : Entity public string? DefaultPaymentMethodId { get; set; } public bool IsDelinquent { get; set; } = false; + /// Square OAuth access token (encrypted at rest). + public string? EncryptedAccessToken { get; set; } + + /// Square OAuth refresh token (encrypted at rest). + public string? EncryptedRefreshToken { get; set; } + + /// When the current access token expires (UTC). + public DateTime? TokenExpiresAtUtc { get; set; } + + /// Square location id chosen during onboarding. + public string? SquareLocationId { get; set; } + public Guid? OrganizationClientId { get; set; } public Guid? OrganizationId { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Organization.cs b/JobFlow.Domain/Models/Organization.cs index a2fd691..6c3bf7f 100644 --- a/JobFlow.Domain/Models/Organization.cs +++ b/JobFlow.Domain/Models/Organization.cs @@ -19,6 +19,8 @@ public class Organization : Entity public bool OnBoardingComplete { get; set; } public string? StripeConnectAccountId { get; set; } public bool IsStripeConnected { get; set; } = false; + public string? SquareMerchantId { get; set; } + public bool IsSquareConnected { get; set; } = false; public string? OnboardingTrack { get; set; } public string? OnboardingPresetKey { get; set; } public DateTimeOffset? OnboardingTrackSelectedAt { get; set; } @@ -26,8 +28,11 @@ public class Organization : Entity public string? IndustryKey { get; set; } public bool CanAcceptPayments => - PaymentProvider == PaymentProvider.Stripe && - !string.IsNullOrWhiteSpace(StripeConnectAccountId); + (PaymentProvider == PaymentProvider.Stripe && + !string.IsNullOrWhiteSpace(StripeConnectAccountId)) || + (PaymentProvider == PaymentProvider.Square && + IsSquareConnected && + !string.IsNullOrWhiteSpace(SquareMerchantId)); public PaymentProvider PaymentProvider { get; set; } = PaymentProvider.Stripe; public ICollection PaymentProfiles { get; set; } = new List(); diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.Designer.cs new file mode 100644 index 0000000..c228be5 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.Designer.cs @@ -0,0 +1,3596 @@ +// +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("20260327131838_AddSquareTokenFields")] + partial class AddSquareTokenFields + { + /// + 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("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .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("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSquareConnected") + .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("SquareMerchantId") + .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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260327131838_AddSquareTokenFields.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.cs new file mode 100644 index 0000000..2e14c4c --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327131838_AddSquareTokenFields.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSquareTokenFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsSquareConnected", + table: "Organization", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SquareMerchantId", + table: "Organization", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "EncryptedAccessToken", + schema: "payment", + table: "CustomerPaymentProfile", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "EncryptedRefreshToken", + schema: "payment", + table: "CustomerPaymentProfile", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "SquareLocationId", + schema: "payment", + table: "CustomerPaymentProfile", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TokenExpiresAtUtc", + schema: "payment", + table: "CustomerPaymentProfile", + type: "datetime2", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsSquareConnected", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "SquareMerchantId", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "EncryptedAccessToken", + schema: "payment", + table: "CustomerPaymentProfile"); + + migrationBuilder.DropColumn( + name: "EncryptedRefreshToken", + schema: "payment", + table: "CustomerPaymentProfile"); + + migrationBuilder.DropColumn( + name: "SquareLocationId", + schema: "payment", + table: "CustomerPaymentProfile"); + + migrationBuilder.DropColumn( + name: "TokenExpiresAtUtc", + schema: "payment", + table: "CustomerPaymentProfile"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 9ab4d21..92e92c0 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -272,6 +272,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DefaultPaymentMethodId") .HasColumnType("nvarchar(max)"); + b.Property("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .HasColumnType("nvarchar(max)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -297,6 +303,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("nvarchar(max)"); + b.Property("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + b.Property("UpdatedAt") .HasColumnType("datetime2"); @@ -1927,6 +1939,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("bit"); + b.Property("IsSquareConnected") + .HasColumnType("bit"); + b.Property("IsStripeConnected") .HasColumnType("bit"); @@ -1957,6 +1972,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PhoneNumber") .HasColumnType("nvarchar(max)"); + b.Property("SquareMerchantId") + .HasColumnType("nvarchar(max)"); + b.Property("State") .HasColumnType("nvarchar(max)"); diff --git a/JobFlow.Infrastructure/PaymentGateways/PaymentProcessorFactory.cs b/JobFlow.Infrastructure/PaymentGateways/PaymentProcessorFactory.cs index 269ec8d..a0b72e8 100644 --- a/JobFlow.Infrastructure/PaymentGateways/PaymentProcessorFactory.cs +++ b/JobFlow.Infrastructure/PaymentGateways/PaymentProcessorFactory.cs @@ -1,6 +1,7 @@ using JobFlow.Business.DI; using JobFlow.Business.PaymentGateways; using JobFlow.Domain.Enums; +using JobFlow.Infrastructure.PaymentGateways.Square; using JobFlow.Infrastructure.PaymentGateways.SquarePayment; using JobFlow.Infrastructure.PaymentGateways.Stripe; using Microsoft.Extensions.DependencyInjection; @@ -31,4 +32,21 @@ public IPaymentProcessor GetProcessor(PaymentProvider provider) { return GetProcessor(provider.ToString()); } + + public async Task GetProcessorForOrgAsync(Guid organizationId, PaymentProvider provider) + { + if (provider != PaymentProvider.Square) + return GetProcessor(provider); + + var processor = _serviceProvider.GetRequiredService(); + var refreshService = _serviceProvider.GetRequiredService(); + + var tokenSet = await refreshService.RefreshIfNeededAsync(organizationId); + if (tokenSet != null) + { + processor.ConfigureForOrganization(tokenSet.AccessToken, null); + } + + return processor; + } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index c3ba9b3..19ea474 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -13,31 +13,56 @@ namespace JobFlow.Infrastructure.PaymentGateways.SquarePayment; [ScopedService] public class SquarePaymentProcessor : IPaymentProcessor, IPaymentOperationsProcessor { - private readonly SquareClient _client; - private readonly string _accessToken; - private readonly string _locationId; + private readonly ISquareSettings _settings; + private readonly IHostEnvironment _hostEnvironment; private readonly string _baseUrl; + // Per-org token override (set by the factory when resolving for a specific org) + private string? _orgAccessToken; + private string? _orgLocationId; + public SquarePaymentProcessor(ISquareSettings settings, IHostEnvironment hostEnvironment) { ArgumentNullException.ThrowIfNull(settings); - if (string.IsNullOrWhiteSpace(settings.AccessToken)) - throw new InvalidOperationException("Square access token is not configured."); - - _accessToken = settings.AccessToken; + _settings = settings; + _hostEnvironment = hostEnvironment; _baseUrl = hostEnvironment.IsDevelopment() ? "https://connect.squareupsandbox.com" : "https://connect.squareup.com"; + } + + /// + /// Configure this processor instance to use a specific org's OAuth token and location. + /// + public void ConfigureForOrganization(string accessToken, string? locationId) + { + _orgAccessToken = accessToken; + _orgLocationId = locationId; + } + + private string ResolveAccessToken() + { + var token = _orgAccessToken ?? _settings.AccessToken; + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Square access token is not configured."); + return token; + } - _client = new SquareClient(settings.AccessToken, new ClientOptions + private string ResolveLocationId() + { + var locationId = _orgLocationId ?? _settings.LocationId; + if (string.IsNullOrWhiteSpace(locationId)) + throw new InvalidOperationException("Square location id is not configured."); + return locationId; + } + + private SquareClient CreateSquareClient() + { + return new SquareClient(ResolveAccessToken(), new ClientOptions { BaseUrl = _baseUrl }); - - _locationId = settings.LocationId ?? string.Empty; - if (string.IsNullOrWhiteSpace(_locationId)) - throw new InvalidOperationException("Square location id is not configured."); } public async Task CreateCheckoutSessionAsync(PaymentSessionRequest request) @@ -60,7 +85,7 @@ public async Task CreateCheckoutSessionAsync(PaymentSessionRequest reque { Name = request.ProductName, PriceMoney = money, - LocationId = _locationId + LocationId = ResolveLocationId() }; var paymentLinkRequest = new CreatePaymentLinkRequest @@ -77,14 +102,15 @@ public async Task CreateCheckoutSessionAsync(PaymentSessionRequest reque try { - var result = await _client.Checkout.PaymentLinks.CreateAsync(paymentLinkRequest); + var client = CreateSquareClient(); + var result = await client.Checkout.PaymentLinks.CreateAsync(paymentLinkRequest); return result.PaymentLink?.Url ?? throw new Exception("Square returned an empty payment link."); } catch (SquareApiException ex) { throw new Exception($"Square API error: {ex.Message}", ex); } - catch (Exception ex) + catch (Exception ex) when (ex is not InvalidOperationException) { throw new Exception("An error occurred while creating the Square checkout session.", ex); } @@ -167,12 +193,13 @@ private async Task CreateCheckoutIntentAsync(PaymentSessio private HttpClient CreateApiClient() { + var accessToken = ResolveAccessToken(); var httpClient = new HttpClient { BaseAddress = new Uri($"{_baseUrl}/") }; httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); httpClient.DefaultRequestHeaders.Add("Square-Version", "2025-10-16"); return httpClient; } diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenEncryptionService.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenEncryptionService.cs new file mode 100644 index 0000000..cf99810 --- /dev/null +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenEncryptionService.cs @@ -0,0 +1,25 @@ +using JobFlow.Business.DI; +using Microsoft.AspNetCore.DataProtection; + +namespace JobFlow.Infrastructure.PaymentGateways.Square; + +public interface ISquareTokenEncryptionService +{ + string Encrypt(string plainText); + string Decrypt(string cipherText); +} + +[SingletonService] +public class SquareTokenEncryptionService : ISquareTokenEncryptionService +{ + private readonly IDataProtector _protector; + + public SquareTokenEncryptionService(IDataProtectionProvider provider) + { + _protector = provider.CreateProtector("JobFlow.Square.OAuthTokens"); + } + + public string Encrypt(string plainText) => _protector.Protect(plainText); + + public string Decrypt(string cipherText) => _protector.Unprotect(cipherText); +} diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenRefreshService.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenRefreshService.cs new file mode 100644 index 0000000..2dda03c --- /dev/null +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquareTokenRefreshService.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using System.Text.Json; +using JobFlow.Business.DI; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Enums; +using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace JobFlow.Infrastructure.PaymentGateways.Square; + +public interface ISquareTokenRefreshService +{ + Task RefreshIfNeededAsync(Guid organizationId); +} + +public record SquareTokenSet(string AccessToken, string RefreshToken, DateTime ExpiresAtUtc); + +[ScopedService] +public class SquareTokenRefreshService : ISquareTokenRefreshService +{ + private readonly IPaymentProfileService _paymentProfileService; + private readonly ISquareTokenEncryptionService _encryption; + private readonly ISquareSettings _settings; + private readonly IHostEnvironment _hostEnvironment; + private readonly ILogger _logger; + + public SquareTokenRefreshService( + IPaymentProfileService paymentProfileService, + ISquareTokenEncryptionService encryption, + ISquareSettings settings, + IHostEnvironment hostEnvironment, + ILogger logger) + { + _paymentProfileService = paymentProfileService; + _encryption = encryption; + _settings = settings; + _hostEnvironment = hostEnvironment; + _logger = logger; + } + + public async Task RefreshIfNeededAsync(Guid organizationId) + { + var profileResult = await _paymentProfileService.GetForOrganizationAsync(organizationId, PaymentProvider.Square); + if (profileResult.IsFailure) + return null; + + var profile = profileResult.Value; + if (string.IsNullOrWhiteSpace(profile.EncryptedAccessToken)) + return null; + + var accessToken = _encryption.Decrypt(profile.EncryptedAccessToken); + + // If token is still valid for > 5 minutes, return as-is + if (profile.TokenExpiresAtUtc.HasValue && profile.TokenExpiresAtUtc.Value > DateTime.UtcNow.AddMinutes(5)) + { + return new SquareTokenSet( + accessToken, + string.IsNullOrWhiteSpace(profile.EncryptedRefreshToken) + ? string.Empty + : _encryption.Decrypt(profile.EncryptedRefreshToken), + profile.TokenExpiresAtUtc.Value); + } + + // Attempt refresh + if (string.IsNullOrWhiteSpace(profile.EncryptedRefreshToken)) + { + _logger.LogWarning("Square token expired for org {OrgId} and no refresh token available", organizationId); + return null; + } + + var refreshToken = _encryption.Decrypt(profile.EncryptedRefreshToken); + + var connectBaseUrl = _hostEnvironment.IsDevelopment() + ? "https://connect.squareupsandbox.com" + : "https://connect.squareup.com"; + + using var httpClient = new HttpClient(); + var response = await httpClient.PostAsJsonAsync($"{connectBaseUrl}/oauth2/token", new + { + client_id = _settings.ApplicationId, + client_secret = _settings.ClientSecret, + grant_type = "refresh_token", + refresh_token = refreshToken + }); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + _logger.LogError("Square token refresh failed for org {OrgId}: {Body}", organizationId, body); + return null; + } + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var root = doc.RootElement; + + var newAccessToken = root.TryGetProperty("access_token", out var atEl) ? atEl.GetString() : null; + var newRefreshToken = root.TryGetProperty("refresh_token", out var rtEl) ? rtEl.GetString() : null; + var expiresAt = root.TryGetProperty("expires_at", out var expEl) ? expEl.GetString() : null; + + if (string.IsNullOrWhiteSpace(newAccessToken)) + { + _logger.LogError("Square token refresh returned empty access_token for org {OrgId}", organizationId); + return null; + } + + var expiresAtUtc = !string.IsNullOrWhiteSpace(expiresAt) + ? DateTime.Parse(expiresAt).ToUniversalTime() + : DateTime.UtcNow.AddDays(30); + + await _paymentProfileService.UpdateTokensAsync( + profile.Id, + _encryption.Encrypt(newAccessToken), + string.IsNullOrWhiteSpace(newRefreshToken) ? profile.EncryptedRefreshToken : _encryption.Encrypt(newRefreshToken), + expiresAtUtc); + + _logger.LogInformation("Square token refreshed for org {OrgId}, expires {ExpiresAt}", organizationId, expiresAtUtc); + + return new SquareTokenSet(newAccessToken, newRefreshToken ?? refreshToken, expiresAtUtc); + } +} diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquareWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquareWebhookService.cs index 0da39fe..0e1a86b 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquareWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquareWebhookService.cs @@ -26,6 +26,7 @@ public class SquareWebhookService : ISquareWebhookService private readonly ISubscriptionRecordService _subscriptionRecordService; private readonly IOnboardingService _onboardingService; private readonly IPaymentHistoryService _paymentHistoryService; + private readonly IOrganizationService _organizationService; private readonly IRepository _paymentProfiles; private readonly ISquareSettings _squareSettings; private readonly ILogger _logger; @@ -36,6 +37,7 @@ public SquareWebhookService( ISubscriptionRecordService subscriptionRecordService, IOnboardingService onboardingService, IPaymentHistoryService paymentHistoryService, + IOrganizationService organizationService, IUnitOfWork unitOfWork, ISquareSettings squareSettings, ILogger logger) @@ -45,6 +47,7 @@ public SquareWebhookService( _subscriptionRecordService = subscriptionRecordService; _onboardingService = onboardingService; _paymentHistoryService = paymentHistoryService; + _organizationService = organizationService; _paymentProfiles = unitOfWork.RepositoryOf(); _squareSettings = squareSettings; _logger = logger; @@ -58,23 +61,51 @@ public async Task HandleEventAsync(string rawBody, string signatureHeader, strin using var document = JsonDocument.Parse(rawBody); var root = document.RootElement; - var eventType = root.GetProperty("data").GetProperty("type").GetString(); + var eventType = root.TryGetProperty("type", out var typeEl) + ? typeEl.GetString() + : null; + if (string.IsNullOrWhiteSpace(eventType)) return; - _logger.LogInformation("Square webhook received: Type={Type}", eventType); + var merchantId = root.TryGetProperty("merchant_id", out var merchantEl) + ? merchantEl.GetString() + : null; - if (eventType is "payment.created" or "payment.updated") - await HandlePaymentEventAsync(root, rawBody, eventType); + _logger.LogInformation("Square webhook received: Type={Type}, MerchantId={MerchantId}", eventType, merchantId); - if (eventType is "refund.created" or "refund.updated") - await HandleRefundEventAsync(root, rawBody, eventType); + Guid? organizationId = null; + if (!string.IsNullOrWhiteSpace(merchantId)) + { + var orgResult = await _organizationService.GetBySquareMerchantIdAsync(merchantId); + if (orgResult.IsSuccess) + organizationId = orgResult.Value.Id; + } - if (eventType is "subscription.created" or "subscription.updated" or "subscription.canceled" or "subscription.deleted") - await HandleSubscriptionEventAsync(root, rawBody, eventType); + switch (eventType) + { + case "payment.created": + case "payment.updated": + await HandlePaymentEventAsync(root, rawBody, eventType, organizationId); + break; + + case "refund.created": + case "refund.updated": + await HandleRefundEventAsync(root, rawBody, eventType, organizationId); + break; + + case "subscription.created": + case "subscription.updated": + await HandleSubscriptionEventAsync(root, rawBody, eventType); + break; + + case "oauth.authorization.revoked": + await HandleOAuthRevokedAsync(merchantId); + break; + } } - private async Task HandlePaymentEventAsync(JsonElement root, string rawBody, string eventType) + private async Task HandlePaymentEventAsync(JsonElement root, string rawBody, string eventType, Guid? organizationId) { var payment = root.GetProperty("data").GetProperty("object").GetProperty("payment"); var status = payment.GetProperty("status").GetString(); @@ -106,17 +137,19 @@ await _onboardingService.MarkStepCompleteAsync( } } + var entityId = organizationId ?? Guid.Empty; if (invoiceId.HasValue) { var invoice = await _invoiceService.GetInvoiceByIdAsync(invoiceId.Value); if (invoice.IsSuccess) { + entityId = invoice.Value.OrganizationId; await _paymentHistoryService.LogAsync(new PaymentHistory { Id = Guid.NewGuid(), PaymentProvider = PaymentProvider.Square, EntityType = PaymentEntityType.Organization, - EntityId = invoice.Value.OrganizationId, + EntityId = entityId, InvoiceId = invoice.Value.Id, AmountPaid = amountCents, Currency = currency, @@ -128,6 +161,23 @@ await _paymentHistoryService.LogAsync(new PaymentHistory }); } } + else + { + await _paymentHistoryService.LogAsync(new PaymentHistory + { + Id = Guid.NewGuid(), + PaymentProvider = PaymentProvider.Square, + EntityType = PaymentEntityType.Organization, + EntityId = entityId, + AmountPaid = amountCents, + Currency = currency, + Status = status ?? "UNKNOWN", + EventType = eventType, + PaidAt = DateTime.UtcNow, + RawEventJson = rawBody, + StripePaymentIntentId = paymentId + }); + } } private async Task HandleSubscriptionEventAsync(JsonElement root, string rawBody, string eventType) @@ -144,8 +194,8 @@ private async Task HandleSubscriptionEventAsync(JsonElement root, string rawBody var planName = TryGetString(subscription, "plan_id") ?? string.Empty; var status = (TryGetString(subscription, "status") ?? eventType).ToLowerInvariant(); - var isCanceledEvent = eventType is "subscription.canceled" or "subscription.deleted" || status.Contains("cancel"); - if (isCanceledEvent) + var isCanceledStatus = status.Contains("cancel") || status.Contains("deactivated"); + if (isCanceledStatus) { await _subscriptionRecordService.CancelAsync(providerSubscriptionId, DateTime.UtcNow); await _paymentHistoryService.LogAsync(new PaymentHistory @@ -215,7 +265,7 @@ await _subscriptionRecordService.CreateAsync( ); } - private async Task HandleRefundEventAsync(JsonElement root, string rawBody, string eventType) + private async Task HandleRefundEventAsync(JsonElement root, string rawBody, string eventType, Guid? organizationId) { var refund = root.GetProperty("data").GetProperty("object").GetProperty("refund"); var status = refund.TryGetProperty("status", out var statusEl) ? statusEl.GetString() : "UNKNOWN"; @@ -228,7 +278,7 @@ await _paymentHistoryService.LogAsync(new PaymentHistory Id = Guid.NewGuid(), PaymentProvider = PaymentProvider.Square, EntityType = PaymentEntityType.Organization, - EntityId = Guid.Empty, + EntityId = organizationId ?? Guid.Empty, InvoiceId = null, AmountPaid = -amountCents, Currency = currency, @@ -240,6 +290,18 @@ await _paymentHistoryService.LogAsync(new PaymentHistory }); } + private async Task HandleOAuthRevokedAsync(string? merchantId) + { + if (string.IsNullOrWhiteSpace(merchantId)) + { + _logger.LogWarning("Square oauth.authorization.revoked received without merchant_id"); + return; + } + + _logger.LogInformation("Square OAuth revoked for MerchantId={MerchantId}", merchantId); + await _organizationService.MarkSquareDisconnectedAsync(merchantId); + } + private bool IsValidSignature(string rawBody, string signatureHeader, string callbackUrl) { var signatureKey = _squareSettings.WebhookSignatureKey; diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 5a63c8b..065647d 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -4,6 +4,7 @@ using JobFlow.Business.PaymentGateways; using JobFlow.Business.PaymentGateways.SharedModels; using JobFlow.Domain.Enums; +using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; using Stripe; using Stripe.Checkout; @@ -13,9 +14,11 @@ namespace JobFlow.Infrastructure.PaymentGateways.Stripe; public class StripePaymentProcessor : IPaymentProcessor, IPaymentOperationsProcessor, IConnectedAccountProcessor { private readonly IPaymentSettings _paymentSettings; - public StripePaymentProcessor(IPaymentSettings paymentSettings) + private readonly IStripeSettings _stripeSettings; + public StripePaymentProcessor(IPaymentSettings paymentSettings, IStripeSettings stripeSettings) { _paymentSettings = paymentSettings; + _stripeSettings = stripeSettings; } public async Task CreateConnectedAccountAsync() { @@ -57,8 +60,10 @@ public async Task GenerateAccountLinkAsync(string accountId) var accountLink = await service.CreateAsync(new AccountLinkCreateOptions { Account = accountId, - ReturnUrl = "http://localhost:4200/admin", - RefreshUrl = $"http://localhost:4200/dashboard/stripe-failed/{accountId}", + ReturnUrl = _stripeSettings.ReturnUrl, + RefreshUrl = string.IsNullOrWhiteSpace(_stripeSettings.RefreshUrl) + ? _stripeSettings.ReturnUrl + : _stripeSettings.RefreshUrl, Type = "account_onboarding" }); @@ -73,7 +78,7 @@ public async Task CreatePaymentIntentAsync( var amountInCents = request.Amount?.ToCents() ?? throw new InvalidOperationException("Payment amount is required."); - long applicationFee = 75L; + long applicationFee = _paymentSettings.ApplicationFee.ToCents(); var options = new PaymentIntentCreateOptions { Amount = amountInCents, diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index f259602..bbd1704 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -71,9 +71,11 @@ public async Task HandleEventAsync(Event stripeEvent) { await _organizationService.MarkStripeConnectedAsync(account.Id); } - else + else if (!account.ChargesEnabled || !account.PayoutsEnabled) { - //await _organizationService.MarkStripeDisconnectedAsync(account.Id); + _logger.LogInformation( + "Stripe account {AccountId} is not fully connected. ChargesEnabled={Charges}, PayoutsEnabled={Payouts}", + account.Id, account.ChargesEnabled, account.PayoutsEnabled); } break; @@ -213,12 +215,25 @@ await _subscriptionRecordService.CancelAsync( private async Task HandleCheckoutSessionAsync(Session session) { + if (string.IsNullOrWhiteSpace(session.SubscriptionId)) + { + _logger.LogWarning("Stripe checkout.session.completed has no SubscriptionId. SessionId={SessionId}", session.Id); + return; + } + var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(session.SubscriptionId); - var ownerId = subscription.Metadata["ownerId"]; - var ownerType = subscription.Metadata["ownerType"]; - var paymentCustomerId = subscription.Metadata["customerId"]; + if (!subscription.Metadata.TryGetValue("ownerId", out var ownerId) || + !subscription.Metadata.TryGetValue("ownerType", out var ownerType) || + !subscription.Metadata.TryGetValue("customerId", out var paymentCustomerId)) + { + _logger.LogWarning( + "Stripe subscription missing required metadata (ownerId/ownerType/customerId). SubscriptionId={SubscriptionId}", + subscription.Id); + return; + } + var priceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id; var planName = subscription.Items?.Data?.FirstOrDefault()?.Price?.Metadata?.GetValueOrDefault("plan-name"); @@ -300,12 +315,21 @@ await _onboardingService.MarkStepCompleteAsync( private async Task HandlePaymentIntentFailedAsync(PaymentIntent intent) { + Guid entityId = Guid.Empty; + if (intent.Metadata.TryGetValue("invoiceId", out var failedInvoiceId) && + Guid.TryParse(failedInvoiceId, out var parsedInvoiceId)) + { + var invoiceResult = await _invoiceService.GetInvoiceByIdAsync(parsedInvoiceId); + if (invoiceResult.IsSuccess) + entityId = invoiceResult.Value.OrganizationId; + } + await _paymentHistoryService.LogAsync(new JobFlow.Domain.Models.PaymentHistory { Id = Guid.NewGuid(), PaymentProvider = PaymentProvider.Stripe, EntityType = PaymentEntityType.Organization, - EntityId = Guid.Empty, + EntityId = entityId, InvoiceId = null, StripePaymentIntentId = intent.Id, AmountPaid = intent.Amount, @@ -319,12 +343,33 @@ await _paymentHistoryService.LogAsync(new JobFlow.Domain.Models.PaymentHistory private async Task HandleChargeRefundedAsync(Charge charge, string eventType) { + Guid entityId = Guid.Empty; + if (!string.IsNullOrWhiteSpace(charge.PaymentIntentId)) + { + var piService = new PaymentIntentService(); + try + { + var pi = await piService.GetAsync(charge.PaymentIntentId); + if (pi.Metadata.TryGetValue("invoiceId", out var refundInvoiceId) && + Guid.TryParse(refundInvoiceId, out var parsedId)) + { + var invoiceResult = await _invoiceService.GetInvoiceByIdAsync(parsedId); + if (invoiceResult.IsSuccess) + entityId = invoiceResult.Value.OrganizationId; + } + } + catch (StripeException ex) + { + _logger.LogWarning(ex, "Could not resolve PaymentIntent {PaymentIntentId} for charge refund org lookup", charge.PaymentIntentId); + } + } + await _paymentHistoryService.LogAsync(new JobFlow.Domain.Models.PaymentHistory { Id = Guid.NewGuid(), PaymentProvider = PaymentProvider.Stripe, EntityType = PaymentEntityType.Organization, - EntityId = Guid.Empty, + EntityId = entityId, InvoiceId = null, StripePaymentIntentId = charge.PaymentIntentId, AmountPaid = -charge.AmountRefunded, @@ -365,9 +410,16 @@ private async Task HandleSubscriptionUpdatedAsync(Subscription subscription) private async Task HandleSubscriptionCreatedAsync(Subscription subscription) { - var ownerId = subscription.Metadata["ownerId"]; - var ownerType = subscription.Metadata["ownerType"]; - var paymentCustomerId = subscription.Metadata["customerId"]; + if (!subscription.Metadata.TryGetValue("ownerId", out var ownerId) || + !subscription.Metadata.TryGetValue("ownerType", out var ownerType) || + !subscription.Metadata.TryGetValue("customerId", out var paymentCustomerId)) + { + _logger.LogWarning( + "Stripe subscription.created missing required metadata (ownerId/ownerType/customerId). SubscriptionId={SubscriptionId}", + subscription.Id); + return; + } + var priceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id; var planName = subscription.Items?.Data?.FirstOrDefault()?.Price?.Metadata?.GetValueOrDefault("plan-name"); From d6b737dd92138b8fcfc0fc7b27d39199d34d6ef7 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Fri, 27 Mar 2026 10:56:55 -0400 Subject: [PATCH 20/26] fix(api): correct unit of work error logging --- JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs b/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs index 1e4cc90..b8aeaa2 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs @@ -38,7 +38,7 @@ public void SaveChanges() } catch (Exception e) { - _logger.LogError("An unknown error occured saving changes to the database", e); + _logger.LogError(e, "An unknown error occurred saving changes to the database"); throw; } @@ -68,7 +68,7 @@ public async Task SaveChangesAsync(bool resetDbContext = true) } catch (Exception e) { - _logger.LogError("An unknown error occured saving changes to the database", e); + _logger.LogError(e, "An unknown error occurred saving changes to the database"); throw; } From b872030700b5ba0902e285f9bf5b241cf6565506 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Fri, 27 Mar 2026 13:26:06 -0400 Subject: [PATCH 21/26] feat(import): Data Import and Export --- .../Controllers/DataExportController.cs | 198 + .../OrganizationClientController.cs | 145 +- JobFlow.API/JobFlow.API.csproj | 1 + JobFlow.API/Models/ClientImportDtos.cs | 79 + JobFlow.API/Models/DataExportDtos.cs | 78 + JobFlow.API/Program.cs | 5 + .../Services/ClientImportCsvService.cs | 152 + JobFlow.API/Services/ClientImportProcessor.cs | 334 ++ .../ClientImportUploadSessionService.cs | 131 + .../Services/DataExportBuilderService.cs | 193 + .../Services/DataExportJobProcessor.cs | 67 + JobFlow.Domain/Models/ClientImportJob.cs | 18 + JobFlow.Domain/Models/ClientImportJobError.cs | 10 + .../Models/ClientImportUploadRow.cs | 10 + .../Models/ClientImportUploadSession.cs | 14 + JobFlow.Domain/Models/DataExportJob.cs | 18 + .../ClientImportJobConfiguration.cs | 33 + .../ClientImportJobErrorConfiguration.cs | 26 + .../ClientImportUploadRowConfiguration.cs | 25 + .../ClientImportUploadSessionConfiguration.cs | 29 + .../DataExportJobConfiguration.cs | 35 + .../JobFlowDbContext.cs | 6 +- ...735_AddClientImportJobTracking.Designer.cs | 3735 ++++++++++++++++ ...260327163735_AddClientImportJobTracking.cs | 104 + ...ImportUploadSessionPersistence.Designer.cs | 3857 ++++++++++++++++ ...AddClientImportUploadSessionPersistence.cs | 91 + ...7165440_AddAsyncDataExportJobs.Designer.cs | 3939 +++++++++++++++++ .../20260327165440_AddAsyncDataExportJobs.cs | 66 + .../JobFlowDbContextModelSnapshot.cs | 343 ++ JobFlow.Tests/JobFlow.Tests.csproj | 1 + ...onClientControllerSwaggerSignatureTests.cs | 34 + 31 files changed, 13775 insertions(+), 2 deletions(-) create mode 100644 JobFlow.API/Controllers/DataExportController.cs create mode 100644 JobFlow.API/Models/ClientImportDtos.cs create mode 100644 JobFlow.API/Models/DataExportDtos.cs create mode 100644 JobFlow.API/Services/ClientImportCsvService.cs create mode 100644 JobFlow.API/Services/ClientImportProcessor.cs create mode 100644 JobFlow.API/Services/ClientImportUploadSessionService.cs create mode 100644 JobFlow.API/Services/DataExportBuilderService.cs create mode 100644 JobFlow.API/Services/DataExportJobProcessor.cs create mode 100644 JobFlow.Domain/Models/ClientImportJob.cs create mode 100644 JobFlow.Domain/Models/ClientImportJobError.cs create mode 100644 JobFlow.Domain/Models/ClientImportUploadRow.cs create mode 100644 JobFlow.Domain/Models/ClientImportUploadSession.cs create mode 100644 JobFlow.Domain/Models/DataExportJob.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobErrorConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadRowConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadSessionConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Configurations/DataExportJobConfiguration.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.cs create mode 100644 JobFlow.Tests/OrganizationClientControllerSwaggerSignatureTests.cs diff --git a/JobFlow.API/Controllers/DataExportController.cs b/JobFlow.API/Controllers/DataExportController.cs new file mode 100644 index 0000000..fda6e11 --- /dev/null +++ b/JobFlow.API/Controllers/DataExportController.cs @@ -0,0 +1,198 @@ +using Hangfire; +using JobFlow.API.Extensions; +using JobFlow.API.Models; +using JobFlow.API.Services; +using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Controllers; + +[ApiController] +[Route("api/data-export")] +public class DataExportController : ControllerBase +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly DataExportBuilderService _builder; + private readonly IOrganizationService _organizations; + + public DataExportController( + IDbContextFactory dbContextFactory, + DataExportBuilderService builder, + IOrganizationService organizations) + { + _dbContextFactory = dbContextFactory; + _builder = builder; + _organizations = organizations; + } + + [HttpGet("json")] + public async Task ExportOrganizationDataJson(CancellationToken cancellationToken) + { + var organizationId = HttpContext.GetOrganizationId(); + var (bytes, fileName) = await _builder.BuildJsonBundleAsync(organizationId, cancellationToken); + + return Results.File(bytes, "application/json", fileName); + } + + [HttpGet("clients.csv")] + public async Task ExportClientsCsv(CancellationToken cancellationToken) + { + var organizationId = HttpContext.GetOrganizationId(); + var (bytes, fileName) = await _builder.BuildClientsCsvAsync(organizationId, cancellationToken); + + return Results.File(bytes, "text/csv", fileName); + } + + [HttpPost("jobs")] + public async Task StartDataExportJob(CancellationToken cancellationToken) + { + var organizationId = HttpContext.GetOrganizationId(); + var userId = HttpContext.GetUserId(); + + var orgResult = await _organizations.GetOrganizationDtoById(organizationId); + if (orgResult.IsFailure) + { + return Results.Problem(statusCode: 404, title: "Organization not found", detail: "Organization context is invalid."); + } + + if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow")) + { + return Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "Subscription Required", + detail: "A Flow plan is required for async ZIP data exports."); + } + + var jobId = Guid.NewGuid(); + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var activeJob = await dbContext.Set() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.OrganizationId == organizationId && (x.Status == "queued" || x.Status == "running"), cancellationToken); + + if (activeJob is not null) + { + return Results.Ok(new StartDataExportJobResponse { JobId = activeJob.Id.ToString("N") }); + } + + dbContext.Set().Add(new DataExportJob + { + Id = jobId, + OrganizationId = organizationId, + RequestedByUserId = userId, + Status = "queued", + CreatedAt = DateTime.UtcNow, + IsActive = true + }); + + await dbContext.SaveChangesAsync(cancellationToken); + + BackgroundJob.Enqueue(x => x.ProcessAsync(jobId, organizationId)); + + return Results.Ok(new StartDataExportJobResponse { JobId = jobId.ToString("N") }); + } + + [HttpGet("jobs")] + public async Task GetDataExportJobs(CancellationToken cancellationToken) + { + var organizationId = HttpContext.GetOrganizationId(); + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var jobs = await dbContext.Set() + .AsNoTracking() + .Where(x => x.OrganizationId == organizationId) + .OrderByDescending(x => x.CreatedAt) + .Take(20) + .Select(x => new DataExportJobStatusResponse + { + JobId = x.Id.ToString("N"), + Status = x.Status, + ErrorMessage = x.ErrorMessage, + FileName = x.FileName, + ContentType = x.ContentType, + StartedAtUtc = x.StartedAtUtc, + CompletedAtUtc = x.CompletedAtUtc, + ExpiresAtUtc = x.ExpiresAtUtc, + DownloadCount = x.DownloadCount + }) + .ToListAsync(cancellationToken); + + return Results.Ok(jobs); + } + + [HttpGet("jobs/{jobId}")] + public async Task GetDataExportJobStatus(string jobId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(jobId, out var parsedJobId)) + return Results.BadRequest("Invalid export job id."); + + var organizationId = HttpContext.GetOrganizationId(); + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var job = await dbContext.Set() + .AsNoTracking() + .Where(x => x.Id == parsedJobId && x.OrganizationId == organizationId) + .Select(x => new DataExportJobStatusResponse + { + JobId = x.Id.ToString("N"), + Status = x.Status, + ErrorMessage = x.ErrorMessage, + FileName = x.FileName, + ContentType = x.ContentType, + StartedAtUtc = x.StartedAtUtc, + CompletedAtUtc = x.CompletedAtUtc, + ExpiresAtUtc = x.ExpiresAtUtc, + DownloadCount = x.DownloadCount + }) + .FirstOrDefaultAsync(cancellationToken); + + return job is null ? Results.NotFound() : Results.Ok(job); + } + + [HttpGet("jobs/{jobId}/download")] + public async Task DownloadDataExportJobFile(string jobId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(jobId, out var parsedJobId)) + return Results.BadRequest("Invalid export job id."); + + var organizationId = HttpContext.GetOrganizationId(); + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var job = await dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == parsedJobId && x.OrganizationId == organizationId, cancellationToken); + + if (job is null) + return Results.NotFound(); + + if (job.Status != "completed" || job.FileContent is null || string.IsNullOrWhiteSpace(job.FileName)) + return Results.Conflict(new { message = "Export file is not ready yet." }); + + if (job.ExpiresAtUtc.HasValue && job.ExpiresAtUtc.Value < DateTime.UtcNow) + return Results.StatusCode(StatusCodes.Status410Gone); + + job.DownloadCount += 1; + await dbContext.SaveChangesAsync(cancellationToken); + + return Results.File(job.FileContent, job.ContentType ?? "application/octet-stream", job.FileName); + } + + 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); + } +} diff --git a/JobFlow.API/Controllers/OrganizationClientController.cs b/JobFlow.API/Controllers/OrganizationClientController.cs index 65b1312..0093c73 100644 --- a/JobFlow.API/Controllers/OrganizationClientController.cs +++ b/JobFlow.API/Controllers/OrganizationClientController.cs @@ -1,13 +1,17 @@ using JobFlow.API.Extensions; using JobFlow.API.Mappings; +using JobFlow.API.Services; using JobFlow.Business; using JobFlow.API.Models; using JobFlow.Business.Extensions; using JobFlow.Business.Models.DTOs; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Models; +using Hangfire; using MapsterMapper; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using JobFlow.Infrastructure.Persistence; namespace JobFlow.API.Controllers; @@ -18,15 +22,24 @@ public class OrganizationClientController : ControllerBase private readonly IOrganizationClientService organizationClientService; private readonly IOrganizationClientPortalService _clientPortal; private readonly IMapper _mapper; + private readonly ClientImportCsvService _csvImportService; + private readonly ClientImportUploadSessionService _uploadSessionService; + private readonly IDbContextFactory _dbContextFactory; public OrganizationClientController( IOrganizationClientService organizationClientService, IOrganizationClientPortalService clientPortal, - IMapper mapper) + IMapper mapper, + ClientImportCsvService csvImportService, + ClientImportUploadSessionService uploadSessionService, + IDbContextFactory dbContextFactory) { this.organizationClientService = organizationClientService; _clientPortal = clientPortal; _mapper = mapper; + _csvImportService = csvImportService; + _uploadSessionService = uploadSessionService; + _dbContextFactory = dbContextFactory; } [HttpGet] @@ -121,4 +134,134 @@ public async Task RestoreClient(Guid clientId) var result = await organizationClientService.RestoreClient(clientId, organizationId); return result.IsSuccess ? Results.Ok(result) : result.ToProblemDetails(); } + + [HttpPost("import/preview")] + [RequestSizeLimit(10 * 1024 * 1024)] + [Consumes("multipart/form-data")] + public async Task PreviewClientImport([FromForm] PreviewClientImportRequest request, CancellationToken cancellationToken) + { + var file = request.File; + if (file is null) + return Results.BadRequest("A CSV file is required."); + + if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + return Results.BadRequest("Only CSV files are supported in this version."); + + try + { + var organizationId = HttpContext.GetOrganizationId(); + var parsed = await _csvImportService.ParseAsync(file, cancellationToken); + var source = string.IsNullOrWhiteSpace(request.SourceSystem) ? "csv" : request.SourceSystem.Trim(); + var uploadSessionId = await _uploadSessionService.SaveAsync(organizationId, source, parsed.Rows, cancellationToken); + + var previewRows = parsed.Rows + .Take(25) + .Select(r => r.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + var response = new ClientImportPreviewResponse + { + UploadToken = uploadSessionId.ToString("N"), + SourceSystem = source, + SourceColumns = parsed.Headers, + SuggestedMappings = parsed.SuggestedMappings, + PreviewRows = previewRows, + TotalRows = parsed.Rows.Count + }; + + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(ex.Message); + } + } + + [HttpPost("import/start")] + public async Task StartClientImport([FromBody] StartClientImportRequest request) + { + if (request is null || string.IsNullOrWhiteSpace(request.UploadToken)) + return Results.BadRequest("Upload token is required."); + + if (!Guid.TryParse(request.UploadToken, out var uploadSessionId)) + return Results.BadRequest("Invalid upload token format."); + + var organizationId = HttpContext.GetOrganizationId(); + var uploadSession = await _uploadSessionService.GetActiveSessionAsync(uploadSessionId, organizationId, CancellationToken.None); + if (uploadSession is null) + return Results.BadRequest("Import session expired or invalid. Please upload your CSV again."); + + if (request.ColumnMappings.Count == 0) + return Results.BadRequest("At least one column mapping is required."); + + var jobId = Guid.NewGuid(); + var sourceSystem = string.IsNullOrWhiteSpace(request.SourceSystem) ? "csv" : request.SourceSystem.Trim(); + + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var importJob = new ClientImportJob + { + Id = jobId, + OrganizationId = organizationId, + SourceSystem = sourceSystem, + Status = "queued", + TotalRows = uploadSession.TotalRows, + ProcessedRows = 0, + SucceededRows = 0, + FailedRows = 0, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + + dbContext.Set().Add(importJob); + await dbContext.SaveChangesAsync(); + + BackgroundJob.Enqueue( + processor => processor.ProcessAsync(jobId, organizationId, uploadSessionId, request.ColumnMappings)); + + return Results.Ok(new StartClientImportResponse { JobId = jobId.ToString("N") }); + } + + [HttpGet("import/jobs/{jobId}")] + public async Task GetClientImportStatus(string jobId) + { + if (!Guid.TryParse(jobId, out var parsedJobId)) + return Results.BadRequest("Invalid import job id."); + + var organizationId = HttpContext.GetOrganizationId(); + + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var job = await dbContext.Set() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == parsedJobId && x.OrganizationId == organizationId); + + if (job is null) + return Results.NotFound(); + + var errors = await dbContext.Set() + .AsNoTracking() + .Where(x => x.ClientImportJobId == parsedJobId) + .OrderBy(x => x.RowNumber) + .Take(100) + .Select(x => new ClientImportErrorItem + { + RowNumber = x.RowNumber, + Message = x.Message + }) + .ToListAsync(); + + var status = new ClientImportJobStatusResponse + { + JobId = job.Id.ToString("N"), + SourceSystem = job.SourceSystem, + Status = job.Status, + TotalRows = job.TotalRows, + ProcessedRows = job.ProcessedRows, + SucceededRows = job.SucceededRows, + FailedRows = job.FailedRows, + ErrorMessage = job.ErrorMessage, + Errors = errors + }; + + return Results.Ok(status); + } } \ No newline at end of file diff --git a/JobFlow.API/JobFlow.API.csproj b/JobFlow.API/JobFlow.API.csproj index 277f2db..0be6300 100644 --- a/JobFlow.API/JobFlow.API.csproj +++ b/JobFlow.API/JobFlow.API.csproj @@ -14,6 +14,7 @@ + diff --git a/JobFlow.API/Models/ClientImportDtos.cs b/JobFlow.API/Models/ClientImportDtos.cs new file mode 100644 index 0000000..670ce47 --- /dev/null +++ b/JobFlow.API/Models/ClientImportDtos.cs @@ -0,0 +1,79 @@ +namespace JobFlow.API.Models; + +public static class ClientImportTargetFields +{ + public const string Ignore = "Ignore"; + public const string FirstName = "FirstName"; + public const string LastName = "LastName"; + public const string FullName = "FullName"; + public const string EmailAddress = "EmailAddress"; + public const string PhoneNumber = "PhoneNumber"; + public const string Address1 = "Address1"; + public const string Address2 = "Address2"; + public const string City = "City"; + public const string State = "State"; + public const string ZipCode = "ZipCode"; + + public static readonly string[] All = + [ + Ignore, + FirstName, + LastName, + FullName, + EmailAddress, + PhoneNumber, + Address1, + Address2, + City, + State, + ZipCode + ]; +} + +public sealed class ClientImportPreviewResponse +{ + public string UploadToken { get; set; } = string.Empty; + public string SourceSystem { get; set; } = "csv"; + public IReadOnlyList SourceColumns { get; set; } = Array.Empty(); + public IReadOnlyDictionary SuggestedMappings { get; set; } = new Dictionary(); + public IReadOnlyList> PreviewRows { get; set; } = Array.Empty>(); + public IReadOnlyList SupportedTargetFields { get; set; } = ClientImportTargetFields.All; + public int TotalRows { get; set; } +} + +public sealed class PreviewClientImportRequest +{ + public IFormFile? File { get; set; } + public string? SourceSystem { get; set; } +} + +public sealed class StartClientImportRequest +{ + public string UploadToken { get; set; } = string.Empty; + public string SourceSystem { get; set; } = "csv"; + public Dictionary ColumnMappings { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class StartClientImportResponse +{ + public string JobId { get; set; } = string.Empty; +} + +public sealed class ClientImportErrorItem +{ + public int RowNumber { get; set; } + public string Message { get; set; } = string.Empty; +} + +public sealed class ClientImportJobStatusResponse +{ + public string JobId { get; set; } = string.Empty; + public string Status { get; set; } = "queued"; + public string SourceSystem { get; set; } = "csv"; + public int TotalRows { get; set; } + public int ProcessedRows { get; set; } + public int SucceededRows { get; set; } + public int FailedRows { get; set; } + public string? ErrorMessage { get; set; } + public IReadOnlyList Errors { get; set; } = Array.Empty(); +} diff --git a/JobFlow.API/Models/DataExportDtos.cs b/JobFlow.API/Models/DataExportDtos.cs new file mode 100644 index 0000000..c8b6099 --- /dev/null +++ b/JobFlow.API/Models/DataExportDtos.cs @@ -0,0 +1,78 @@ +namespace JobFlow.API.Models; + +public sealed class DataExportBundleDto +{ + public string ExportedAtUtc { get; set; } = string.Empty; + public Guid OrganizationId { get; set; } + public IReadOnlyList Clients { get; set; } = Array.Empty(); + public IReadOnlyList Jobs { get; set; } = Array.Empty(); + public IReadOnlyList Invoices { get; set; } = Array.Empty(); + public IReadOnlyList Employees { get; set; } = Array.Empty(); +} + +public sealed class DataExportClientDto +{ + public Guid Id { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? EmailAddress { get; set; } + public string? PhoneNumber { get; set; } + public string? Address1 { get; set; } + public string? Address2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } +} + +public sealed class DataExportJobDto +{ + public Guid Id { get; set; } + public Guid OrganizationClientId { get; set; } + public string? Title { get; set; } + public string? Comments { get; set; } + public string LifecycleStatus { get; set; } = string.Empty; + public string? InvoicingWorkflow { get; set; } +} + +public sealed class DataExportInvoiceDto +{ + public Guid Id { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; + public Guid OrganizationClientId { get; set; } + public Guid? JobId { get; set; } + public DateTime InvoiceDate { get; set; } + public DateTime DueDate { get; set; } + public decimal TotalAmount { get; set; } + public decimal AmountPaid { get; set; } + public decimal BalanceDue { get; set; } + public string Status { get; set; } = string.Empty; +} + +public sealed class DataExportEmployeeDto +{ + public Guid Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string RoleName { get; set; } = string.Empty; + public bool IsActive { get; set; } +} + +public sealed class StartDataExportJobResponse +{ + public string JobId { get; set; } = string.Empty; +} + +public sealed class DataExportJobStatusResponse +{ + public string JobId { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? ErrorMessage { get; set; } + public string? FileName { get; set; } + public string? ContentType { get; set; } + public DateTime? StartedAtUtc { get; set; } + public DateTime? CompletedAtUtc { get; set; } + public DateTime? ExpiresAtUtc { get; set; } + public int DownloadCount { get; set; } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 53f1ead..57f4aaf 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -325,6 +325,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddJobFlowHttpClients(); builder.Services.AddAttributedServices(typeof(IJobFlowHttpClientFactory).Assembly, typeof(IUserService).Assembly); diff --git a/JobFlow.API/Services/ClientImportCsvService.cs b/JobFlow.API/Services/ClientImportCsvService.cs new file mode 100644 index 0000000..5523481 --- /dev/null +++ b/JobFlow.API/Services/ClientImportCsvService.cs @@ -0,0 +1,152 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using CsvHelper; +using CsvHelper.Configuration; +using JobFlow.API.Models; + +namespace JobFlow.API.Services; + +public sealed class ClientImportCsvService +{ + private const int MaxRows = 10000; + + private static readonly Dictionary FieldHints = new(StringComparer.OrdinalIgnoreCase) + { + ["first"] = ClientImportTargetFields.FirstName, + ["firstname"] = ClientImportTargetFields.FirstName, + ["givenname"] = ClientImportTargetFields.FirstName, + ["last"] = ClientImportTargetFields.LastName, + ["lastname"] = ClientImportTargetFields.LastName, + ["surname"] = ClientImportTargetFields.LastName, + ["familyname"] = ClientImportTargetFields.LastName, + ["fullname"] = ClientImportTargetFields.FullName, + ["name"] = ClientImportTargetFields.FullName, + ["clientname"] = ClientImportTargetFields.FullName, + ["customername"] = ClientImportTargetFields.FullName, + ["email"] = ClientImportTargetFields.EmailAddress, + ["emailaddress"] = ClientImportTargetFields.EmailAddress, + ["mail"] = ClientImportTargetFields.EmailAddress, + ["phone"] = ClientImportTargetFields.PhoneNumber, + ["phonenumber"] = ClientImportTargetFields.PhoneNumber, + ["mobile"] = ClientImportTargetFields.PhoneNumber, + ["telephone"] = ClientImportTargetFields.PhoneNumber, + ["address"] = ClientImportTargetFields.Address1, + ["address1"] = ClientImportTargetFields.Address1, + ["street"] = ClientImportTargetFields.Address1, + ["address2"] = ClientImportTargetFields.Address2, + ["unit"] = ClientImportTargetFields.Address2, + ["city"] = ClientImportTargetFields.City, + ["state"] = ClientImportTargetFields.State, + ["province"] = ClientImportTargetFields.State, + ["zip"] = ClientImportTargetFields.ZipCode, + ["zipcode"] = ClientImportTargetFields.ZipCode, + ["postalcode"] = ClientImportTargetFields.ZipCode + }; + + public async Task ParseAsync(IFormFile file, CancellationToken cancellationToken) + { + if (file.Length == 0) + { + throw new InvalidOperationException("The uploaded CSV file is empty."); + } + + await using var fileStream = file.OpenReadStream(); + using var streamReader = new StreamReader(fileStream); + using var csv = new CsvReader(streamReader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + IgnoreBlankLines = true, + MissingFieldFound = null, + HeaderValidated = null, + BadDataFound = null, + TrimOptions = TrimOptions.Trim + }); + + if (!await csv.ReadAsync()) + { + throw new InvalidOperationException("Could not read the CSV header row."); + } + + csv.ReadHeader(); + + var rawHeaders = csv.HeaderRecord ?? Array.Empty(); + var headers = rawHeaders + .Where(h => !string.IsNullOrWhiteSpace(h)) + .Select(h => h.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (headers.Count == 0) + { + throw new InvalidOperationException("No valid header columns were found in this CSV file."); + } + + var rows = new List>(capacity: Math.Min(2000, MaxRows)); + + while (await csv.ReadAsync()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (rows.Count >= MaxRows) + { + throw new InvalidOperationException($"CSV row limit exceeded. Maximum supported rows: {MaxRows}."); + } + + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in headers) + { + row[header] = csv.GetField(header)?.Trim(); + } + + rows.Add(row); + } + + var suggestedMappings = BuildSuggestedMappings(headers); + + return new ParsedClientCsv(headers, rows, suggestedMappings); + } + + public static Dictionary BuildSuggestedMappings(IEnumerable headers) + { + var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var header in headers) + { + var normalized = NormalizeHeader(header); + + var mapped = FieldHints.TryGetValue(normalized, out var exact) + ? exact + : GuessFieldByContains(normalized); + + mappings[header] = mapped ?? ClientImportTargetFields.Ignore; + } + + return mappings; + } + + private static string? GuessFieldByContains(string normalized) + { + if (normalized.Contains("first") && normalized.Contains("name")) return ClientImportTargetFields.FirstName; + if (normalized.Contains("last") && normalized.Contains("name")) return ClientImportTargetFields.LastName; + if (normalized.Contains("full") && normalized.Contains("name")) return ClientImportTargetFields.FullName; + if (normalized.Contains("mail")) return ClientImportTargetFields.EmailAddress; + if (normalized.Contains("phone") || normalized.Contains("mobile") || normalized.Contains("tel")) return ClientImportTargetFields.PhoneNumber; + if (normalized.Contains("address2") || normalized.Contains("suite") || normalized.Contains("unit")) return ClientImportTargetFields.Address2; + if (normalized.Contains("address") || normalized.Contains("street")) return ClientImportTargetFields.Address1; + if (normalized.Contains("city")) return ClientImportTargetFields.City; + if (normalized.Contains("state") || normalized.Contains("province")) return ClientImportTargetFields.State; + if (normalized.Contains("zip") || normalized.Contains("postal")) return ClientImportTargetFields.ZipCode; + + return null; + } + + private static string NormalizeHeader(string header) + { + return Regex.Replace(header, "[^a-zA-Z0-9]", string.Empty).ToLowerInvariant(); + } +} + +public sealed record ParsedClientCsv( + IReadOnlyList Headers, + IReadOnlyList> Rows, + IReadOnlyDictionary SuggestedMappings); diff --git a/JobFlow.API/Services/ClientImportProcessor.cs b/JobFlow.API/Services/ClientImportProcessor.cs new file mode 100644 index 0000000..a214da7 --- /dev/null +++ b/JobFlow.API/Services/ClientImportProcessor.cs @@ -0,0 +1,334 @@ +using JobFlow.API.Models; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Services; + +public sealed class ClientImportProcessor +{ + private const int MaxPersistedErrors = 1000; + + private readonly IDbContextFactory _dbContextFactory; + private readonly ClientImportUploadSessionService _uploadSessionService; + private readonly ILogger _logger; + + public ClientImportProcessor( + IDbContextFactory dbContextFactory, + ClientImportUploadSessionService uploadSessionService, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _uploadSessionService = uploadSessionService; + _logger = logger; + } + + public async Task ProcessAsync( + Guid jobId, + Guid organizationId, + Guid uploadSessionId, + Dictionary columnMappings) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var importJob = await dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == jobId && x.OrganizationId == organizationId); + + if (importJob is null) + { + return; + } + + importJob.Status = "running"; + importJob.StartedAtUtc ??= DateTime.UtcNow; + importJob.ErrorMessage = null; + await dbContext.SaveChangesAsync(); + + try + { + var session = await _uploadSessionService.GetActiveSessionAsync(uploadSessionId, organizationId, CancellationToken.None); + if (session is null) + { + await MarkFailedAsync(dbContext, importJob, "Import session not found or expired. Please upload your CSV again."); + return; + } + + var clients = dbContext.Set(); + var errors = dbContext.Set(); + + var emailSourceColumn = columnMappings + .FirstOrDefault(x => string.Equals(x.Value, ClientImportTargetFields.EmailAddress, StringComparison.OrdinalIgnoreCase)) + .Key; + + var sessionEmails = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(emailSourceColumn)) + { + foreach (var row in session.Rows) + { + if (row.Row.TryGetValue(emailSourceColumn, out var email) && !string.IsNullOrWhiteSpace(email)) + { + sessionEmails.Add(email.Trim().ToLowerInvariant()); + } + } + } + + var existingByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (sessionEmails.Count > 0) + { + var existing = await clients + .Where(c => c.OrganizationId == organizationId + && c.EmailAddress != null + && sessionEmails.Contains(c.EmailAddress.ToLower())) + .ToListAsync(); + + foreach (var row in existing) + { + if (!string.IsNullOrWhiteSpace(row.EmailAddress)) + { + existingByEmail[row.EmailAddress.Trim().ToLowerInvariant()] = row; + } + } + } + + var processedRows = 0; + var succeededRows = 0; + var failedRows = 0; + var persistedErrors = 0; + + for (var index = 0; index < session.Rows.Count; index++) + { + var rowItem = session.Rows[index]; + var rowNumber = rowItem.RowNumber; + var row = rowItem.Row; + processedRows++; + + try + { + var mapped = MapRow(row, columnMappings); + + if (string.IsNullOrWhiteSpace(mapped.FirstName) + && string.IsNullOrWhiteSpace(mapped.LastName) + && string.IsNullOrWhiteSpace(mapped.EmailAddress) + && string.IsNullOrWhiteSpace(mapped.PhoneNumber)) + { + failedRows++; + if (persistedErrors < MaxPersistedErrors) + { + errors.Add(new ClientImportJobError + { + Id = Guid.NewGuid(), + ClientImportJobId = jobId, + RowNumber = rowNumber, + Message = "Row has no usable client data.", + CreatedAt = DateTime.UtcNow, + IsActive = true + }); + persistedErrors++; + } + continue; + } + + OrganizationClient entity; + if (!string.IsNullOrWhiteSpace(mapped.EmailAddress) + && existingByEmail.TryGetValue(mapped.EmailAddress.Trim().ToLowerInvariant(), out var existingClient)) + { + entity = existingClient; + } + else + { + entity = new OrganizationClient + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + await clients.AddAsync(entity); + } + + Merge(entity, mapped); + + if (!string.IsNullOrWhiteSpace(entity.EmailAddress)) + { + existingByEmail[entity.EmailAddress.Trim().ToLowerInvariant()] = entity; + } + + succeededRows++; + } + catch (Exception ex) + { + failedRows++; + if (persistedErrors < MaxPersistedErrors) + { + errors.Add(new ClientImportJobError + { + Id = Guid.NewGuid(), + ClientImportJobId = jobId, + RowNumber = rowNumber, + Message = Truncate(ex.Message, 2000), + CreatedAt = DateTime.UtcNow, + IsActive = true + }); + persistedErrors++; + } + + _logger.LogWarning(ex, "Client import row failed. JobId={JobId}, Row={RowNumber}", jobId, rowNumber); + } + + if (processedRows % 200 == 0) + { + importJob.ProcessedRows = processedRows; + importJob.SucceededRows = succeededRows; + importJob.FailedRows = failedRows; + await dbContext.SaveChangesAsync(); + } + } + + importJob.Status = "completed"; + importJob.ProcessedRows = processedRows; + importJob.SucceededRows = succeededRows; + importJob.FailedRows = failedRows; + importJob.CompletedAtUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(); + await _uploadSessionService.MarkConsumedAsync(uploadSessionId, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "Client import job failed. JobId={JobId}", jobId); + await MarkFailedAsync(dbContext, importJob, "Import failed unexpectedly. Please retry or contact support."); + } + } + + private static async Task MarkFailedAsync(JobFlowDbContext dbContext, ClientImportJob importJob, string message) + { + importJob.Status = "failed"; + importJob.ErrorMessage = Truncate(message, 2000); + importJob.CompletedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(); + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value.Substring(0, maxLength); + } + + private static void Merge(OrganizationClient entity, MappedClientRow mapped) + { + entity.FirstName = Prefer(mapped.FirstName, entity.FirstName); + entity.LastName = Prefer(mapped.LastName, entity.LastName); + entity.EmailAddress = Prefer(mapped.EmailAddress, entity.EmailAddress); + entity.PhoneNumber = Prefer(mapped.PhoneNumber, entity.PhoneNumber); + entity.Address1 = Prefer(mapped.Address1, entity.Address1); + entity.Address2 = Prefer(mapped.Address2, entity.Address2); + entity.City = Prefer(mapped.City, entity.City); + entity.State = Prefer(mapped.State, entity.State); + entity.ZipCode = Prefer(mapped.ZipCode, entity.ZipCode); + entity.UpdatedAt = DateTime.UtcNow; + entity.IsActive = true; + entity.DeactivatedAtUtc = null; + } + + private static string? Prefer(string? incoming, string? current) + { + return string.IsNullOrWhiteSpace(incoming) ? current : incoming.Trim(); + } + + private static MappedClientRow MapRow( + Dictionary row, + Dictionary columnMappings) + { + var mapped = new MappedClientRow(); + + foreach (var (sourceColumn, targetField) in columnMappings) + { + if (string.IsNullOrWhiteSpace(targetField) + || string.Equals(targetField, ClientImportTargetFields.Ignore, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!row.TryGetValue(sourceColumn, out var rawValue)) + { + continue; + } + + var value = rawValue?.Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + switch (targetField) + { + case ClientImportTargetFields.FirstName: + mapped.FirstName = value; + break; + case ClientImportTargetFields.LastName: + mapped.LastName = value; + break; + case ClientImportTargetFields.FullName: + ApplyFullName(mapped, value); + break; + case ClientImportTargetFields.EmailAddress: + mapped.EmailAddress = value; + break; + case ClientImportTargetFields.PhoneNumber: + mapped.PhoneNumber = value; + break; + case ClientImportTargetFields.Address1: + mapped.Address1 = value; + break; + case ClientImportTargetFields.Address2: + mapped.Address2 = value; + break; + case ClientImportTargetFields.City: + mapped.City = value; + break; + case ClientImportTargetFields.State: + mapped.State = value; + break; + case ClientImportTargetFields.ZipCode: + mapped.ZipCode = value; + break; + } + } + + return mapped; + } + + private static void ApplyFullName(MappedClientRow mapped, string fullName) + { + var parts = fullName + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length == 0) + { + return; + } + + mapped.FirstName ??= parts[0]; + + if (parts.Length > 1) + { + mapped.LastName ??= string.Join(' ', parts.Skip(1)); + } + } + + private sealed class MappedClientRow + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? EmailAddress { get; set; } + public string? PhoneNumber { get; set; } + public string? Address1 { get; set; } + public string? Address2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + } +} diff --git a/JobFlow.API/Services/ClientImportUploadSessionService.cs b/JobFlow.API/Services/ClientImportUploadSessionService.cs new file mode 100644 index 0000000..742893d --- /dev/null +++ b/JobFlow.API/Services/ClientImportUploadSessionService.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Services; + +public sealed class ClientImportUploadSessionService +{ + private static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly IDbContextFactory _dbContextFactory; + + public ClientImportUploadSessionService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task SaveAsync( + Guid organizationId, + string sourceSystem, + IReadOnlyList> rows, + CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var now = DateTime.UtcNow; + + var expired = await dbContext.Set() + .Where(x => x.ExpiresAtUtc < now) + .ToListAsync(cancellationToken); + + if (expired.Count > 0) + { + dbContext.Set().RemoveRange(expired); + } + + var sessionId = Guid.NewGuid(); + var session = new ClientImportUploadSession + { + Id = sessionId, + OrganizationId = organizationId, + SourceSystem = sourceSystem, + Status = "active", + TotalRows = rows.Count, + CreatedAt = now, + ExpiresAtUtc = now.Add(SessionTtl), + IsActive = true + }; + + dbContext.Set().Add(session); + + var rowEntities = rows.Select((row, index) => new ClientImportUploadRow + { + Id = Guid.NewGuid(), + ClientImportUploadSessionId = sessionId, + RowNumber = index + 2, + RowDataJson = JsonSerializer.Serialize(row, JsonOptions), + CreatedAt = now, + IsActive = true + }).ToList(); + + dbContext.Set().AddRange(rowEntities); + await dbContext.SaveChangesAsync(cancellationToken); + + return sessionId; + } + + public async Task GetActiveSessionAsync(Guid sessionId, Guid organizationId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var now = DateTime.UtcNow; + var session = await dbContext.Set() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == sessionId && x.OrganizationId == organizationId, cancellationToken); + + if (session is null || session.ExpiresAtUtc < now || !string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var rows = await dbContext.Set() + .AsNoTracking() + .Where(x => x.ClientImportUploadSessionId == sessionId) + .OrderBy(x => x.RowNumber) + .Select(x => new ClientImportUploadRowData( + x.RowNumber, + JsonSerializer.Deserialize>(x.RowDataJson, JsonOptions) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase))) + .ToListAsync(cancellationToken); + + return new ClientImportUploadData( + session.Id, + session.OrganizationId, + session.SourceSystem, + session.TotalRows, + rows); + } + + public async Task MarkConsumedAsync(Guid sessionId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var session = await dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + + if (session is null) + { + return; + } + + session.Status = "consumed"; + session.ConsumedAtUtc = DateTime.UtcNow; + session.UpdatedAt = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + } +} + +public sealed record ClientImportUploadData( + Guid SessionId, + Guid OrganizationId, + string SourceSystem, + int TotalRows, + IReadOnlyList Rows); + +public sealed record ClientImportUploadRowData( + int RowNumber, + Dictionary Row); diff --git a/JobFlow.API/Services/DataExportBuilderService.cs b/JobFlow.API/Services/DataExportBuilderService.cs new file mode 100644 index 0000000..44ae748 --- /dev/null +++ b/JobFlow.API/Services/DataExportBuilderService.cs @@ -0,0 +1,193 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using JobFlow.API.Models; +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Services; + +public sealed class DataExportBuilderService +{ + private readonly IDbContextFactory _dbContextFactory; + + public DataExportBuilderService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + public async Task<(byte[] Content, string FileName)> BuildJsonBundleAsync(Guid organizationId, CancellationToken cancellationToken) + { + var export = await BuildExportBundleAsync(organizationId, cancellationToken); + var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }); + var bytes = Encoding.UTF8.GetBytes(json); + var fileName = $"jobflow-data-export-{organizationId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.json"; + return (bytes, fileName); + } + + public async Task<(byte[] Content, string FileName)> BuildClientsCsvAsync(Guid organizationId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var clients = await dbContext.Set() + .AsNoTracking() + .Where(x => x.OrganizationId == organizationId) + .OrderBy(x => x.LastName) + .ThenBy(x => x.FirstName) + .ToListAsync(cancellationToken); + + var sb = new StringBuilder(); + sb.AppendLine("Id,FirstName,LastName,EmailAddress,PhoneNumber,Address1,Address2,City,State,ZipCode"); + + foreach (var c in clients) + { + sb.AppendLine(string.Join(',', + Csv(c.Id.ToString()), + Csv(c.FirstName), + Csv(c.LastName), + Csv(c.EmailAddress), + Csv(c.PhoneNumber), + Csv(c.Address1), + Csv(c.Address2), + Csv(c.City), + Csv(c.State), + Csv(c.ZipCode))); + } + + var fileName = $"jobflow-clients-{organizationId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; + return (Encoding.UTF8.GetBytes(sb.ToString()), fileName); + } + + public async Task<(byte[] Content, string FileName)> BuildZipPackageAsync(Guid organizationId, CancellationToken cancellationToken) + { + var (jsonContent, jsonFileName) = await BuildJsonBundleAsync(organizationId, cancellationToken); + var (csvContent, csvFileName) = await BuildClientsCsvAsync(organizationId, cancellationToken); + + await using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var jsonEntry = archive.CreateEntry(jsonFileName, CompressionLevel.Fastest); + await using (var jsonEntryStream = jsonEntry.Open()) + { + await jsonEntryStream.WriteAsync(jsonContent, cancellationToken); + } + + var csvEntry = archive.CreateEntry(csvFileName, CompressionLevel.Fastest); + await using (var csvEntryStream = csvEntry.Open()) + { + await csvEntryStream.WriteAsync(csvContent, cancellationToken); + } + + var readmeEntry = archive.CreateEntry("README.txt", CompressionLevel.Fastest); + await using var readmeStream = readmeEntry.Open(); + await using var writer = new StreamWriter(readmeStream, Encoding.UTF8, leaveOpen: true); + await writer.WriteAsync( + "JobFlow Data Export\n" + + $"Generated at (UTC): {DateTime.UtcNow:O}\n" + + "Contents:\n" + + $"- {jsonFileName} (full organization data bundle)\n" + + $"- {csvFileName} (clients only)\n"); + await writer.FlushAsync(cancellationToken); + } + + var zipName = $"jobflow-export-{organizationId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.zip"; + return (stream.ToArray(), zipName); + } + + private async Task BuildExportBundleAsync(Guid organizationId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var clients = await dbContext.Set() + .AsNoTracking() + .Where(x => x.OrganizationId == organizationId) + .Select(x => new DataExportClientDto + { + Id = x.Id, + FirstName = x.FirstName, + LastName = x.LastName, + EmailAddress = x.EmailAddress, + PhoneNumber = x.PhoneNumber, + Address1 = x.Address1, + Address2 = x.Address2, + City = x.City, + State = x.State, + ZipCode = x.ZipCode + }) + .ToListAsync(cancellationToken); + + var jobs = await dbContext.Set() + .AsNoTracking() + .Where(x => x.OrganizationClient.OrganizationId == organizationId) + .Select(x => new DataExportJobDto + { + Id = x.Id, + OrganizationClientId = x.OrganizationClientId, + Title = x.Title, + Comments = x.Comments, + LifecycleStatus = x.LifecycleStatus.ToString(), + InvoicingWorkflow = x.InvoicingWorkflow.HasValue ? x.InvoicingWorkflow.Value.ToString() : null + }) + .ToListAsync(cancellationToken); + + var invoices = await dbContext.Set() + .AsNoTracking() + .Where(x => x.OrganizationId == organizationId) + .Select(x => new DataExportInvoiceDto + { + Id = x.Id, + InvoiceNumber = x.InvoiceNumber, + OrganizationClientId = x.OrganizationClientId, + JobId = x.JobId, + InvoiceDate = x.InvoiceDate, + DueDate = x.DueDate, + TotalAmount = x.TotalAmount, + AmountPaid = x.AmountPaid, + BalanceDue = x.TotalAmount - x.AmountPaid, + Status = x.Status.ToString() + }) + .ToListAsync(cancellationToken); + + var employees = await dbContext.Set() + .AsNoTracking() + .Include(x => x.Role) + .Where(x => x.OrganizationId == organizationId) + .Select(x => new DataExportEmployeeDto + { + Id = x.Id, + FirstName = x.FirstName, + LastName = x.LastName, + Email = x.Email, + PhoneNumber = x.PhoneNumber, + RoleName = x.Role.Name, + IsActive = x.IsActive + }) + .ToListAsync(cancellationToken); + + return new DataExportBundleDto + { + ExportedAtUtc = DateTime.UtcNow.ToString("O"), + OrganizationId = organizationId, + Clients = clients, + Jobs = jobs, + Invoices = invoices, + Employees = employees + }; + } + + private static string Csv(string? value) + { + if (value is null) + { + return string.Empty; + } + + if (!value.Contains(',') && !value.Contains('"') && !value.Contains('\n') && !value.Contains('\r')) + { + return value; + } + + return $"\"{value.Replace("\"", "\"\"")}\""; + } +} \ No newline at end of file diff --git a/JobFlow.API/Services/DataExportJobProcessor.cs b/JobFlow.API/Services/DataExportJobProcessor.cs new file mode 100644 index 0000000..385ead0 --- /dev/null +++ b/JobFlow.API/Services/DataExportJobProcessor.cs @@ -0,0 +1,67 @@ +using JobFlow.Domain.Models; +using JobFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace JobFlow.API.Services; + +public sealed class DataExportJobProcessor +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly DataExportBuilderService _builder; + private readonly ILogger _logger; + + public DataExportJobProcessor( + IDbContextFactory dbContextFactory, + DataExportBuilderService builder, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _builder = builder; + _logger = logger; + } + + public async Task ProcessAsync(Guid jobId, Guid organizationId) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var job = await dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == jobId && x.OrganizationId == organizationId); + + if (job is null) + { + _logger.LogWarning("Data export job not found. JobId={JobId}, OrganizationId={OrganizationId}", jobId, organizationId); + return; + } + + if (job.Status is "completed" or "running") + { + return; + } + + try + { + job.Status = "running"; + job.StartedAtUtc = DateTime.UtcNow; + job.ErrorMessage = null; + await dbContext.SaveChangesAsync(); + + var (zipContent, zipName) = await _builder.BuildZipPackageAsync(organizationId, CancellationToken.None); + + job.Status = "completed"; + job.FileContent = zipContent; + job.FileName = zipName; + job.ContentType = "application/zip"; + job.CompletedAtUtc = DateTime.UtcNow; + job.ExpiresAtUtc = DateTime.UtcNow.AddDays(7); + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process data export job. JobId={JobId}, OrganizationId={OrganizationId}", jobId, organizationId); + + job.Status = "failed"; + job.ErrorMessage = ex.Message; + job.CompletedAtUtc = DateTime.UtcNow; + await dbContext.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/JobFlow.Domain/Models/ClientImportJob.cs b/JobFlow.Domain/Models/ClientImportJob.cs new file mode 100644 index 0000000..0005ffc --- /dev/null +++ b/JobFlow.Domain/Models/ClientImportJob.cs @@ -0,0 +1,18 @@ +namespace JobFlow.Domain.Models; + +public class ClientImportJob : Entity +{ + public Guid OrganizationId { get; set; } + public string SourceSystem { get; set; } = "csv"; + public string Status { get; set; } = "queued"; + public int TotalRows { get; set; } + public int ProcessedRows { get; set; } + public int SucceededRows { get; set; } + public int FailedRows { get; set; } + public string? ErrorMessage { get; set; } + public DateTime? StartedAtUtc { get; set; } + public DateTime? CompletedAtUtc { get; set; } + + public virtual Organization Organization { get; set; } + public virtual ICollection Errors { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/ClientImportJobError.cs b/JobFlow.Domain/Models/ClientImportJobError.cs new file mode 100644 index 0000000..c20aeee --- /dev/null +++ b/JobFlow.Domain/Models/ClientImportJobError.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Models; + +public class ClientImportJobError : Entity +{ + public Guid ClientImportJobId { get; set; } + public int RowNumber { get; set; } + public string Message { get; set; } = string.Empty; + + public virtual ClientImportJob ClientImportJob { get; set; } +} diff --git a/JobFlow.Domain/Models/ClientImportUploadRow.cs b/JobFlow.Domain/Models/ClientImportUploadRow.cs new file mode 100644 index 0000000..1f3482d --- /dev/null +++ b/JobFlow.Domain/Models/ClientImportUploadRow.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Domain.Models; + +public class ClientImportUploadRow : Entity +{ + public Guid ClientImportUploadSessionId { get; set; } + public int RowNumber { get; set; } + public string RowDataJson { get; set; } = string.Empty; + + public virtual ClientImportUploadSession Session { get; set; } +} diff --git a/JobFlow.Domain/Models/ClientImportUploadSession.cs b/JobFlow.Domain/Models/ClientImportUploadSession.cs new file mode 100644 index 0000000..4615965 --- /dev/null +++ b/JobFlow.Domain/Models/ClientImportUploadSession.cs @@ -0,0 +1,14 @@ +namespace JobFlow.Domain.Models; + +public class ClientImportUploadSession : Entity +{ + public Guid OrganizationId { get; set; } + public string SourceSystem { get; set; } = "csv"; + public string Status { get; set; } = "active"; + public int TotalRows { get; set; } + public DateTime ExpiresAtUtc { get; set; } + public DateTime? ConsumedAtUtc { get; set; } + + public virtual Organization Organization { get; set; } + public virtual ICollection Rows { get; set; } = new List(); +} diff --git a/JobFlow.Domain/Models/DataExportJob.cs b/JobFlow.Domain/Models/DataExportJob.cs new file mode 100644 index 0000000..3b0cbb2 --- /dev/null +++ b/JobFlow.Domain/Models/DataExportJob.cs @@ -0,0 +1,18 @@ +namespace JobFlow.Domain.Models; + +public class DataExportJob : Entity +{ + public Guid OrganizationId { get; set; } + public Guid RequestedByUserId { get; set; } + public string Status { get; set; } = "queued"; + public string? ErrorMessage { get; set; } + public string? FileName { get; set; } + public string? ContentType { get; set; } + public byte[]? FileContent { get; set; } + public int DownloadCount { get; set; } + public DateTime? StartedAtUtc { get; set; } + public DateTime? CompletedAtUtc { get; set; } + public DateTime? ExpiresAtUtc { get; set; } + + public virtual Organization Organization { get; set; } +} \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobConfiguration.cs new file mode 100644 index 0000000..21c0f71 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobConfiguration.cs @@ -0,0 +1,33 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal sealed class ClientImportJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientImportJob"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.SourceSystem) + .HasMaxLength(64) + .IsRequired(); + + builder.Property(x => x.Status) + .HasMaxLength(32) + .IsRequired(); + + builder.Property(x => x.ErrorMessage) + .HasMaxLength(2000); + + builder.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); + builder.HasIndex(x => x.Status); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobErrorConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobErrorConfiguration.cs new file mode 100644 index 0000000..61b2b37 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportJobErrorConfiguration.cs @@ -0,0 +1,26 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal sealed class ClientImportJobErrorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientImportJobError"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Message) + .HasMaxLength(2000) + .IsRequired(); + + builder.HasIndex(x => x.ClientImportJobId); + builder.HasIndex(x => x.RowNumber); + + builder.HasOne(x => x.ClientImportJob) + .WithMany(x => x.Errors) + .HasForeignKey(x => x.ClientImportJobId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadRowConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadRowConfiguration.cs new file mode 100644 index 0000000..bc37331 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadRowConfiguration.cs @@ -0,0 +1,25 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal sealed class ClientImportUploadRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientImportUploadRow"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RowDataJson) + .IsRequired(); + + builder.HasIndex(x => new { x.ClientImportUploadSessionId, x.RowNumber }) + .IsUnique(); + + builder.HasOne(x => x.Session) + .WithMany(x => x.Rows) + .HasForeignKey(x => x.ClientImportUploadSessionId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadSessionConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadSessionConfiguration.cs new file mode 100644 index 0000000..64513be --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/ClientImportUploadSessionConfiguration.cs @@ -0,0 +1,29 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal sealed class ClientImportUploadSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientImportUploadSession"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.SourceSystem) + .HasMaxLength(64) + .IsRequired(); + + builder.Property(x => x.Status) + .HasMaxLength(32) + .IsRequired(); + + builder.HasIndex(x => new { x.OrganizationId, x.Status, x.ExpiresAtUtc }); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/JobFlow.Infrastructure.Persistence/Configurations/DataExportJobConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/DataExportJobConfiguration.cs new file mode 100644 index 0000000..bb8b220 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Configurations/DataExportJobConfiguration.cs @@ -0,0 +1,35 @@ +using JobFlow.Domain.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace JobFlow.Infrastructure.Persistence.Configurations; + +internal sealed class DataExportJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DataExportJob"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Status) + .HasMaxLength(32) + .IsRequired(); + + builder.Property(x => x.ErrorMessage) + .HasMaxLength(2000); + + builder.Property(x => x.FileName) + .HasMaxLength(255); + + builder.Property(x => x.ContentType) + .HasMaxLength(128); + + builder.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); + builder.HasIndex(x => new { x.OrganizationId, x.Status }); + + builder.HasOne(x => x.Organization) + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs index 29dc8fa..76fd7ad 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowDbContext.cs @@ -1,6 +1,5 @@ using JobFlow.Domain.Models; using System.Linq.Expressions; -using JobFlow.Domain.Models; using Microsoft.EntityFrameworkCore; namespace JobFlow.Infrastructure.Persistence; @@ -30,6 +29,11 @@ public JobFlowDbContext(DbContextOptions options) : base(options) public DbSet FollowUpSteps { get; set; } public DbSet FollowUpRuns { get; set; } public DbSet FollowUpExecutionLogs { get; set; } + public DbSet ClientImportJobs { get; set; } + public DbSet ClientImportJobErrors { get; set; } + public DbSet ClientImportUploadSessions { get; set; } + public DbSet ClientImportUploadRows { get; set; } + public DbSet DataExportJobs { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.Designer.cs new file mode 100644 index 0000000..d24a171 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.Designer.cs @@ -0,0 +1,3735 @@ +// +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("20260327163735_AddClientImportJobTracking")] + partial class AddClientImportJobTracking + { + /// + 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.ClientImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FailedRows") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProcessedRows") + .HasColumnType("int"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SucceededRows") + .HasColumnType("int"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("ClientImportJob", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportJobId") + .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("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportJobId"); + + b.HasIndex("RowNumber"); + + b.ToTable("ClientImportJobError", (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("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .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("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSquareConnected") + .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("SquareMerchantId") + .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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.ClientImportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportJob", "ClientImportJob") + .WithMany("Errors") + .HasForeignKey("ClientImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientImportJob"); + }); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.ClientImportJob", b => + { + b.Navigation("Errors"); + }); + + 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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260327163735_AddClientImportJobTracking.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.cs new file mode 100644 index 0000000..7aba85e --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327163735_AddClientImportJobTracking.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddClientImportJobTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClientImportJob", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + SourceSystem = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + TotalRows = table.Column(type: "int", nullable: false), + ProcessedRows = table.Column(type: "int", nullable: false), + SucceededRows = table.Column(type: "int", nullable: false), + FailedRows = table.Column(type: "int", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + StartedAtUtc = table.Column(type: "datetime2", nullable: true), + CompletedAtUtc = table.Column(type: "datetime2", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientImportJob", x => x.Id); + table.ForeignKey( + name: "FK_ClientImportJob_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ClientImportJobError", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ClientImportJobId = table.Column(type: "uniqueidentifier", nullable: false), + RowNumber = table.Column(type: "int", nullable: false), + Message = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientImportJobError", x => x.Id); + table.ForeignKey( + name: "FK_ClientImportJobError_ClientImportJob_ClientImportJobId", + column: x => x.ClientImportJobId, + principalTable: "ClientImportJob", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportJob_OrganizationId_CreatedAt", + table: "ClientImportJob", + columns: new[] { "OrganizationId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportJob_Status", + table: "ClientImportJob", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportJobError_ClientImportJobId", + table: "ClientImportJobError", + column: "ClientImportJobId"); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportJobError_RowNumber", + table: "ClientImportJobError", + column: "RowNumber"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClientImportJobError"); + + migrationBuilder.DropTable( + name: "ClientImportJob"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.Designer.cs new file mode 100644 index 0000000..032d64c --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.Designer.cs @@ -0,0 +1,3857 @@ +// +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("20260327164425_AddClientImportUploadSessionPersistence")] + partial class AddClientImportUploadSessionPersistence + { + /// + 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.ClientImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FailedRows") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProcessedRows") + .HasColumnType("int"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SucceededRows") + .HasColumnType("int"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("ClientImportJob", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportJobId") + .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("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportJobId"); + + b.HasIndex("RowNumber"); + + b.ToTable("ClientImportJobError", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportUploadSessionId") + .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("RowDataJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportUploadSessionId", "RowNumber") + .IsUnique(); + + b.ToTable("ClientImportUploadRow", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConsumedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Status", "ExpiresAtUtc"); + + b.ToTable("ClientImportUploadSession", (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("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .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("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSquareConnected") + .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("SquareMerchantId") + .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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.ClientImportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportJob", "ClientImportJob") + .WithMany("Errors") + .HasForeignKey("ClientImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientImportJob"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportUploadSession", "Session") + .WithMany("Rows") + .HasForeignKey("ClientImportUploadSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.ClientImportJob", b => + { + b.Navigation("Errors"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Navigation("Rows"); + }); + + 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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260327164425_AddClientImportUploadSessionPersistence.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.cs new file mode 100644 index 0000000..e5bceac --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327164425_AddClientImportUploadSessionPersistence.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddClientImportUploadSessionPersistence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClientImportUploadSession", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + SourceSystem = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + TotalRows = table.Column(type: "int", nullable: false), + ExpiresAtUtc = table.Column(type: "datetime2", nullable: false), + ConsumedAtUtc = table.Column(type: "datetime2", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientImportUploadSession", x => x.Id); + table.ForeignKey( + name: "FK_ClientImportUploadSession_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ClientImportUploadRow", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ClientImportUploadSessionId = table.Column(type: "uniqueidentifier", nullable: false), + RowNumber = table.Column(type: "int", nullable: false), + RowDataJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientImportUploadRow", x => x.Id); + table.ForeignKey( + name: "FK_ClientImportUploadRow_ClientImportUploadSession_ClientImportUploadSessionId", + column: x => x.ClientImportUploadSessionId, + principalTable: "ClientImportUploadSession", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportUploadRow_ClientImportUploadSessionId_RowNumber", + table: "ClientImportUploadRow", + columns: new[] { "ClientImportUploadSessionId", "RowNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ClientImportUploadSession_OrganizationId_Status_ExpiresAtUtc", + table: "ClientImportUploadSession", + columns: new[] { "OrganizationId", "Status", "ExpiresAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClientImportUploadRow"); + + migrationBuilder.DropTable( + name: "ClientImportUploadSession"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.Designer.cs new file mode 100644 index 0000000..b552810 --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.Designer.cs @@ -0,0 +1,3939 @@ +// +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("20260327165440_AddAsyncDataExportJobs")] + partial class AddAsyncDataExportJobs + { + /// + 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.ClientImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FailedRows") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProcessedRows") + .HasColumnType("int"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SucceededRows") + .HasColumnType("int"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("ClientImportJob", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportJobId") + .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("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportJobId"); + + b.HasIndex("RowNumber"); + + b.ToTable("ClientImportJobError", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportUploadSessionId") + .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("RowDataJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportUploadSessionId", "RowNumber") + .IsUnique(); + + b.ToTable("ClientImportUploadRow", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConsumedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Status", "ExpiresAtUtc"); + + b.ToTable("ClientImportUploadSession", (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("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .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("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + + 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.DataExportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ContentType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DownloadCount") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FileContent") + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RequestedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("DataExportJob", (string)null); + }); + + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSquareConnected") + .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("SquareMerchantId") + .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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.ClientImportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportJob", "ClientImportJob") + .WithMany("Errors") + .HasForeignKey("ClientImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientImportJob"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportUploadSession", "Session") + .WithMany("Rows") + .HasForeignKey("ClientImportUploadSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.DataExportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.ClientImportJob", b => + { + b.Navigation("Errors"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Navigation("Rows"); + }); + + 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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260327165440_AddAsyncDataExportJobs.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.cs new file mode 100644 index 0000000..cb90d4c --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327165440_AddAsyncDataExportJobs.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAsyncDataExportJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataExportJob", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrganizationId = table.Column(type: "uniqueidentifier", nullable: false), + RequestedByUserId = table.Column(type: "uniqueidentifier", nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + ErrorMessage = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + FileName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + ContentType = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + FileContent = table.Column(type: "varbinary(max)", nullable: true), + DownloadCount = table.Column(type: "int", nullable: false), + StartedAtUtc = table.Column(type: "datetime2", nullable: true), + CompletedAtUtc = table.Column(type: "datetime2", nullable: true), + ExpiresAtUtc = table.Column(type: "datetime2", nullable: true), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + DeactivatedAtUtc = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataExportJob", x => x.Id); + table.ForeignKey( + name: "FK_DataExportJob_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_DataExportJob_OrganizationId_CreatedAt", + table: "DataExportJob", + columns: new[] { "OrganizationId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_DataExportJob_OrganizationId_Status", + table: "DataExportJob", + columns: new[] { "OrganizationId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataExportJob"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 92e92c0..3d36dea 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -195,6 +195,213 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AssignmentOrder", (string)null); }); + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FailedRows") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProcessedRows") + .HasColumnType("int"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SucceededRows") + .HasColumnType("int"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("ClientImportJob", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportJobId") + .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("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportJobId"); + + b.HasIndex("RowNumber"); + + b.ToTable("ClientImportJobError", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportUploadSessionId") + .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("RowDataJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportUploadSessionId", "RowNumber") + .IsUnique(); + + b.ToTable("ClientImportUploadRow", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConsumedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Status", "ExpiresAtUtc"); + + b.ToTable("ClientImportUploadSession", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => { b.Property("Id") @@ -324,6 +531,77 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CustomerPaymentProfile", "payment"); }); + modelBuilder.Entity("JobFlow.Domain.Models.DataExportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ContentType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DownloadCount") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FileContent") + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RequestedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("DataExportJob", (string)null); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => { b.Property("Id") @@ -2990,6 +3268,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Order"); }); + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportJob", "ClientImportJob") + .WithMany("Errors") + .HasForeignKey("ClientImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientImportJob"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportUploadSession", "Session") + .WithMany("Rows") + .HasForeignKey("ClientImportUploadSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => { b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") @@ -3030,6 +3352,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("OrganizationId"); }); + modelBuilder.Entity("JobFlow.Domain.Models.DataExportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Employee", b => { b.HasOne("JobFlow.Domain.Models.Organization", "Organization") @@ -3486,6 +3819,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AssignmentOrders"); }); + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJob", b => + { + b.Navigation("Errors"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Navigation("Rows"); + }); + modelBuilder.Entity("JobFlow.Domain.Models.Conversation", b => { b.Navigation("Messages"); diff --git a/JobFlow.Tests/JobFlow.Tests.csproj b/JobFlow.Tests/JobFlow.Tests.csproj index e7d0bf2..30419fe 100644 --- a/JobFlow.Tests/JobFlow.Tests.csproj +++ b/JobFlow.Tests/JobFlow.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/JobFlow.Tests/OrganizationClientControllerSwaggerSignatureTests.cs b/JobFlow.Tests/OrganizationClientControllerSwaggerSignatureTests.cs new file mode 100644 index 0000000..1086890 --- /dev/null +++ b/JobFlow.Tests/OrganizationClientControllerSwaggerSignatureTests.cs @@ -0,0 +1,34 @@ +using JobFlow.API.Controllers; +using JobFlow.API.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace JobFlow.Tests; + +public class OrganizationClientControllerSwaggerSignatureTests +{ + [Fact] + public void PreviewClientImport_UsesFromFormRequestDto_AndConsumesMultipartFormData() + { + var method = typeof(OrganizationClientController) + .GetMethod(nameof(OrganizationClientController.PreviewClientImport)); + + Assert.NotNull(method); + + var parameters = method!.GetParameters(); + Assert.NotEmpty(parameters); + + var requestParam = parameters[0]; + Assert.Equal(typeof(PreviewClientImportRequest), requestParam.ParameterType); + Assert.NotNull(requestParam.GetCustomAttributes(typeof(FromFormAttribute), inherit: true).SingleOrDefault()); + + Assert.DoesNotContain(parameters, p => p.ParameterType == typeof(IFormFile)); + + var consumes = method.GetCustomAttributes(typeof(ConsumesAttribute), inherit: true) + .Cast() + .SingleOrDefault(); + + Assert.NotNull(consumes); + Assert.Contains("multipart/form-data", consumes!.ContentTypes); + } +} From 2064bd327d29fba537447841a8eb54e8ec972940 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Fri, 27 Mar 2026 19:15:42 -0400 Subject: [PATCH 22/26] feat(estimates): Revise Estimate Workflow --- .../Controllers/ClientHubController.cs | 45 + JobFlow.API/Controllers/PaymentController.cs | 10 + .../Models/DTOs/CreateSubscriptionRequest.cs | 4 +- .../Models/DTOs/EmployeeRoleDto.cs | 2 +- .../Models/DTOs/EmployeeRolePresetDto.cs | 2 +- .../Models/DTOs/EmployeeRolePresetItemDto.cs | 2 +- JobFlow.Business/Models/DTOs/InviteUserDto.cs | 4 +- JobFlow.Business/Models/DTOs/JobDto.cs | 1 + JobFlow.Business/Models/DTOs/LoginDto.cs | 4 +- JobFlow.Business/Models/DTOs/RegisterDto.cs | 6 +- JobFlow.Business/Models/DTOs/UpdateRoleDto.cs | 2 +- .../Models/DTOs/WorkflowSettingsDtos.cs | 4 + .../Models/NewsletterSubscriptionRequest.cs | 2 +- .../Notifications/NotificationService.cs | 2 +- .../Services/AssignmentService.cs | 42 + .../Services/EmployeeInviteService.cs | 8 +- JobFlow.Business/Services/EmployeeService.cs | 2 +- JobFlow.Business/Services/EstimateService.cs | 22 + JobFlow.Business/Services/InvoiceService.cs | 57 +- .../Services/InvoicingSettingsService.cs | 6 +- .../Services/OrganizationServiceService.cs | 4 +- .../ServiceInterfaces/IInvoiceService.cs | 6 + JobFlow.Business/Services/UserService.cs | 3 +- JobFlow.Domain/IRepository.cs | 2 +- JobFlow.Domain/Models/Assignment.cs | 2 +- JobFlow.Domain/Models/AssignmentAssignee.cs | 4 +- JobFlow.Domain/Models/AssignmentHistory.cs | 2 +- JobFlow.Domain/Models/AssignmentOrder.cs | 4 +- JobFlow.Domain/Models/ClientImportJob.cs | 2 +- JobFlow.Domain/Models/ClientImportJobError.cs | 2 +- .../Models/ClientImportUploadRow.cs | 2 +- .../Models/ClientImportUploadSession.cs | 2 +- JobFlow.Domain/Models/DataExportJob.cs | 2 +- JobFlow.Domain/Models/Employee.cs | 6 +- JobFlow.Domain/Models/EmployeeInvite.cs | 8 +- JobFlow.Domain/Models/EmployeeRole.cs | 4 +- JobFlow.Domain/Models/EmployeeRolePreset.cs | 2 +- .../Models/EmployeeRolePresetItem.cs | 4 +- JobFlow.Domain/Models/Estimate.cs | 4 +- JobFlow.Domain/Models/Invoice.cs | 9 +- JobFlow.Domain/Models/Job.cs | 5 +- JobFlow.Domain/Models/JobRecurrence.cs | 4 +- JobFlow.Domain/Models/Order.cs | 8 +- JobFlow.Domain/Models/OrganizationClient.cs | 2 +- .../Models/OrganizationClientPortalSession.cs | 2 +- .../Models/OrganizationInvoicingSettings.cs | 3 + .../Models/OrganizationOnboardingStep.cs | 4 +- JobFlow.Domain/Models/OrganizationService.cs | 6 +- JobFlow.Domain/Models/OrganizationType.cs | 2 +- JobFlow.Domain/Models/PaymentHistory.cs | 10 +- JobFlow.Domain/Models/SubscriptionRecord.cs | 2 +- JobFlow.Domain/Models/SystemRole.cs | 2 +- JobFlow.Domain/Models/User.cs | 4 +- JobFlow.Domain/Models/UserRole.cs | 4 +- ...anizationInvoicingSettingsConfiguration.cs | 3 + .../JobFlowUnitOfWork.cs | 6 +- ...ddEstimateIdAndDepositSettings.Designer.cs | 3960 +++++++++++++++++ ...7210319_AddEstimateIdAndDepositSettings.cs | 83 + .../JobFlowDbContextModelSnapshot.cs | 21 + .../Repository.cs | 6 +- 60 files changed, 4350 insertions(+), 88 deletions(-) create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.Designer.cs create mode 100644 JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.cs diff --git a/JobFlow.API/Controllers/ClientHubController.cs b/JobFlow.API/Controllers/ClientHubController.cs index 45e4936..561230e 100644 --- a/JobFlow.API/Controllers/ClientHubController.cs +++ b/JobFlow.API/Controllers/ClientHubController.cs @@ -627,6 +627,46 @@ public async Task DownloadJobUpdateAttachment( : result.ToProblemDetails(); } + [HttpPost("jobs/{jobId:guid}/updates")] + [RequestSizeLimit(55_000_000)] + public async Task CreateJobUpdate(Guid jobId, [FromForm] ClientHubJobUpdateRequest request) + { + var organizationId = HttpContext.GetOrganizationId(); + var orgClientId = HttpContext.GetUserId(); + + var jobResult = await _jobs.GetJobForClientAsync(jobId, organizationId, orgClientId); + if (!jobResult.IsSuccess) + return jobResult.ToProblemDetails(); + + var uploads = new List(); + if (request.Attachments is not null) + { + foreach (var file in request.Attachments) + { + if (file.Length <= 0) + continue; + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream); + + uploads.Add(new JobUpdateAttachmentUpload( + file.FileName, + file.ContentType, + stream.ToArray(), + file.Length)); + } + } + + var createRequest = new CreateJobUpdateRequest( + request.Type, + request.Message, + null, + uploads); + + var result = await _jobUpdates.CreateAsync(jobId, organizationId, createRequest); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); + } + private static DateTimeOffset ToDateTimeOffset(DateTime value) { var utc = DateTime.SpecifyKind(value, DateTimeKind.Utc); @@ -757,4 +797,9 @@ public record CreateEstimateRevisionFormRequest( string? Message, List? Attachments); +public record ClientHubJobUpdateRequest( + JobUpdateType Type, + string? Message, + List? Attachments); + public record ClientHubReadRequest(Guid ConversationId); diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index ed2d277..ad27684 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -313,6 +313,16 @@ public async Task CreateDeposit([FromBody] DepositPaymentRequestD : null }); + // Record the deposit against the linked invoice + if (request.InvoiceId.HasValue && !string.IsNullOrEmpty(result.ProviderPaymentId)) + { + await _invoiceService.RecordDepositAsync( + request.InvoiceId.Value, + request.Amount, + request.Provider, + result.ProviderPaymentId); + } + return Ok(new { clientSecret = result.ClientSecret, diff --git a/JobFlow.Business/Models/DTOs/CreateSubscriptionRequest.cs b/JobFlow.Business/Models/DTOs/CreateSubscriptionRequest.cs index 34281ee..3623874 100644 --- a/JobFlow.Business/Models/DTOs/CreateSubscriptionRequest.cs +++ b/JobFlow.Business/Models/DTOs/CreateSubscriptionRequest.cs @@ -3,7 +3,7 @@ public class CreateSubscriptionRequest { public Guid PaymentProfileId { get; set; } - public string ProviderSubscriptionId { get; set; } - public string ProviderPriceId { get; set; } + public string ProviderSubscriptionId { get; set; } = string.Empty; + public string ProviderPriceId { get; set; } = string.Empty; public string? Status { get; set; } // Optional, default = "active" } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs index 841e743..5d8e691 100644 --- a/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs +++ b/JobFlow.Business/Models/DTOs/EmployeeRoleDto.cs @@ -3,7 +3,7 @@ public class EmployeeRoleDto { public Guid? Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public Guid OrganizationId { get; set; } } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs index e04656d..e73ee01 100644 --- a/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs +++ b/JobFlow.Business/Models/DTOs/EmployeeRolePresetDto.cs @@ -3,7 +3,7 @@ namespace JobFlow.Business.Models.DTOs; public class EmployeeRolePresetDto { public Guid? Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public string? IndustryKey { get; set; } public bool IsSystem { get; set; } diff --git a/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs b/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs index 801de3c..9a3cc25 100644 --- a/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs +++ b/JobFlow.Business/Models/DTOs/EmployeeRolePresetItemDto.cs @@ -3,7 +3,7 @@ namespace JobFlow.Business.Models.DTOs; public class EmployeeRolePresetItemDto { public Guid? Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public int SortOrder { get; set; } } diff --git a/JobFlow.Business/Models/DTOs/InviteUserDto.cs b/JobFlow.Business/Models/DTOs/InviteUserDto.cs index 97a861a..689a4d1 100644 --- a/JobFlow.Business/Models/DTOs/InviteUserDto.cs +++ b/JobFlow.Business/Models/DTOs/InviteUserDto.cs @@ -2,6 +2,6 @@ public class InviteUserDto { - public string Email { get; set; } - public string Role { get; set; } + public string Email { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/JobDto.cs b/JobFlow.Business/Models/DTOs/JobDto.cs index b55fe1a..1aeb72f 100644 --- a/JobFlow.Business/Models/DTOs/JobDto.cs +++ b/JobFlow.Business/Models/DTOs/JobDto.cs @@ -10,6 +10,7 @@ public class JobDto public JobLifecycleStatus LifecycleStatus { get; set; } public InvoicingWorkflow? InvoicingWorkflow { get; set; } public Guid OrganizationClientId { get; set; } + public Guid? EstimateId { get; set; } public OrganizationClientDto? OrganizationClient { get; set; } public IEnumerable? Assignments { get; set; } diff --git a/JobFlow.Business/Models/DTOs/LoginDto.cs b/JobFlow.Business/Models/DTOs/LoginDto.cs index 8182bbe..3731be1 100644 --- a/JobFlow.Business/Models/DTOs/LoginDto.cs +++ b/JobFlow.Business/Models/DTOs/LoginDto.cs @@ -2,6 +2,6 @@ public class LoginDto { - public string Email { get; set; } - public string Password { get; set; } + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/RegisterDto.cs b/JobFlow.Business/Models/DTOs/RegisterDto.cs index a59357b..d11f624 100644 --- a/JobFlow.Business/Models/DTOs/RegisterDto.cs +++ b/JobFlow.Business/Models/DTOs/RegisterDto.cs @@ -2,8 +2,8 @@ public class RegisterDto { - public string Email { get; set; } - public string Password { get; set; } + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; public Guid OrganizationId { get; set; } - public string Role { get; set; } + public string Role { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/UpdateRoleDto.cs b/JobFlow.Business/Models/DTOs/UpdateRoleDto.cs index 58a5f90..2a9013b 100644 --- a/JobFlow.Business/Models/DTOs/UpdateRoleDto.cs +++ b/JobFlow.Business/Models/DTOs/UpdateRoleDto.cs @@ -2,5 +2,5 @@ public class UpdateRoleDto { - public string Role { get; set; } + public string Role { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs index 0ef47d3..1d54cec 100644 --- a/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs +++ b/JobFlow.Business/Models/DTOs/WorkflowSettingsDtos.cs @@ -19,9 +19,13 @@ public class WorkflowStatusUpsertRequestDto public class InvoicingSettingsDto { public InvoicingWorkflow DefaultWorkflow { get; set; } + public bool DepositRequired { get; set; } + public decimal DepositPercentage { get; set; } } public class InvoicingSettingsUpsertRequestDto { public InvoicingWorkflow DefaultWorkflow { get; set; } + public bool DepositRequired { get; set; } + public decimal DepositPercentage { get; set; } } diff --git a/JobFlow.Business/Models/NewsletterSubscriptionRequest.cs b/JobFlow.Business/Models/NewsletterSubscriptionRequest.cs index 7bf6c60..0e93917 100644 --- a/JobFlow.Business/Models/NewsletterSubscriptionRequest.cs +++ b/JobFlow.Business/Models/NewsletterSubscriptionRequest.cs @@ -4,5 +4,5 @@ public class NewsletterSubscriptionRequest { public string Email { get; set; } = string.Empty; public int ListId { get; set; } - public string CaptchaToken { get; set; } + public string CaptchaToken { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 6d8af79..26a2283 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -164,7 +164,7 @@ await _emailService.SendContactEmailAsync(new ContactFormRequest Name = message.Name, Subject = message.Subject, Message = message.Body, - TemplateId = (int)message.TemplateId, + TemplateId = (int)(message.TemplateId ?? 0), Link = message.Link }); } diff --git a/JobFlow.Business/Services/AssignmentService.cs b/JobFlow.Business/Services/AssignmentService.cs index 2a15e0b..c6611e6 100644 --- a/JobFlow.Business/Services/AssignmentService.cs +++ b/JobFlow.Business/Services/AssignmentService.cs @@ -25,6 +25,7 @@ public class AssignmentService : IAssignmentService private readonly IWorkflowSettingsService _workflowSettings; private readonly IScheduleSettingsService _scheduleSettings; private readonly INotificationService _notificationService; + private readonly IJobService _jobService; private readonly ILogger _logger; public AssignmentService( @@ -34,6 +35,7 @@ public AssignmentService( IWorkflowSettingsService workflowSettings, IScheduleSettingsService scheduleSettings, INotificationService notificationService, + IJobService jobService, ILogger logger) { _unitOfWork = unitOfWork; @@ -47,6 +49,7 @@ public AssignmentService( _workflowSettings = workflowSettings; _scheduleSettings = scheduleSettings; _notificationService = notificationService; + _jobService = jobService; _logger = logger; } @@ -177,6 +180,12 @@ public async Task> UpdateAssignmentStatusAsync( _assignments.Update(assignment); await _unitOfWork.SaveChangesAsync(); + // When assignment is completed, check if all assignments for the job are done + if (dto.Status == AssignmentStatus.Completed) + { + await TryCompleteJobAsync(organizationId, assignment.JobId); + } + return Result.Success(await MapToDtoAsync(organizationId, assignment)); } @@ -318,6 +327,39 @@ private async Task MapToDtoAsync(Guid organizationId, Assignment return MapToDto(assignment, labelMap); } + private async Task TryCompleteJobAsync(Guid organizationId, Guid jobId) + { + var allJobAssignments = await _assignments.Query() + .Where(a => a.JobId == jobId) + .ToListAsync(); + + var allDone = allJobAssignments.All(a => + a.Status is AssignmentStatus.Completed or AssignmentStatus.Skipped or AssignmentStatus.Canceled); + + if (!allDone) + return; + + var hasCompleted = allJobAssignments.Any(a => a.Status == AssignmentStatus.Completed); + if (!hasCompleted) + return; + + var job = await _jobs.Query() + .Include(j => j.OrganizationClient) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job == null || job.LifecycleStatus == JobLifecycleStatus.Completed) + return; + + var result = await _jobService.UpdateJobStatusAsync(organizationId, jobId, JobLifecycleStatus.Completed); + if (result.IsFailure) + { + _logger.LogWarning( + "Auto-complete failed for job {JobId} after all assignments finished: {Error}", + jobId, + result.Error.Description); + } + } + private AssignmentDto MapToDto(Assignment assignment, Dictionary labelMap) { var dto = _mapper.Map(assignment); diff --git a/JobFlow.Business/Services/EmployeeInviteService.cs b/JobFlow.Business/Services/EmployeeInviteService.cs index d9a464b..446c57f 100644 --- a/JobFlow.Business/Services/EmployeeInviteService.cs +++ b/JobFlow.Business/Services/EmployeeInviteService.cs @@ -16,7 +16,6 @@ namespace JobFlow.Business.Services; [ScopedService] public class EmployeeInviteService : IEmployeeInviteService { - private readonly IRepository _employees; private readonly IFrontendSettings _frontendSettings; private readonly IRepository _invites; private readonly ILogger _logger; @@ -71,7 +70,8 @@ public async Task> InviteAsync(EmployeeInvite invite) .Include(i => i.Role) .FirstOrDefaultAsync(i => i.Id == invite.Id); var dto = _mapper.Map(invite); - await _notifications.SendEmployeeInviteNotificationAsync(createdInvite); + if (createdInvite is not null) + await _notifications.SendEmployeeInviteNotificationAsync(createdInvite); return Result.Success(dto); } @@ -146,8 +146,8 @@ public async Task> AcceptInviteAsync(Guid inviteToken) var employee = new Employee { Id = Guid.NewGuid(), - FirstName = invite.FirstName, - LastName = invite.LastName, + FirstName = invite.FirstName ?? string.Empty, + LastName = invite.LastName ?? string.Empty, Email = invite.Email, RoleId = invite.RoleId, OrganizationId = invite.OrganizationId, diff --git a/JobFlow.Business/Services/EmployeeService.cs b/JobFlow.Business/Services/EmployeeService.cs index 2f3237f..97a9ed3 100644 --- a/JobFlow.Business/Services/EmployeeService.cs +++ b/JobFlow.Business/Services/EmployeeService.cs @@ -38,7 +38,7 @@ public async Task> CreateAsync(CreateEmployeeRequest request var employee = new Employee { Id = Guid.NewGuid(), - OrganizationId = request.OrganizationId.Value, + OrganizationId = request.OrganizationId.GetValueOrDefault(), UserId = request.UserId, FirstName = request.FirstName, LastName = request.LastName, diff --git a/JobFlow.Business/Services/EstimateService.cs b/JobFlow.Business/Services/EstimateService.cs index 5c7b1e7..14b6511 100644 --- a/JobFlow.Business/Services/EstimateService.cs +++ b/JobFlow.Business/Services/EstimateService.cs @@ -18,6 +18,7 @@ public class EstimateService : IEstimateService private readonly IPdfGenerator pdfGenerator; private readonly IOrganizationClientPortalService clientPortalService; private readonly IFollowUpAutomationService? _followUpAutomation; + private readonly IJobService _jobService; private readonly IRepository estimates; private readonly IRepository clients; @@ -27,12 +28,14 @@ public EstimateService( INotificationService notificationService, IPdfGenerator pdfGenerator, IOrganizationClientPortalService clientPortalService, + IJobService jobService, IFollowUpAutomationService? followUpAutomation = null) { this.unitOfWork = unitOfWork; this.notificationService = notificationService; this.pdfGenerator = pdfGenerator; this.clientPortalService = clientPortalService; + _jobService = jobService; _followUpAutomation = followUpAutomation; estimates = unitOfWork.RepositoryOf(); @@ -281,6 +284,11 @@ private async Task> RespondAsync( await _followUpAutomation.StopEstimateSequenceAsync(estimate.Id, reason); } + if (newStatus == EstimateStatus.Accepted) + { + await CreateJobFromEstimateAsync(estimate); + } + return Result.Success(ToDto(estimate)); } @@ -291,6 +299,20 @@ private static void RecalculateTotals(Estimate estimate) estimate.Total = Math.Round(estimate.Subtotal + estimate.TaxTotal, 2); } + private async Task CreateJobFromEstimateAsync(Estimate estimate) + { + var job = new Job + { + OrganizationClientId = estimate.OrganizationClientId, + EstimateId = estimate.Id, + Title = estimate.Title ?? $"Job from {estimate.EstimateNumber}", + Comments = estimate.Description, + LifecycleStatus = JobLifecycleStatus.Approved + }; + + await _jobService.UpsertJobAsync(job, estimate.OrganizationId); + } + private async Task GenerateEstimateNumberAsync(Guid organizationId) { var prefix = $"EST-{DateTime.UtcNow:yyyyMMdd}-"; diff --git a/JobFlow.Business/Services/InvoiceService.cs b/JobFlow.Business/Services/InvoiceService.cs index e9490a1..6c3b622 100644 --- a/JobFlow.Business/Services/InvoiceService.cs +++ b/JobFlow.Business/Services/InvoiceService.cs @@ -115,7 +115,8 @@ public async Task> UpsertInvoiceAsync(Invoice model) model.Id = Guid.NewGuid(); // Attach invoice to line items - foreach (var li in model.LineItems) li.InvoiceId = model.Id; + if (model.LineItems is not null) + foreach (var li in model.LineItems) li.InvoiceId = model.Id; await invoices.AddAsync(model); } @@ -230,6 +231,44 @@ public async Task> MarkPaidAsync( return Result.Success(invoice); } + public async Task> RecordDepositAsync( + Guid invoiceId, + decimal depositAmount, + PaymentProvider provider, + string externalPaymentId) + { + var invoice = await invoices.Query() + .Include(e => e.OrganizationClient) + .ThenInclude(e => e.Organization) + .FirstOrDefaultAsync(i => i.Id == invoiceId); + + if (invoice == null) + return Result.Failure(InvoiceErrors.NotFound); + + if (invoice.Status == InvoiceStatus.Paid) + return Result.Success(invoice); + + invoice.AmountPaid += depositAmount; + invoice.PaymentProvider = provider; + invoice.ExternalPaymentId = externalPaymentId; + + if (invoice.AmountPaid >= invoice.TotalAmount) + { + invoice.Status = InvoiceStatus.Paid; + invoice.PaidAt = DateTimeOffset.UtcNow; + } + + invoices.Update(invoice); + await unitOfWork.SaveChangesAsync(); + + if (invoice.Status == InvoiceStatus.Paid && _realtimeNotifier != null) + { + await _realtimeNotifier.NotifyInvoicePaidAsync(invoice); + } + + return Result.Success(invoice); + } + private async Task SendInvoiceToClientAsync(Invoice invoice) { var client = invoice.OrganizationClient; @@ -259,7 +298,20 @@ private async Task SendInvoiceToClientAsync(Invoice invoice) private async Task> CreateInvoiceFromEstimateAsync(Guid organizationId, Job job) { - var estimate = await estimates.Query() + Estimate? estimate = null; + + // Prefer direct link via EstimateId on Job + if (job.EstimateId.HasValue) + { + estimate = await estimates.Query() + .Include(e => e.LineItems) + .FirstOrDefaultAsync(e => + e.Id == job.EstimateId.Value && + e.Status == EstimateStatus.Accepted); + } + + // Fallback: match by client + accepted status + estimate ??= await estimates.Query() .Include(e => e.LineItems) .OrderByDescending(e => e.UpdatedAt ?? e.CreatedAt) .FirstOrDefaultAsync(e => @@ -283,6 +335,7 @@ private async Task> CreateInvoiceFromEstimateAsync(Guid organiza OrganizationId = organizationId, OrganizationClientId = job.OrganizationClientId, JobId = job.Id, + EstimateId = estimate.Id, InvoiceNumber = await _numberGenerator.GenerateAsync(organizationId), InvoiceDate = DateTime.UtcNow, DueDate = DateTime.UtcNow.AddDays(14), diff --git a/JobFlow.Business/Services/InvoicingSettingsService.cs b/JobFlow.Business/Services/InvoicingSettingsService.cs index 24fc031..870cfd4 100644 --- a/JobFlow.Business/Services/InvoicingSettingsService.cs +++ b/JobFlow.Business/Services/InvoicingSettingsService.cs @@ -54,6 +54,8 @@ public async Task> UpsertInvoicingSettingsAsync( } settings.DefaultWorkflow = dto.DefaultWorkflow; + settings.DepositRequired = dto.DepositRequired; + settings.DepositPercentage = dto.DepositPercentage; await _unitOfWork.SaveChangesAsync(); @@ -64,7 +66,9 @@ private static InvoicingSettingsDto Map(OrganizationInvoicingSettings settings) { return new InvoicingSettingsDto { - DefaultWorkflow = settings.DefaultWorkflow + DefaultWorkflow = settings.DefaultWorkflow, + DepositRequired = settings.DepositRequired, + DepositPercentage = settings.DepositPercentage }; } } \ No newline at end of file diff --git a/JobFlow.Business/Services/OrganizationServiceService.cs b/JobFlow.Business/Services/OrganizationServiceService.cs index 88fb2b4..1479299 100644 --- a/JobFlow.Business/Services/OrganizationServiceService.cs +++ b/JobFlow.Business/Services/OrganizationServiceService.cs @@ -57,7 +57,7 @@ public async Task>> .ToListAsync(); if (orgServices.Count == 0) return Result.Failure>( - OrganizationServiceErrors.NoServiceFoundForOrganizationName(organization.OrganizationName)); + OrganizationServiceErrors.NoServiceFoundForOrganizationName(organization.OrganizationName ?? string.Empty)); return Result.Success>(orgServices); } @@ -72,7 +72,7 @@ public async Task>> .FirstOrDefaultAsync(org => org.OrganizationId == organizationId); if (orgService == null) return Result.Failure( - OrganizationServiceErrors.NoServiceFoundForOrganizationName(organization.OrganizationName)); + OrganizationServiceErrors.NoServiceFoundForOrganizationName(organization.OrganizationName ?? string.Empty)); return Result.Success(orgService); } diff --git a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs index 3139adc..4a39a61 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs @@ -20,4 +20,10 @@ Task> MarkPaidAsync( PaymentProvider provider, string externalPaymentId, decimal amountReceived); + + Task> RecordDepositAsync( + Guid invoiceId, + decimal depositAmount, + PaymentProvider provider, + string externalPaymentId); } \ No newline at end of file diff --git a/JobFlow.Business/Services/UserService.cs b/JobFlow.Business/Services/UserService.cs index 8351bd8..57a7498 100644 --- a/JobFlow.Business/Services/UserService.cs +++ b/JobFlow.Business/Services/UserService.cs @@ -117,7 +117,8 @@ public async Task> GetUserByFirebaseUid(string uid) .OrderByDescending(s => s.StartDate) .FirstOrDefaultAsync(); - user.Organization.SubscriptionPlanName = latestSubscription?.PlanName; + if (user.Organization is not null) + user.Organization.SubscriptionPlanName = latestSubscription?.PlanName; } return Result.Success(user); diff --git a/JobFlow.Domain/IRepository.cs b/JobFlow.Domain/IRepository.cs index 5caa8c9..2b103fa 100644 --- a/JobFlow.Domain/IRepository.cs +++ b/JobFlow.Domain/IRepository.cs @@ -5,7 +5,7 @@ namespace JobFlow.Domain; public interface IRepository where TEntity : class { // 🔹 Query - IQueryable Query(Expression> filter = null); + IQueryable Query(Expression>? filter = null); IQueryable QueryWithNoTracking(); // 🔹 Create diff --git a/JobFlow.Domain/Models/Assignment.cs b/JobFlow.Domain/Models/Assignment.cs index 1a9956c..84b4a1f 100644 --- a/JobFlow.Domain/Models/Assignment.cs +++ b/JobFlow.Domain/Models/Assignment.cs @@ -5,7 +5,7 @@ namespace JobFlow.Domain.Models; public class Assignment : Entity { public Guid JobId { get; set; } - public virtual Job Job { get; set; } + public virtual Job Job { get; set; } = null!; // Window vs exact is semantic; both use ScheduledStart/End public ScheduleType ScheduleType { get; set; } = ScheduleType.Window; diff --git a/JobFlow.Domain/Models/AssignmentAssignee.cs b/JobFlow.Domain/Models/AssignmentAssignee.cs index 211796e..c9ee5b9 100644 --- a/JobFlow.Domain/Models/AssignmentAssignee.cs +++ b/JobFlow.Domain/Models/AssignmentAssignee.cs @@ -7,10 +7,10 @@ namespace JobFlow.Domain.Models public class AssignmentAssignee { public Guid AssignmentId { get; set; } - public virtual Assignment Assignment { get; set; } + public virtual Assignment Assignment { get; set; } = null!; public Guid EmployeeId { get; set; } // assuming you already have Employee entity - public virtual Employee Employee { get; set; } + public virtual Employee Employee { get; set; } = null!; public bool IsLead { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/JobFlow.Domain/Models/AssignmentHistory.cs b/JobFlow.Domain/Models/AssignmentHistory.cs index 605510a..cb76fa8 100644 --- a/JobFlow.Domain/Models/AssignmentHistory.cs +++ b/JobFlow.Domain/Models/AssignmentHistory.cs @@ -8,7 +8,7 @@ namespace JobFlow.Domain.Models public class AssignmentHistory : Entity { public Guid AssignmentId { get; set; } - public virtual Assignment Assignment { get; set; } + public virtual Assignment Assignment { get; set; } = null!; public AssignmentEventType EventType { get; set; } diff --git a/JobFlow.Domain/Models/AssignmentOrder.cs b/JobFlow.Domain/Models/AssignmentOrder.cs index e9fc83e..9383dd6 100644 --- a/JobFlow.Domain/Models/AssignmentOrder.cs +++ b/JobFlow.Domain/Models/AssignmentOrder.cs @@ -6,6 +6,6 @@ public class AssignmentOrder public Guid OrderId { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public virtual Assignment Assignment { get; set; } - public virtual Order Order { get; set; } + public virtual Assignment Assignment { get; set; } = null!; + public virtual Order Order { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/ClientImportJob.cs b/JobFlow.Domain/Models/ClientImportJob.cs index 0005ffc..ba1468f 100644 --- a/JobFlow.Domain/Models/ClientImportJob.cs +++ b/JobFlow.Domain/Models/ClientImportJob.cs @@ -13,6 +13,6 @@ public class ClientImportJob : Entity public DateTime? StartedAtUtc { get; set; } public DateTime? CompletedAtUtc { get; set; } - public virtual Organization Organization { get; set; } + public virtual Organization Organization { get; set; } = null!; public virtual ICollection Errors { get; set; } = new List(); } diff --git a/JobFlow.Domain/Models/ClientImportJobError.cs b/JobFlow.Domain/Models/ClientImportJobError.cs index c20aeee..9b9af93 100644 --- a/JobFlow.Domain/Models/ClientImportJobError.cs +++ b/JobFlow.Domain/Models/ClientImportJobError.cs @@ -6,5 +6,5 @@ public class ClientImportJobError : Entity public int RowNumber { get; set; } public string Message { get; set; } = string.Empty; - public virtual ClientImportJob ClientImportJob { get; set; } + public virtual ClientImportJob ClientImportJob { get; set; } = null!; } diff --git a/JobFlow.Domain/Models/ClientImportUploadRow.cs b/JobFlow.Domain/Models/ClientImportUploadRow.cs index 1f3482d..a79de44 100644 --- a/JobFlow.Domain/Models/ClientImportUploadRow.cs +++ b/JobFlow.Domain/Models/ClientImportUploadRow.cs @@ -6,5 +6,5 @@ public class ClientImportUploadRow : Entity public int RowNumber { get; set; } public string RowDataJson { get; set; } = string.Empty; - public virtual ClientImportUploadSession Session { get; set; } + public virtual ClientImportUploadSession Session { get; set; } = null!; } diff --git a/JobFlow.Domain/Models/ClientImportUploadSession.cs b/JobFlow.Domain/Models/ClientImportUploadSession.cs index 4615965..8416ecf 100644 --- a/JobFlow.Domain/Models/ClientImportUploadSession.cs +++ b/JobFlow.Domain/Models/ClientImportUploadSession.cs @@ -9,6 +9,6 @@ public class ClientImportUploadSession : Entity public DateTime ExpiresAtUtc { get; set; } public DateTime? ConsumedAtUtc { get; set; } - public virtual Organization Organization { get; set; } + public virtual Organization Organization { get; set; } = null!; public virtual ICollection Rows { get; set; } = new List(); } diff --git a/JobFlow.Domain/Models/DataExportJob.cs b/JobFlow.Domain/Models/DataExportJob.cs index 3b0cbb2..5ed0f2b 100644 --- a/JobFlow.Domain/Models/DataExportJob.cs +++ b/JobFlow.Domain/Models/DataExportJob.cs @@ -14,5 +14,5 @@ public class DataExportJob : Entity public DateTime? CompletedAtUtc { get; set; } public DateTime? ExpiresAtUtc { get; set; } - public virtual Organization Organization { get; set; } + public virtual Organization Organization { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/Employee.cs b/JobFlow.Domain/Models/Employee.cs index 13785ea..ed96f40 100644 --- a/JobFlow.Domain/Models/Employee.cs +++ b/JobFlow.Domain/Models/Employee.cs @@ -9,7 +9,7 @@ public class Employee : Entity public string? Email { get; set; } public string? PhoneNumber { get; set; } public Guid RoleId { get; set; } - public bool IsActive { get; set; } + public new bool IsActive { get; set; } public DateTime? HireDate { get; set; } public DateTime? TerminationDate { get; set; } public string? JobTitle { get; set; } @@ -17,7 +17,7 @@ public class Employee : Entity public string? ProfilePictureUrl { get; set; } public string FullName => $"{FirstName} {LastName}"; public bool IsConnectedUser => UserId.HasValue; - public EmployeeRole Role { get; set; } - public Organization Organization { get; set; } + public EmployeeRole Role { get; set; } = null!; + public Organization Organization { get; set; } = null!; public User? User { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EmployeeInvite.cs b/JobFlow.Domain/Models/EmployeeInvite.cs index 2e08ed0..7761af8 100644 --- a/JobFlow.Domain/Models/EmployeeInvite.cs +++ b/JobFlow.Domain/Models/EmployeeInvite.cs @@ -5,7 +5,7 @@ namespace JobFlow.Domain.Models; public class EmployeeInvite : Entity { public Guid OrganizationId { get; set; } - public string Email { get; set; } + public string Email { get; set; } = string.Empty; public string? FirstName { get; set; } public string? LastName { get; set; } public Guid RoleId { get; set; } @@ -14,10 +14,10 @@ public class EmployeeInvite : Entity public DateTime ExpiresAt { get; set; } public EmployeeInviteStatus Status { get; set; } public string FullName => $"{FirstName} {LastName}".Trim(); - public string ShortCode { get; set; } + public string ShortCode { get; set; } = string.Empty; public DateTime? AccessedAt { get; set; } public int AccessCount { get; set; } public string? AccessIpAddress { get; set; } - public Organization Organization { get; set; } - public EmployeeRole Role { get; set; } + public Organization Organization { get; set; } = null!; + public EmployeeRole Role { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EmployeeRole.cs b/JobFlow.Domain/Models/EmployeeRole.cs index dda1a4c..c3499eb 100644 --- a/JobFlow.Domain/Models/EmployeeRole.cs +++ b/JobFlow.Domain/Models/EmployeeRole.cs @@ -3,10 +3,10 @@ public class EmployeeRole { public Guid Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public Guid OrganizationId { get; set; } - public Organization Organization { get; set; } + public Organization Organization { get; set; } = null!; public ICollection Employees { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/EmployeeRolePreset.cs b/JobFlow.Domain/Models/EmployeeRolePreset.cs index 4c352fd..3d0127f 100644 --- a/JobFlow.Domain/Models/EmployeeRolePreset.cs +++ b/JobFlow.Domain/Models/EmployeeRolePreset.cs @@ -4,7 +4,7 @@ public class EmployeeRolePreset : Entity { public Guid? OrganizationId { get; set; } public string? IndustryKey { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public bool IsSystem { get; set; } public Organization? Organization { get; set; } diff --git a/JobFlow.Domain/Models/EmployeeRolePresetItem.cs b/JobFlow.Domain/Models/EmployeeRolePresetItem.cs index df7d787..3971f95 100644 --- a/JobFlow.Domain/Models/EmployeeRolePresetItem.cs +++ b/JobFlow.Domain/Models/EmployeeRolePresetItem.cs @@ -3,8 +3,8 @@ namespace JobFlow.Domain.Models; public class EmployeeRolePresetItem : Entity { public Guid PresetId { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public string? Description { get; set; } public int SortOrder { get; set; } - public EmployeeRolePreset Preset { get; set; } + public EmployeeRolePreset Preset { get; set; } = null!; } diff --git a/JobFlow.Domain/Models/Estimate.cs b/JobFlow.Domain/Models/Estimate.cs index 39f9de1..381147f 100644 --- a/JobFlow.Domain/Models/Estimate.cs +++ b/JobFlow.Domain/Models/Estimate.cs @@ -19,8 +19,8 @@ public class Estimate : Entity public decimal TaxTotal { get; set; } public decimal Total { get; set; } - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset? UpdatedAt { get; set; } + public new DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public new DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? SentAt { get; set; } // Public unauthenticated link diff --git a/JobFlow.Domain/Models/Invoice.cs b/JobFlow.Domain/Models/Invoice.cs index 3b867f7..ae44dc1 100644 --- a/JobFlow.Domain/Models/Invoice.cs +++ b/JobFlow.Domain/Models/Invoice.cs @@ -4,10 +4,11 @@ namespace JobFlow.Domain.Models; public class Invoice : Entity { - public string InvoiceNumber { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; public Guid OrganizationId { get; set; } public Guid OrganizationClientId { get; set; } public Guid? JobId { get; set; } + public Guid? EstimateId { get; set; } public Guid? OrderId { get; set; } public DateTime InvoiceDate { get; set; } public DateTime DueDate { get; set; } @@ -19,10 +20,10 @@ public class Invoice : Entity public PaymentProvider PaymentProvider { get; set; } public string? ExternalPaymentId { get; set; } public DateTimeOffset? PaidAt { get; set; } - public virtual OrganizationClient OrganizationClient { get; set; } + public virtual OrganizationClient OrganizationClient { get; set; } = null!; public virtual Job? Job { get; set; } - public virtual Order Order { get; set; } - public virtual ICollection Payments { get; set; } + public virtual Order Order { get; set; } = null!; + public virtual ICollection Payments { get; set; } = new List(); public virtual ICollection LineItems { get; set; } = new List(); diff --git a/JobFlow.Domain/Models/Job.cs b/JobFlow.Domain/Models/Job.cs index 55ab4c6..10cbb55 100644 --- a/JobFlow.Domain/Models/Job.cs +++ b/JobFlow.Domain/Models/Job.cs @@ -10,7 +10,10 @@ public class Job : Entity public string? Comments { get; set; } public Guid OrganizationClientId { get; set; } - public virtual OrganizationClient OrganizationClient { get; set; } + public virtual OrganizationClient OrganizationClient { get; set; } = null!; + + public Guid? EstimateId { get; set; } + public virtual Estimate? Estimate { get; set; } public double? Latitude { get; set; } public double? Longitude { get; set; } diff --git a/JobFlow.Domain/Models/JobRecurrence.cs b/JobFlow.Domain/Models/JobRecurrence.cs index 575edf1..d300854 100644 --- a/JobFlow.Domain/Models/JobRecurrence.cs +++ b/JobFlow.Domain/Models/JobRecurrence.cs @@ -5,7 +5,7 @@ namespace JobFlow.Domain.Models public class JobRecurrence : Entity { public Guid JobId { get; set; } - public virtual Job Job { get; set; } + public virtual Job Job { get; set; } = null!; public RecurrenceFrequency Frequency { get; set; } = RecurrenceFrequency.Weekly; @@ -25,7 +25,7 @@ public class JobRecurrence : Entity public DateTime StartDate { get; set; } // date-only semantics (stored as DateTime) public DateTime? EndDate { get; set; } - public bool IsActive { get; set; } = true; + public new bool IsActive { get; set; } = true; /// How far ahead to generate when calendar fetches. public int GenerateDaysAhead { get; set; } = 28; diff --git a/JobFlow.Domain/Models/Order.cs b/JobFlow.Domain/Models/Order.cs index a76e22f..890aeb6 100644 --- a/JobFlow.Domain/Models/Order.cs +++ b/JobFlow.Domain/Models/Order.cs @@ -5,10 +5,10 @@ public class Order : Entity public Guid OrganizationClientId { get; set; } public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } - public string Status { get; set; } // Pending, Completed, Canceled - public string Notes { get; set; } - public virtual OrganizationClient OrganizationClient { get; set; } - public virtual ICollection Invoices { get; set; } + public string Status { get; set; } = string.Empty; // Pending, Completed, Canceled + public string Notes { get; set; } = string.Empty; + public virtual OrganizationClient OrganizationClient { get; set; } = null!; + public virtual ICollection Invoices { get; set; } = new List(); public virtual ICollection AssignmentOrders { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/OrganizationClient.cs b/JobFlow.Domain/Models/OrganizationClient.cs index ba7787d..39a61c6 100644 --- a/JobFlow.Domain/Models/OrganizationClient.cs +++ b/JobFlow.Domain/Models/OrganizationClient.cs @@ -13,7 +13,7 @@ public class OrganizationClient : Entity public string? EmailAddress { get; set; } public string? ZipCode { get; set; } - public virtual Organization Organization { get; set; } + public virtual Organization Organization { get; set; } = null!; // ✅ Replace the old join collection with a direct Jobs collection public virtual ICollection Jobs { get; set; } = new List(); diff --git a/JobFlow.Domain/Models/OrganizationClientPortalSession.cs b/JobFlow.Domain/Models/OrganizationClientPortalSession.cs index ec0d4e6..c5b5e02 100644 --- a/JobFlow.Domain/Models/OrganizationClientPortalSession.cs +++ b/JobFlow.Domain/Models/OrganizationClientPortalSession.cs @@ -11,7 +11,7 @@ public class OrganizationClientPortalSession : Entity public string TokenHash { get; set; } = string.Empty; public DateTimeOffset ExpiresAt { get; set; } - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public new DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? RedeemedAt { get; set; } public OrganizationClient? OrganizationClient { get; set; } diff --git a/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs b/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs index 9ba5816..9b76c73 100644 --- a/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs +++ b/JobFlow.Domain/Models/OrganizationInvoicingSettings.cs @@ -6,4 +6,7 @@ public class OrganizationInvoicingSettings : Entity { public Guid OrganizationId { get; set; } public InvoicingWorkflow DefaultWorkflow { get; set; } = InvoicingWorkflow.SendInvoice; + + public bool DepositRequired { get; set; } + public decimal DepositPercentage { get; set; } } \ No newline at end of file diff --git a/JobFlow.Domain/Models/OrganizationOnboardingStep.cs b/JobFlow.Domain/Models/OrganizationOnboardingStep.cs index 43a7689..3878121 100644 --- a/JobFlow.Domain/Models/OrganizationOnboardingStep.cs +++ b/JobFlow.Domain/Models/OrganizationOnboardingStep.cs @@ -3,10 +3,10 @@ public class OrganizationOnboardingStep : Entity { public Guid OrganizationId { get; set; } - public string StepName { get; set; } + public string StepName { get; set; } = string.Empty; public bool IsCompleted { get; set; } public DateTimeOffset? CompletedAt { get; set; } // Navigation - public Organization Organization { get; set; } + public Organization Organization { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/OrganizationService.cs b/JobFlow.Domain/Models/OrganizationService.cs index a367552..eb4eada 100644 --- a/JobFlow.Domain/Models/OrganizationService.cs +++ b/JobFlow.Domain/Models/OrganizationService.cs @@ -3,7 +3,7 @@ public class OrganizationService : Entity { public Guid OrganizationId { get; set; } - public string ServiceName { get; set; } - public bool IsActive { get; set; } - public virtual Organization Organization { get; set; } + public string ServiceName { get; set; } = string.Empty; + public new bool IsActive { get; set; } + public virtual Organization Organization { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/OrganizationType.cs b/JobFlow.Domain/Models/OrganizationType.cs index 7aa3528..e2108bc 100644 --- a/JobFlow.Domain/Models/OrganizationType.cs +++ b/JobFlow.Domain/Models/OrganizationType.cs @@ -2,5 +2,5 @@ public class OrganizationType : Entity { - public string TypeName { get; set; } + public string TypeName { get; set; } = string.Empty; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/PaymentHistory.cs b/JobFlow.Domain/Models/PaymentHistory.cs index 7f2bb73..aa9bf6e 100644 --- a/JobFlow.Domain/Models/PaymentHistory.cs +++ b/JobFlow.Domain/Models/PaymentHistory.cs @@ -13,10 +13,10 @@ public class PaymentHistory : Entity public string? SubscriptionId { get; set; } public string? CustomerId { get; set; } public long AmountPaid { get; set; } - public string Currency { get; set; } - public string Status { get; set; } + public string Currency { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; public DateTime PaidAt { get; set; } - public string EventType { get; set; } - public string RawEventJson { get; set; } - public virtual Invoice Invoice { get; set; } + public string EventType { get; set; } = string.Empty; + public string RawEventJson { get; set; } = string.Empty; + public virtual Invoice Invoice { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/SubscriptionRecord.cs b/JobFlow.Domain/Models/SubscriptionRecord.cs index f7db5bf..b651e4c 100644 --- a/JobFlow.Domain/Models/SubscriptionRecord.cs +++ b/JobFlow.Domain/Models/SubscriptionRecord.cs @@ -13,5 +13,5 @@ public class SubscriptionRecord : Entity public DateTime StartDate { get; set; } public DateTime? CanceledAt { get; set; } - public virtual CustomerPaymentProfile PaymentProfile { get; set; } + public virtual CustomerPaymentProfile PaymentProfile { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Domain/Models/SystemRole.cs b/JobFlow.Domain/Models/SystemRole.cs index 089e5ce..b3695bb 100644 --- a/JobFlow.Domain/Models/SystemRole.cs +++ b/JobFlow.Domain/Models/SystemRole.cs @@ -3,6 +3,6 @@ public class SystemRole { public Guid Id { get; set; } - public string Name { get; set; } + public string Name { get; set; } = string.Empty; public ICollection UserRoles { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/User.cs b/JobFlow.Domain/Models/User.cs index e6e33a8..45f2884 100644 --- a/JobFlow.Domain/Models/User.cs +++ b/JobFlow.Domain/Models/User.cs @@ -8,8 +8,8 @@ public class User : Entity public Guid OrganizationId { get; set; } public string? FirebaseUid { get; set; } public Guid? ClientId { get; set; } - public Organization Organization { get; set; } - public OrganizationClient Client { get; set; } + public Organization Organization { get; set; } = null!; + public OrganizationClient Client { get; set; } = null!; public ICollection UserRoles { get; set; } = new List(); public ICollection Employees { get; set; } = new List(); } \ No newline at end of file diff --git a/JobFlow.Domain/Models/UserRole.cs b/JobFlow.Domain/Models/UserRole.cs index e8bce2a..5bd34f1 100644 --- a/JobFlow.Domain/Models/UserRole.cs +++ b/JobFlow.Domain/Models/UserRole.cs @@ -4,6 +4,6 @@ public class UserRole { public Guid UserId { get; set; } public Guid RoleId { get; set; } - public virtual User User { get; set; } - public virtual SystemRole Role { get; set; } + public virtual User User { get; set; } = null!; + public virtual SystemRole Role { get; set; } = null!; } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs index 1f0f00e..8b5ffe9 100644 --- a/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs +++ b/JobFlow.Infrastructure.Persistence/Configurations/OrganizationInvoicingSettingsConfiguration.cs @@ -14,5 +14,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.DefaultWorkflow) .HasConversion() .HasDefaultValue(JobFlow.Domain.Enums.InvoicingWorkflow.SendInvoice); + + builder.Property(x => x.DepositPercentage) + .HasPrecision(5, 2); } } \ No newline at end of file diff --git a/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs b/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs index b8aeaa2..54ae49b 100644 --- a/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs +++ b/JobFlow.Infrastructure.Persistence/JobFlowUnitOfWork.cs @@ -9,7 +9,7 @@ public class JobFlowUnitOfWork : IUnitOfWork { private readonly IDbContextFactory _factory; private readonly ILogger _logger; - private JobFlowDbContext _context; + private JobFlowDbContext _context = null!; public JobFlowUnitOfWork(ILogger logger, IDbContextFactory factory) { @@ -77,7 +77,7 @@ public async Task SaveChangesAsync(bool resetDbContext = true) public TEntity GetAddedEntity(TEntity entity) where TEntity : class { if (_context.Entry(entity).State == EntityState.Added) return entity; - return null; + return null!; } public void Dispose() @@ -99,7 +99,7 @@ private void ResetDbContext() private void ClearDbContext() { if (_context != null) _context.Dispose(); - _context = null; + _context = null!; } private void EnsureDbContext() diff --git a/JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.Designer.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.Designer.cs new file mode 100644 index 0000000..6466d7b --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.Designer.cs @@ -0,0 +1,3960 @@ +// +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("20260327210319_AddEstimateIdAndDepositSettings")] + partial class AddEstimateIdAndDepositSettings + { + /// + 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.ClientImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FailedRows") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProcessedRows") + .HasColumnType("int"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SucceededRows") + .HasColumnType("int"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("ClientImportJob", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportJobId") + .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("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportJobId"); + + b.HasIndex("RowNumber"); + + b.ToTable("ClientImportJobError", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClientImportUploadSessionId") + .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("RowDataJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RowNumber") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ClientImportUploadSessionId", "RowNumber") + .IsUnique(); + + b.ToTable("ClientImportUploadRow", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConsumedAtUtc") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SourceSystem") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalRows") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Status", "ExpiresAtUtc"); + + b.ToTable("ClientImportUploadSession", (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("EncryptedAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("EncryptedRefreshToken") + .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("SquareLocationId") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAtUtc") + .HasColumnType("datetime2"); + + 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.DataExportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ContentType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DownloadCount") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("FileContent") + .HasColumnType("varbinary(max)"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RequestedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.HasIndex("OrganizationId", "Status"); + + b.ToTable("DataExportJob", (string)null); + }); + + 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("Description") + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + 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.EmployeeRolePreset", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("EmployeeRolePresets", (string)null); + + b.HasData( + new + { + Id = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for field service teams.", + IndustryKey = "home-services", + IsActive = true, + IsSystem = true, + Name = "Home services" + }, + new + { + Id = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for creative studios.", + IndustryKey = "creative", + IsActive = true, + IsSystem = true, + Name = "Creative" + }, + new + { + Id = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for consulting teams.", + IndustryKey = "consulting", + IsActive = true, + IsSystem = true, + Name = "Consulting" + }, + new + { + Id = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Default roles for repair shops.", + IndustryKey = "tech-repair", + IsActive = true, + IsSystem = true, + Name = "Tech repair" + }); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", 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(240) + .HasColumnType("nvarchar(240)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("PresetId") + .HasColumnType("uniqueidentifier"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PresetId"); + + b.ToTable("EmployeeRolePresetItems", (string)null); + + b.HasData( + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111111"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Field technician for on-site work.", + IsActive = true, + Name = "Technician", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111112"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Lead for quality checks and approvals.", + IsActive = true, + Name = "Supervisor", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111113"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Routes schedules and job assignments.", + IsActive = true, + Name = "Dispatcher", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-1111-1111-1111-111111111114"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-1111-1111-1111-111111111111"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222221"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Primary creator and deliverable owner.", + IsActive = true, + Name = "Designer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222222"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns timelines, approvals, and client comms.", + IsActive = true, + Name = "Producer", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222223"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Schedules tasks and supports delivery.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-2222-2222-2222-222222222224"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-2222-2222-2222-222222222222"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333331"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client-facing delivery specialist.", + IsActive = true, + Name = "Consultant", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333332"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Owns engagement delivery and quality.", + IsActive = true, + Name = "Lead", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333333"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Plans meetings and follow-ups.", + IsActive = true, + Name = "Coordinator", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-3333-3333-3333-333333333334"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Back-office support and billing.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-3333-3333-3333-333333333333"), + SortOrder = 4 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444441"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Executes diagnostics and repairs.", + IsActive = true, + Name = "Repair Tech", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 1 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444442"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Final testing and release approvals.", + IsActive = true, + Name = "QA", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 2 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444443"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Client intake and status updates.", + IsActive = true, + Name = "Service Advisor", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 3 + }, + new + { + Id = new Guid("2a2b3c4d-4444-4444-4444-444444444444"), + CreatedAt = new DateTime(2026, 3, 23, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Operations and billing support.", + IsActive = true, + Name = "Admin", + PresetId = new Guid("1a2b3c4d-4444-4444-4444-444444444444"), + SortOrder = 4 + }); + }); + + 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.FollowUpExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FollowUpRunId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ScheduledFor") + .HasColumnType("datetimeoffset"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WasSent") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpRunId", "StepOrder"); + + b.ToTable("FollowUpExecutionLogs", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", 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("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("NextStepOrder") + .HasColumnType("int"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StopReason") + .HasColumnType("int"); + + b.Property("TriggerEntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "OrganizationClientId", "Status"); + + b.HasIndex("OrganizationId", "SequenceType", "TriggerEntityId", "Status"); + + b.ToTable("FollowUpRuns", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", 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("DefaultChannel") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SequenceType") + .HasColumnType("int"); + + b.Property("StopOnClientReply") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "SequenceType", "IsActive"); + + b.ToTable("FollowUpSequences", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChannelOverride") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("DelayHours") + .HasColumnType("int"); + + b.Property("FollowUpSequenceId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsEscalation") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("StepOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FollowUpSequenceId", "StepOrder") + .IsUnique(); + + b.ToTable("FollowUpSteps", (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("EstimateId") + .HasColumnType("uniqueidentifier"); + + 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("EstimateId") + .HasColumnType("uniqueidentifier"); + + 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("EstimateId"); + + 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.JobUpdate", 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("JobId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationClientId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "OccurredAt"); + + b.ToTable("JobUpdates", (string)null); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", 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("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("JobUpdateId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobUpdateId"); + + b.ToTable("JobUpdateAttachments", (string)null); + }); + + 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("IndustryKey") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsSquareConnected") + .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("SquareMerchantId") + .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.OrganizationBranding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BusinessName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("FooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LogoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PrimaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("SecondaryColor") + .HasColumnType("nvarchar(max)"); + + b.Property("Tagline") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationBranding", (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("DepositPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("DepositRequired") + .HasColumnType("bit"); + + 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.SupportHubInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("RedeemedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedByUid") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("SupportHubInvites"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("EndedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubSessions"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeactivatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LastActivityAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SupportHubTickets"); + }); + + 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("PreferredLanguage") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + 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.ClientImportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportJobError", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportJob", "ClientImportJob") + .WithMany("Errors") + .HasForeignKey("ClientImportJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ClientImportJob"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadRow", b => + { + b.HasOne("JobFlow.Domain.Models.ClientImportUploadSession", "Session") + .WithMany("Rows") + .HasForeignKey("ClientImportUploadSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.DataExportJob", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.EmployeeRolePreset", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.EmployeeRolePresetItem", b => + { + b.HasOne("JobFlow.Domain.Models.EmployeeRolePreset", "Preset") + .WithMany("Items") + .HasForeignKey("PresetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Preset"); + }); + + 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.FollowUpExecutionLog", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpRun", "Run") + .WithMany("ExecutionLogs") + .HasForeignKey("FollowUpRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpRun", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany() + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpStep", b => + { + b.HasOne("JobFlow.Domain.Models.FollowUpSequence", "Sequence") + .WithMany("Steps") + .HasForeignKey("FollowUpSequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sequence"); + }); + + 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.Estimate", "Estimate") + .WithMany() + .HasForeignKey("EstimateId"); + + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") + .WithMany("Jobs") + .HasForeignKey("OrganizationClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Estimate"); + + 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.JobUpdate", b => + { + b.HasOne("JobFlow.Domain.Models.Job", "Job") + .WithMany("JobUpdates") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdateAttachment", b => + { + b.HasOne("JobFlow.Domain.Models.JobUpdate", "JobUpdate") + .WithMany("Attachments") + .HasForeignKey("JobUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobUpdate"); + }); + + 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.OrganizationBranding", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + 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.SupportHubSession", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.SupportHubTicket", b => + { + b.HasOne("JobFlow.Domain.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.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.ClientImportJob", b => + { + b.Navigation("Errors"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.ClientImportUploadSession", b => + { + b.Navigation("Rows"); + }); + + 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.EmployeeRolePreset", b => + { + b.Navigation("Items"); + }); + + 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.FollowUpRun", b => + { + b.Navigation("ExecutionLogs"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.FollowUpSequence", b => + { + b.Navigation("Steps"); + }); + + 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"); + + b.Navigation("JobUpdates"); + }); + + modelBuilder.Entity("JobFlow.Domain.Models.JobUpdate", b => + { + b.Navigation("Attachments"); + }); + + 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/20260327210319_AddEstimateIdAndDepositSettings.cs b/JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.cs new file mode 100644 index 0000000..b02e93d --- /dev/null +++ b/JobFlow.Infrastructure.Persistence/Migrations/20260327210319_AddEstimateIdAndDepositSettings.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobFlow.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEstimateIdAndDepositSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DepositPercentage", + table: "OrganizationInvoicingSettings", + type: "decimal(5,2)", + precision: 5, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "DepositRequired", + table: "OrganizationInvoicingSettings", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EstimateId", + table: "Job", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimateId", + table: "Invoice", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Job_EstimateId", + table: "Job", + column: "EstimateId"); + + migrationBuilder.AddForeignKey( + name: "FK_Job_Estimates_EstimateId", + table: "Job", + column: "EstimateId", + principalTable: "Estimates", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Job_Estimates_EstimateId", + table: "Job"); + + migrationBuilder.DropIndex( + name: "IX_Job_EstimateId", + table: "Job"); + + migrationBuilder.DropColumn( + name: "DepositPercentage", + table: "OrganizationInvoicingSettings"); + + migrationBuilder.DropColumn( + name: "DepositRequired", + table: "OrganizationInvoicingSettings"); + + migrationBuilder.DropColumn( + name: "EstimateId", + table: "Job"); + + migrationBuilder.DropColumn( + name: "EstimateId", + table: "Invoice"); + } + } +} diff --git a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs index 3d36dea..3d25525 100644 --- a/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs +++ b/JobFlow.Infrastructure.Persistence/Migrations/JobFlowDbContextModelSnapshot.cs @@ -1654,6 +1654,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DueDate") .HasColumnType("datetime2"); + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + b.Property("ExternalPaymentId") .HasColumnType("nvarchar(max)"); @@ -1812,6 +1815,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeactivatedAtUtc") .HasColumnType("datetime2"); + b.Property("EstimateId") + .HasColumnType("uniqueidentifier"); + b.Property("InvoicingWorkflow") .HasColumnType("int"); @@ -1842,6 +1848,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("EstimateId"); + b.HasIndex("OrganizationClientId"); b.ToTable("Job", (string)null); @@ -2465,6 +2473,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("int") .HasDefaultValue(0); + b.Property("DepositPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("DepositRequired") + .HasColumnType("bit"); + b.Property("IsActive") .HasColumnType("bit"); @@ -3561,12 +3576,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("JobFlow.Domain.Models.Job", b => { + b.HasOne("JobFlow.Domain.Models.Estimate", "Estimate") + .WithMany() + .HasForeignKey("EstimateId"); + b.HasOne("JobFlow.Domain.Models.OrganizationClient", "OrganizationClient") .WithMany("Jobs") .HasForeignKey("OrganizationClientId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("Estimate"); + b.Navigation("OrganizationClient"); }); diff --git a/JobFlow.Infrastructure.Persistence/Repository.cs b/JobFlow.Infrastructure.Persistence/Repository.cs index 27c77a5..d3738d4 100644 --- a/JobFlow.Infrastructure.Persistence/Repository.cs +++ b/JobFlow.Infrastructure.Persistence/Repository.cs @@ -18,7 +18,7 @@ public Repository(DbContext context) } // 🔹 Query - public IQueryable Query(Expression> filter = null) + public IQueryable Query(Expression>? filter = null) { return filter != null ? _dbSet.Where(filter) : _dbSet; } @@ -140,12 +140,12 @@ public Task RemoveRangeAsync(IEnumerable items) // 🔹 Read Helpers public async Task GetByIdAsync(Guid id) { - return await _dbSet.FindAsync(id); + return (await _dbSet.FindAsync(id))!; } public async Task FirstOrDefaultAsync(Expression> predicate) { - return await _dbSet.FirstOrDefaultAsync(predicate); + return (await _dbSet.FirstOrDefaultAsync(predicate))!; } public async Task ExistsAsync(Expression> predicate) From 11e6fb62a4b7ea84623a297abe0782f5f5602c53 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 11:02:34 -0400 Subject: [PATCH 23/26] fix(square): Fix Square flow. Both UI and API --- .../Controllers/OnboardingController.cs | 4 +- .../Controllers/OrganizationController.cs | 7 +- JobFlow.API/Controllers/PaymentController.cs | 137 ++++++++++++++++-- JobFlow.API/Mappings/MapsterConfig.cs | 4 +- JobFlow.API/Program.cs | 5 +- .../Models/DTOs/OrganizationDto.cs | 1 + .../Onboarding/OnboardingCatalog.cs | 10 +- .../Services/OrganizationClientService.cs | 6 - .../Services/PaymentProfileService.cs | 29 ++++ .../IPaymentProfileService.cs | 2 + .../Services/SubscriptionRecordService.cs | 16 ++ .../Square/SquarePaymentProcessor.cs | 8 + .../Stripe/StripePaymentProcessor.cs | 39 ++++- .../Stripe/StripeWebhookService.cs | 89 ++++++++++++ 14 files changed, 328 insertions(+), 29 deletions(-) diff --git a/JobFlow.API/Controllers/OnboardingController.cs b/JobFlow.API/Controllers/OnboardingController.cs index 64d9cce..818dda1 100644 --- a/JobFlow.API/Controllers/OnboardingController.cs +++ b/JobFlow.API/Controllers/OnboardingController.cs @@ -50,12 +50,12 @@ public async Task ApplyQuickStart([FromBody] OnboardingQuickStartApplyR return orgResult.ToProblemDetails(); } - if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow")) + if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Go")) { return Results.Problem( statusCode: StatusCodes.Status403Forbidden, title: "Subscription Required", - detail: "A Flow plan is required to apply quick-start presets."); + detail: "A Go plan is required to apply quick-start presets."); } var result = await onboarding.ApplyQuickStartAsync(organizationId, request); diff --git a/JobFlow.API/Controllers/OrganizationController.cs b/JobFlow.API/Controllers/OrganizationController.cs index 84c49a6..593f483 100644 --- a/JobFlow.API/Controllers/OrganizationController.cs +++ b/JobFlow.API/Controllers/OrganizationController.cs @@ -6,6 +6,7 @@ using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Enums; using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; @@ -14,7 +15,6 @@ namespace JobFlow.API.Controllers; [ApiController] public class OrganizationController : ControllerBase { - private readonly INotificationService _notificationService; private readonly IOrganizationBrandingService _organizationBrandingService; private readonly IOrganizationService _organizationService; private readonly IPaymentProfileService _paymentProfileService; @@ -24,14 +24,12 @@ public OrganizationController( IOrganizationService organizationService, IUserService userService, IPaymentProfileService paymentProfileService, - INotificationService notificationService, IOrganizationBrandingService organizationBrandingService ) { _organizationService = organizationService; _userService = userService; _paymentProfileService = paymentProfileService; - _notificationService = notificationService; _organizationBrandingService = organizationBrandingService; } @@ -45,10 +43,10 @@ public async Task GetAllOrganizations() [HttpPost] [Route("create")] + [AllowAnonymous] public async Task CreateOrganizationAccount(Organization model) { var result = await _organizationService.UpsertOrganization(model); - if (result.IsSuccess) await _notificationService.SendOrganizationWelcomeNotificationAsync(model); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } @@ -96,6 +94,7 @@ await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(model.FireBaseUid, [HttpPost] [Route("retrieve")] + [AllowAnonymous] public async Task GetOrganizationById([FromBody] OrganizationRequest org) { var result = await _organizationService.GetOrganizationDtoById(org.OrganizationId); diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index ad27684..235735d 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -3,6 +3,7 @@ using JobFlow.Business.PaymentGateways.SharedModels; using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Business.Onboarding; using JobFlow.Domain.Enums; using JobFlow.API.Extensions; using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authentication.JwtBearer; using System.Net.Http.Json; +using System.Security.Cryptography; using System.Text.Json; using Stripe; using CreateSubscriptionRequest = JobFlow.Business.Models.DTOs.CreateSubscriptionRequest; @@ -38,6 +40,7 @@ public class PaymentController : ControllerBase private readonly ISquareTokenEncryptionService _squareTokenEncryption; private readonly IHostEnvironment _hostEnvironment; private readonly IFrontendSettings _frontEndSettings; + private readonly IOnboardingService _onboardingService; public PaymentController( IPaymentProcessorFactory processorFactory, @@ -51,7 +54,8 @@ public PaymentController( ISquareSettings squareSettings, ISquareTokenEncryptionService squareTokenEncryption, IHostEnvironment hostEnvironment, - IFrontendSettings frontEndSettings) + IFrontendSettings frontEndSettings, + IOnboardingService onboardingService) { _processorFactory = processorFactory; _organizationService = organizationService; @@ -65,12 +69,27 @@ public PaymentController( _squareTokenEncryption = squareTokenEncryption; _hostEnvironment = hostEnvironment; _frontEndSettings = frontEndSettings; + _onboardingService = onboardingService; } [HttpPost("checkout")] + [AllowAnonymous] public async Task Checkout([FromBody] PaymentSessionRequest request) { - var orgId = HttpContext.GetOrganizationId(); + Guid orgId; + if (User?.Identity?.IsAuthenticated == true) + { + orgId = HttpContext.GetOrganizationId(); + } + else + { + var requestedOrgId = request.OrgId ?? request.OrganizationId; + if (!requestedOrgId.HasValue || requestedOrgId.Value == Guid.Empty) + return BadRequest("Organization id is required."); + + orgId = requestedOrgId.Value; + } + request.OrgId = orgId; var org = await _organizationService.GetOrganiztionById(orgId); @@ -117,9 +136,17 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque } } - var processor = provider == PaymentProvider.Square - ? await _processorFactory.GetProcessorForOrgAsync(orgId, provider) - : _processorFactory.GetProcessor(provider); + IPaymentProcessor processor; + try + { + processor = provider == PaymentProvider.Square + ? await GetSquareProcessorForCheckoutAsync(orgId, provider) + : _processorFactory.GetProcessor(provider); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } string checkoutUrl; if (request.Mode == "subscription") @@ -130,7 +157,15 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque { request.ConnectedAccountId = organization.StripeConnectAccountId; } - var paymentIntent = await processor.CreatePaymentIntentAsync(request); + PaymentSessionResult paymentIntent; + try + { + paymentIntent = await processor.CreatePaymentIntentAsync(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } return Ok(new { @@ -142,6 +177,18 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque return Ok(new { url = checkoutUrl }); } + private async Task GetSquareProcessorForCheckoutAsync(Guid orgId, PaymentProvider provider) + { + try + { + return await _processorFactory.GetProcessorForOrgAsync(orgId, provider); + } + catch (CryptographicException) + { + throw new InvalidOperationException("Your Square connection has expired. Disconnect and reconnect Square, then try checkout again."); + } + } + [HttpPost("create-connected-account")] public async Task CreateConnectedAccount( [FromQuery] PaymentProvider? provider = null) @@ -225,9 +272,35 @@ public async Task LinkConnectedAccount([FromBody] LinkConnectedAc return BadRequest(profileResult.Error); var orgUpdate = await _organizationService.UpsertOrganization(organization); + if (orgUpdate.IsSuccess) + await _onboardingService.MarkStepCompleteAsync(orgId, OnboardingStepKeys.ConnectStripe); + return orgUpdate.IsSuccess ? Ok(new { linked = true }) : BadRequest(orgUpdate.Error); } + [HttpDelete("square/disconnect")] + public async Task DisconnectSquare() + { + var orgId = HttpContext.GetOrganizationId(); + var orgResult = await _organizationService.GetOrganiztionById(orgId); + if (orgResult.IsFailure) + return NotFound(orgResult.Error); + + var organization = orgResult.Value; + organization.IsSquareConnected = false; + organization.SquareMerchantId = null; + + var updateResult = await _organizationService.UpsertOrganization(organization); + if (updateResult.IsFailure) + return BadRequest(updateResult.Error); + + var profileDisconnectResult = await _paymentProfileService.DisconnectOrganizationProviderAsync(orgId, PaymentProvider.Square); + if (profileDisconnectResult.IsFailure) + return BadRequest(profileDisconnectResult.Error); + + return Ok(new { disconnected = true }); + } + [HttpPost("refund")] public async Task RefundPayment([FromBody] PaymentRefundRequestDto request) { @@ -348,13 +421,24 @@ public async Task CreatePaymentProfile([FromBody] CreatePaymentPr // POST: api/payments/subscription [HttpPost("subscription")] + [AllowAnonymous] public async Task CreateSubscription([FromBody] CreateSubscriptionRequest request) { + if (request.PaymentProfileId == Guid.Empty) + return BadRequest("Payment profile is required."); + + if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId)) + return BadRequest("Provider subscription ID is required."); + + var normalizedStatus = NormalizeSubscriptionStatus(request.Status); + if (normalizedStatus is null) + return BadRequest("Invalid subscription status."); + var result = await _subscriptionRecordService.CreateAsync( request.PaymentProfileId, request.ProviderSubscriptionId, request.ProviderPriceId, - request.Status ?? "active", + normalizedStatus, "" ); @@ -363,16 +447,49 @@ public async Task CreateSubscription([FromBody] CreateSubscriptio // POST: api/payments/subscription/cancel [HttpPost("subscription/cancel")] + [AllowAnonymous] public async Task CancelSubscription([FromBody] CancelSubscriptionRequest request) { + if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId)) + return BadRequest("Provider subscription ID is required."); + + var subscriptionResult = await _subscriptionRecordService.GetByProviderIdAsync(request.ProviderSubscriptionId); + if (subscriptionResult.IsFailure) + return NotFound(subscriptionResult.Error); + + if (string.Equals(subscriptionResult.Value.Status, "canceled", StringComparison.OrdinalIgnoreCase)) + return Ok(); + + var canceledAt = request.CanceledAt == default ? DateTime.UtcNow : request.CanceledAt.ToUniversalTime(); + var result = await _subscriptionRecordService.CancelAsync( request.ProviderSubscriptionId, - request.CanceledAt + canceledAt ); return result.IsSuccess ? Ok() : BadRequest(result.Error); } + private static string? NormalizeSubscriptionStatus(string? status) + { + var normalized = string.IsNullOrWhiteSpace(status) + ? "active" + : status.Trim().ToLowerInvariant(); + + return normalized switch + { + "active" => "active", + "trialing" => "trialing", + "past_due" => "past_due", + "incomplete" => "incomplete", + "incomplete_expired" => "incomplete_expired", + "unpaid" => "unpaid", + "paused" => "paused", + "canceled" => "canceled", + _ => null + }; + } + // POST: api/payments/profile/default-method [HttpPost("profile/default-method")] public async Task SetDefaultPaymentMethod([FromBody] SetDefaultPaymentMethodRequest request) @@ -526,6 +643,8 @@ public async Task HandleSquareCallback( if (updateResult.IsFailure) return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString(updateResult.Error?.ToString() ?? "Unable to update organization payment provider.")}"); + await _onboardingService.MarkStepCompleteAsync(organizationId, OnboardingStepKeys.ConnectStripe); + return Redirect($"{uiBase}?provider=square&success=true&merchantId={Uri.EscapeDataString(merchantId)}"); } @@ -540,7 +659,7 @@ private string BuildSquareOnboardingUrl(string orgState) ? "https://connect.squareupsandbox.com" : "https://connect.squareup.com"; - return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}"; + return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+ORDERS_WRITE+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}"; } private string BuildSquareUiRedirectBaseUrl() diff --git a/JobFlow.API/Mappings/MapsterConfig.cs b/JobFlow.API/Mappings/MapsterConfig.cs index e78ef9d..4ef2fd6 100644 --- a/JobFlow.API/Mappings/MapsterConfig.cs +++ b/JobFlow.API/Mappings/MapsterConfig.cs @@ -27,7 +27,9 @@ public void Register(TypeAdapterConfig config) config.NewConfig(); // Organization → DTO - config.NewConfig(); + config.NewConfig() + .Map(dest => dest.Email, src => src.EmailAddress) + .Map(dest => dest.EmailAddress, src => src.EmailAddress); //OrganizationBranding → DTO config.NewConfig(); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 57f4aaf..4a6d3d7 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -389,7 +389,10 @@ app.UseSwaggerUI(); } -app.UseHttpsRedirection(); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} app.UseExceptionHandler(app.Environment.IsProduction() ? "/error" : "/error-development"); app.UseRouting(); diff --git a/JobFlow.Business/Models/DTOs/OrganizationDto.cs b/JobFlow.Business/Models/DTOs/OrganizationDto.cs index eec4b1b..64f8251 100644 --- a/JobFlow.Business/Models/DTOs/OrganizationDto.cs +++ b/JobFlow.Business/Models/DTOs/OrganizationDto.cs @@ -29,6 +29,7 @@ public class OrganizationDto public Guid? Id { get; set; } public string? OrganizationName { get; set; } public string? Email { get; set; } + public string? EmailAddress { get; set; } public string? Address1 { get; set; } public string? Address2 { get; set; } public string? City { get; set; } diff --git a/JobFlow.Business/Onboarding/OnboardingCatalog.cs b/JobFlow.Business/Onboarding/OnboardingCatalog.cs index 00b4cf5..ac18774 100644 --- a/JobFlow.Business/Onboarding/OnboardingCatalog.cs +++ b/JobFlow.Business/Onboarding/OnboardingCatalog.cs @@ -18,10 +18,10 @@ public static class OnboardingCatalog { OnboardingStepKeys.ConnectStripe, 10 }, { OnboardingStepKeys.CreateCustomer, 20 }, { OnboardingStepKeys.CreateJob, 30 }, - { OnboardingStepKeys.CreateInvoice, 40 }, - { OnboardingStepKeys.SendInvoice, 50 }, - { OnboardingStepKeys.ReceivePayment, 60 }, - { OnboardingStepKeys.ScheduleJob, 70 } + { OnboardingStepKeys.ScheduleJob, 40 }, + { OnboardingStepKeys.CreateInvoice, 50 }, + { OnboardingStepKeys.SendInvoice, 60 }, + { OnboardingStepKeys.ReceivePayment, 70 } }; private static readonly Dictionary OrganizedFirstOrder = new() @@ -45,7 +45,7 @@ public static class OnboardingCatalog new(OnboardingStepKeys.CreateJob, "Create your first job", 20, _ => true), new(OnboardingStepKeys.ScheduleJob, "Schedule the job", 30, _ => true), new(OnboardingStepKeys.CreateInvoice, "Create an invoice", 40, _ => true), - new(OnboardingStepKeys.ConnectStripe, "Create Stripe connected account", 50, _ => true), + new(OnboardingStepKeys.ConnectStripe, "Connect your payment account", 50, _ => true), new(OnboardingStepKeys.SendInvoice, "Send the invoice", 60, _ => true), new( OnboardingStepKeys.ReceivePayment, diff --git a/JobFlow.Business/Services/OrganizationClientService.cs b/JobFlow.Business/Services/OrganizationClientService.cs index 56d0605..456a043 100644 --- a/JobFlow.Business/Services/OrganizationClientService.cs +++ b/JobFlow.Business/Services/OrganizationClientService.cs @@ -68,9 +68,6 @@ public async Task DeleteClient(Guid clientId, Guid organizationId) public async Task>> GetAllClients() { var clients = await organizationClient.Query().ToListAsync(); - if (!clients.Any()) - return Result.Failure>(OrganizationClientErrors.NoClientsToShow); - return Result.Success>(clients); } @@ -78,9 +75,6 @@ public async Task>> GetAllClientsByOrgani { var clients = await organizationClient.Query().Where(client => client.OrganizationId == organizationId) .ToListAsync(); - if (!clients.Any()) - return Result.Failure>(OrganizationClientErrors.NoClientFound); - return Result.Success>(clients); } diff --git a/JobFlow.Business/Services/PaymentProfileService.cs b/JobFlow.Business/Services/PaymentProfileService.cs index cf240d3..c2a2bfb 100644 --- a/JobFlow.Business/Services/PaymentProfileService.cs +++ b/JobFlow.Business/Services/PaymentProfileService.cs @@ -185,4 +185,33 @@ public async Task UpdateTokensAsync(Guid profileId, string encryptedAcce return Result.Success(); } + + public async Task DisconnectOrganizationProviderAsync(Guid organizationId, PaymentProvider provider) + { + if (organizationId == Guid.Empty) + return Result.Failure(PaymentProfileErrors.NullOrEmptyOwnerId); + + var profiles = await paymentProfiles.Query() + .Where(p => p.OwnerId == organizationId + && p.OwnerType == PaymentEntityType.Organization + && p.Provider == provider) + .ToListAsync(); + + if (profiles.Count == 0) + return Result.Success(); + + foreach (var profile in profiles) + { + profile.ProviderCustomerId = string.Empty; + profile.DefaultPaymentMethodId = null; + profile.EncryptedAccessToken = null; + profile.EncryptedRefreshToken = null; + profile.TokenExpiresAtUtc = null; + profile.SquareLocationId = null; + profile.UpdatedAt = DateTime.UtcNow; + } + + await unitOfWork.SaveChangesAsync(); + return Result.Success(); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs index e1e9ff6..61f72f1 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs @@ -24,4 +24,6 @@ Task> UpsertWithTokensAsync( Task UpdateTokensAsync(Guid profileId, string encryptedAccessToken, string encryptedRefreshToken, DateTime tokenExpiresAtUtc); + + Task DisconnectOrganizationProviderAsync(Guid organizationId, PaymentProvider provider); } \ No newline at end of file diff --git a/JobFlow.Business/Services/SubscriptionRecordService.cs b/JobFlow.Business/Services/SubscriptionRecordService.cs index 266637a..c23a831 100644 --- a/JobFlow.Business/Services/SubscriptionRecordService.cs +++ b/JobFlow.Business/Services/SubscriptionRecordService.cs @@ -32,10 +32,26 @@ public async Task> CreateAsync(Guid paymentProfileId, if (string.IsNullOrWhiteSpace(providerSubscriptionId)) return Result.Failure(SubscriptionErrors.MissingProviderSubscriptionId); + + providerSubscriptionId = providerSubscriptionId.Trim(); + var paymentProfile = await paymentProfiles.Query().FirstOrDefaultAsync(p => p.Id == paymentProfileId); if (paymentProfile == null) return Result.Failure(SubscriptionErrors.InvalidPaymentProfile); + var existing = await subscriptions.Query() + .FirstOrDefaultAsync(s => s.ProviderSubscriptionId == providerSubscriptionId); + + if (existing is not null) + { + if (existing.PaymentProfileId != paymentProfileId) + return Result.Failure(Error.Conflict( + "Subscription", + "Provider subscription ID is already linked to a different payment profile.")); + + return Result.Success(existing); + } + var subscription = new SubscriptionRecord { Id = Guid.NewGuid(), diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index 19ea474..5265d12 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -108,6 +108,14 @@ public async Task CreateCheckoutSessionAsync(PaymentSessionRequest reque } catch (SquareApiException ex) { + if (ex.Message.Contains("INSUFFICIENT_SCOPES", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("ORDERS_WRITE", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Square account needs updated permissions (ORDERS_WRITE). Please reconnect Square from Connected Payment to re-authorize the required scopes.", + ex); + } + throw new Exception($"Square API error: {ex.Message}", ex); } catch (Exception ex) when (ex is not InvalidOperationException) diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 065647d..648a061 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -200,7 +200,10 @@ public async Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionR Quantity = request.Quantity } }, - SuccessUrl = $"{request.SuccessUrl}?organizationId={request.OrgId}&session_id={{CHECKOUT_SESSION_ID}}", + SuccessUrl = BuildSubscriptionSuccessUrl( + request.SuccessUrl, + ownerId, + "{CHECKOUT_SESSION_ID}"), CancelUrl = request.CancelUrl, Customer = customerId, SubscriptionData = new SessionSubscriptionDataOptions @@ -239,4 +242,38 @@ public async Task CreateStripeCustomerAsync(string email) return customer.Id; } + + private static string BuildSubscriptionSuccessUrl( + string? baseUrl, + string organizationId, + string sessionIdPlaceholder) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new InvalidOperationException("Success URL is required for subscription checkout."); + + var urlWithoutFragment = baseUrl; + var fragment = string.Empty; + var fragmentIndex = baseUrl.IndexOf('#'); + if (fragmentIndex >= 0) + { + urlWithoutFragment = baseUrl[..fragmentIndex]; + fragment = baseUrl[fragmentIndex..]; + } + + var queryIndex = urlWithoutFragment.IndexOf('?'); + var path = queryIndex >= 0 ? urlWithoutFragment[..queryIndex] : urlWithoutFragment; + var query = queryIndex >= 0 ? urlWithoutFragment[(queryIndex + 1)..] : string.Empty; + + var parts = query + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Where(p => + !p.StartsWith("organizationId=", StringComparison.OrdinalIgnoreCase) + && !p.StartsWith("session_id=", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + parts.Add($"organizationId={Uri.EscapeDataString(organizationId)}"); + parts.Add($"session_id={sessionIdPlaceholder}"); + + return $"{path}?{string.Join("&", parts)}{fragment}"; + } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index bbd1704..7bfe11f 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -248,6 +248,8 @@ private async Task HandleCheckoutSessionAsync(Session session) planName = "Unknown"; } + var existingSubscription = await _subscriptionRecordService.GetByProviderIdAsync(subscription.Id); + var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), Enum.Parse(ownerType), @@ -262,6 +264,11 @@ await _subscriptionRecordService.CreateAsync( subscription.Status, planName ); + + if (existingSubscription.IsFailure) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.CheckoutSessionCompleted); + } } private async Task HandlePaymentIntentAsync(PaymentIntent intent) @@ -402,10 +409,19 @@ private async Task HandleSubscriptionUpdatedAsync(Subscription subscription) if (!subResult.IsSuccess) return; + var previousStatus = subResult.Value.Status; + subResult.Value.Status = subscription.Status; subResult.Value.ProviderPriceId = subscription.Items.Data.First().Price.Id; await _subscriptionRecordService.UpdateAsync(subResult.Value); + + if (!IsSubscriptionCompleteStatus(previousStatus) + && IsSubscriptionCompleteStatus(subscription.Status) + && TryGetSubscriptionOwnerMetadata(subscription, out var ownerId, out var ownerType)) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.CustomerSubscriptionUpdated); + } } private async Task HandleSubscriptionCreatedAsync(Subscription subscription) @@ -434,6 +450,8 @@ private async Task HandleSubscriptionCreatedAsync(Subscription subscription) planName = "Unknown"; } + var existingSubscription = await _subscriptionRecordService.GetByProviderIdAsync(subscription.Id); + var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), Enum.Parse(ownerType), @@ -448,6 +466,77 @@ await _subscriptionRecordService.CreateAsync( subscription.Status, planName ); + + if (existingSubscription.IsFailure) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.SubscriptionCreated); + } + } + + private async Task TrySendOrganizationWelcomeAsync( + string ownerIdRaw, + string ownerTypeRaw, + string? subscriptionStatus, + string subscriptionId, + string sourceEvent) + { + if (!IsSubscriptionCompleteStatus(subscriptionStatus)) + return; + + if (!Enum.TryParse(ownerTypeRaw, true, out var ownerType) + || ownerType != PaymentEntityType.Organization) + return; + + if (!Guid.TryParse(ownerIdRaw, out var organizationId)) + { + _logger.LogWarning( + "Unable to parse organization owner id for welcome notification. SubscriptionId={SubscriptionId}, OwnerId={OwnerId}, EventType={EventType}", + subscriptionId, + ownerIdRaw, + sourceEvent); + return; + } + + var orgResult = await _organizationService.GetOrganiztionById(organizationId); + if (orgResult.IsFailure) + { + _logger.LogWarning( + "Organization not found for welcome notification. OrganizationId={OrganizationId}, SubscriptionId={SubscriptionId}, EventType={EventType}", + organizationId, + subscriptionId, + sourceEvent); + return; + } + + await _notificationService.SendOrganizationWelcomeNotificationAsync(orgResult.Value); + } + + private static bool IsSubscriptionCompleteStatus(string? status) + { + return string.Equals(status, "active", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "trialing", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetSubscriptionOwnerMetadata( + Subscription subscription, + out string ownerId, + out string ownerType) + { + ownerId = string.Empty; + ownerType = string.Empty; + + if (subscription.Metadata == null) + return false; + + if (!subscription.Metadata.TryGetValue("ownerId", out var ownerIdValue) + || !subscription.Metadata.TryGetValue("ownerType", out var ownerTypeValue)) + return false; + + ownerId = ownerIdValue ?? string.Empty; + ownerType = ownerTypeValue ?? string.Empty; + + return !string.IsNullOrWhiteSpace(ownerId) + && !string.IsNullOrWhiteSpace(ownerType); } private async Task HandleSubscriptionTrialWillEndAsync(Subscription subscription) From faec7585b3f9522b4366b0861e6a2efc9e70595c Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 12:29:04 -0400 Subject: [PATCH 24/26] feat(automation): Initial Automation Tests --- .github/workflows/ci.yml | 28 +++++ tests/e2e/.env.example | 8 ++ tests/e2e/README.md | 47 ++++++++ tests/e2e/fixtures.seed.example.json | 15 +++ tests/e2e/package-lock.json | 96 ++++++++++++++++ tests/e2e/package.json | 15 +++ tests/e2e/playwright.config.ts | 17 +++ tests/e2e/specs/api-smoke.spec.ts | 25 +++++ tests/e2e/specs/business-flow.spec.ts | 151 ++++++++++++++++++++++++++ tests/e2e/support/api-client.ts | 47 ++++++++ tests/e2e/support/config.ts | 29 +++++ tests/e2e/tsconfig.json | 12 ++ 12 files changed, 490 insertions(+) create mode 100644 tests/e2e/.env.example create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/fixtures.seed.example.json create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/specs/api-smoke.spec.ts create mode 100644 tests/e2e/specs/business-flow.spec.ts create mode 100644 tests/e2e/support/api-client.ts create mode 100644 tests/e2e/support/config.ts create mode 100644 tests/e2e/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62d4322..e838b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,31 @@ jobs: - name: Test run: dotnet test ./JobFlow.API/JobFlow.API.csproj -c Release --no-build + + api-e2e-playwright: + name: API E2E (Playwright) + runs-on: ubuntu-latest + if: ${{ secrets.JOBFLOW_API_BASE_URL != '' && secrets.JOBFLOW_API_BEARER_TOKEN != '' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: tests/e2e/package-lock.json + + - name: Install E2E dependencies + working-directory: ./tests/e2e + run: npm ci + + - name: Run API Playwright tests + working-directory: ./tests/e2e + env: + API_BASE_URL: ${{ secrets.JOBFLOW_API_BASE_URL }} + JOBFLOW_API_BEARER_TOKEN: ${{ secrets.JOBFLOW_API_BEARER_TOKEN }} + JOBFLOW_ORGANIZATION_ID: ${{ secrets.JOBFLOW_ORGANIZATION_ID }} + run: npm run test:e2e diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example new file mode 100644 index 0000000..0b362ff --- /dev/null +++ b/tests/e2e/.env.example @@ -0,0 +1,8 @@ +# API endpoint for Playwright API E2E tests +API_BASE_URL=https://localhost:5099 + +# Seeded account JWT containing organizationId claim (required for business-flow tests) +JOBFLOW_API_BEARER_TOKEN= + +# Optional explicit org id used for assertions and payload defaults +JOBFLOW_ORGANIZATION_ID= diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..a523d5c --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,47 @@ +# JobFlow API E2E (Playwright) + +This folder provides black-box API automation using Playwright's request client. + +## Why Playwright for API + +- Reuses one runner across UI and API automation. +- Fast HTTP smoke tests and contract checks. +- Good reporting and CI ergonomics. + +## Setup + +1. Start JobFlow API locally. +2. `cd tests/e2e` +3. `npm install` +4. Configure env (see `.env.example`): + - PowerShell: `$env:API_BASE_URL = "https://localhost:5099"` + - PowerShell: `$env:JOBFLOW_API_BEARER_TOKEN = ""` + - PowerShell: `$env:JOBFLOW_ORGANIZATION_ID = ""` +5. `npm run test:e2e` + +## Current coverage + +- Swagger UI availability. +- OpenAPI document availability. +- Unknown route behavior. +- End-to-end business lifecycle: + - Create client + - Create estimate + - Upsert job + - Create and fetch invoice + +## Seeded fixtures + +- `fixtures.seed.example.json` documents required seed assumptions. +- `JOBFLOW_API_BEARER_TOKEN` should be a token whose claims include `organizationId`. +- Business-flow tests auto-skip when token is not set. + +## Next workflow scenarios to automate + +1. Firebase login handshake (`/api/auth/login-with-firebase`) with a seeded test identity. +2. Onboarding checklist fetch for a test organization. +3. Client create/list/update lifecycle. +4. Estimate create -> revise -> accept path. +5. Job create/schedule/assignment path. +6. Invoice issue/payment record path. +7. Subscription-gated endpoint behavior by plan. diff --git a/tests/e2e/fixtures.seed.example.json b/tests/e2e/fixtures.seed.example.json new file mode 100644 index 0000000..cec89c4 --- /dev/null +++ b/tests/e2e/fixtures.seed.example.json @@ -0,0 +1,15 @@ +{ + "seededIdentity": { + "description": "User whose JWT includes organizationId claim", + "source": "Firebase login -> API login-with-firebase", + "requiredEnv": [ + "JOBFLOW_API_BEARER_TOKEN", + "JOBFLOW_ORGANIZATION_ID" + ] + }, + "sampleClient": { + "firstName": "E2E", + "lastName": "Client", + "emailDomain": "example.test" + } +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..1317cb4 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "jobflow-api-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jobflow-api-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/node": "^24.7.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..2385f66 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "jobflow-api-e2e", + "private": true, + "version": "1.0.0", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.1", + "@types/node": "^24.7.2" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..3c66199 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +const baseURL = process.env.API_BASE_URL ?? 'https://localhost:7090'; + +export default defineConfig({ + testDir: './specs', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: [['html', { open: 'never' }], ['list']], + use: { + baseURL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + Accept: 'application/json' + } + } +}); diff --git a/tests/e2e/specs/api-smoke.spec.ts b/tests/e2e/specs/api-smoke.spec.ts new file mode 100644 index 0000000..38aee57 --- /dev/null +++ b/tests/e2e/specs/api-smoke.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test'; + +test.describe('JobFlow API smoke', () => { + test('swagger ui is reachable', async ({ request }) => { + const response = await request.get('/swagger/index.html'); + expect(response.ok()).toBeTruthy(); + + const html = await response.text(); + expect(html.toLowerCase()).toContain('swagger'); + }); + + test('openapi document is served', async ({ request }) => { + const response = await request.get('/swagger/v1/swagger.json'); + expect(response.ok()).toBeTruthy(); + + const body = await response.json(); + expect(body).toHaveProperty('paths'); + expect(Object.keys(body.paths).length).toBeGreaterThan(0); + }); + + test('unknown route returns non-success status', async ({ request }) => { + const response = await request.get('/api/this-route-should-not-exist'); + expect(response.status()).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/tests/e2e/specs/business-flow.spec.ts b/tests/e2e/specs/business-flow.spec.ts new file mode 100644 index 0000000..b45d017 --- /dev/null +++ b/tests/e2e/specs/business-flow.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; +import { authJson, unwrapResult } from '../support/api-client'; +import { hasBearerToken, requireBearerToken } from '../support/config'; + +type OrganizationClientDto = { + id: string; + organizationId?: string; + firstName?: string; + lastName?: string; + emailAddress?: string; +}; + +type EstimateDto = { + id: string; + organizationClientId: string; + title?: string; +}; + +type JobDto = { + id: string; + title?: string; + organizationClientId: string; + estimateId?: string; +}; + +type InvoiceDto = { + id: string; + jobId: string; + organizationClientId: string; + invoiceNumber: string; +}; + +test.describe('Business flow API E2E', () => { + test.skip(!hasBearerToken(), 'Requires JOBFLOW_API_BEARER_TOKEN for authenticated flow.'); + + test('create client -> create estimate -> upsert job -> create invoice', async ({ request }) => { + const bearerToken = requireBearerToken(); + const stamp = Date.now(); + const clientEmail = `e2e-client-${stamp}@example.test`; + + const clientPayload = { + firstName: 'E2E', + lastName: `Client-${stamp}`, + emailAddress: clientEmail, + phoneNumber: '555-0100', + city: 'Austin', + state: 'TX', + zipCode: '73301' + }; + + const upsertClientRaw = await authJson( + request, + 'post', + '/api/organization/clients/upsert', + bearerToken, + clientPayload + ); + const createdClient = unwrapResult(upsertClientRaw); + + expect(createdClient.id).toBeTruthy(); + expect((createdClient.emailAddress ?? '').toLowerCase()).toBe(clientEmail.toLowerCase()); + + const estimatePayload = { + organizationClientId: createdClient.id, + title: `E2E Estimate ${stamp}`, + description: 'Automation estimate', + notes: 'Generated by Playwright business-flow suite', + lineItems: [ + { + name: 'Diagnostic Visit', + description: 'Initial site visit', + quantity: 1, + unitPrice: 95.0 + }, + { + name: 'Labor', + description: 'Repair work', + quantity: 2, + unitPrice: 120.0 + } + ] + }; + + const createdEstimate = await authJson( + request, + 'post', + '/api/estimates', + bearerToken, + estimatePayload + ); + + expect(createdEstimate.id).toBeTruthy(); + expect(createdEstimate.organizationClientId).toBe(createdClient.id); + + const jobPayload = { + title: `E2E Job ${stamp}`, + comments: 'Created from e2e estimate', + lifecycleStatus: 0, + organizationClientId: createdClient.id, + estimateId: createdEstimate.id + }; + + const upsertedJob = await authJson( + request, + 'post', + '/api/job/upsert', + bearerToken, + jobPayload + ); + + expect(upsertedJob.id).toBeTruthy(); + expect(upsertedJob.organizationClientId).toBe(createdClient.id); + + const invoicePayload = { + organizationId: process.env.JOBFLOW_ORGANIZATION_ID ?? '00000000-0000-0000-0000-000000000000', + jobId: upsertedJob.id, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + lineItems: [ + { + description: 'Labor & Materials', + quantity: 1, + unitPrice: 335, + lineTotal: 335 + } + ] + }; + + const createdInvoice = await authJson( + request, + 'post', + '/api/invoice/organization', + bearerToken, + invoicePayload + ); + + expect(createdInvoice.id).toBeTruthy(); + expect(createdInvoice.jobId).toBe(upsertedJob.id); + expect(createdInvoice.organizationClientId).toBe(createdClient.id); + expect(createdInvoice.invoiceNumber).toBeTruthy(); + + const fetchedInvoice = await authJson( + request, + 'get', + `/api/invoice/${createdInvoice.id}`, + bearerToken + ); + + expect(fetchedInvoice.id).toBe(createdInvoice.id); + expect(fetchedInvoice.jobId).toBe(upsertedJob.id); + }); +}); diff --git a/tests/e2e/support/api-client.ts b/tests/e2e/support/api-client.ts new file mode 100644 index 0000000..0234fe2 --- /dev/null +++ b/tests/e2e/support/api-client.ts @@ -0,0 +1,47 @@ +import { APIRequestContext } from '@playwright/test'; + +type HttpMethod = 'get' | 'post' | 'put' | 'delete'; + +export async function authJson( + request: APIRequestContext, + method: HttpMethod, + url: string, + bearerToken: string, + data?: unknown +): Promise { + const response = await request.fetch(url, { + method, + headers: { + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json' + }, + data + }); + + const text = await response.text(); + let body: unknown = {}; + + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + + if (!response.ok()) { + throw new Error( + `Request failed: ${method.toUpperCase()} ${url} -> ${response.status()} ${response.statusText()} | ${JSON.stringify(body)}` + ); + } + + return body as T; +} + +export function unwrapResult(payload: unknown): T { + if (payload && typeof payload === 'object' && 'value' in payload) { + return (payload as { value: T }).value; + } + + return payload as T; +} diff --git a/tests/e2e/support/config.ts b/tests/e2e/support/config.ts new file mode 100644 index 0000000..9c7999b --- /dev/null +++ b/tests/e2e/support/config.ts @@ -0,0 +1,29 @@ +export type E2EConfig = { + apiBaseUrl: string; + bearerToken?: string; + organizationId?: string; +}; + +export function readConfig(): E2EConfig { + return { + apiBaseUrl: process.env.API_BASE_URL ?? 'https://localhost:7090', + bearerToken: process.env.JOBFLOW_API_BEARER_TOKEN, + organizationId: process.env.JOBFLOW_ORGANIZATION_ID + }; +} + +export function requireBearerToken(): string { + const token = process.env.JOBFLOW_API_BEARER_TOKEN; + if (!token) { + throw new Error( + 'JOBFLOW_API_BEARER_TOKEN is required for authenticated business-flow tests. ' + + 'Set it from a seeded account login token in local env or GitHub Actions secrets.' + ); + } + + return token; +} + +export function hasBearerToken(): boolean { + return Boolean(process.env.JOBFLOW_API_BEARER_TOKEN); +} diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..be88524 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "types": ["node", "@playwright/test"], + "skipLibCheck": true + }, + "include": ["specs/**/*.ts", "support/**/*.ts", "playwright.config.ts"] +} From 6b493db509ca9da4b867b706f9fed02df198e59f Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 19:39:25 -0400 Subject: [PATCH 25/26] feat(staging): Environment Setup --- .github/workflows/ci.yml | 1 - .github/workflows/master_jobflow-api.yml | 3 - .github/workflows/staging_jobflow-api.yml | 95 +++++++++++++++++++++ JobFlow.API/Program.cs | 37 +++++--- JobFlow.API/appsettings.Staging.json | 16 ++++ JobFlow.API/job-flow-firebase-adminsdk.json | 12 +-- 6 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/staging_jobflow-api.yml create mode 100644 JobFlow.API/appsettings.Staging.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e838b3c..71fa75c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: pull_request: push: branches: - - main - master - dev - feature/** diff --git a/.github/workflows/master_jobflow-api.yml b/.github/workflows/master_jobflow-api.yml index 65aa19c..1270b03 100644 --- a/.github/workflows/master_jobflow-api.yml +++ b/.github/workflows/master_jobflow-api.yml @@ -4,9 +4,6 @@ name: Build and deploy ASP.Net Core app to Azure Web App - jobflow-api on: - push: - branches: - - master workflow_dispatch: jobs: diff --git a/.github/workflows/staging_jobflow-api.yml b/.github/workflows/staging_jobflow-api.yml new file mode 100644 index 0000000..95efaa8 --- /dev/null +++ b/.github/workflows/staging_jobflow-api.yml @@ -0,0 +1,95 @@ +name: Build and deploy ASP.Net Core app to Azure Web App - jobflow-api-staging + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build with dotnet + run: dotnet build JobFlow.API/JobFlow.API.csproj --configuration Release + + - name: dotnet publish + run: dotnet publish JobFlow.API/JobFlow.API.csproj --configuration Release --output ${{env.DOTNET_ROOT}}/myapp + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: .net-app + path: ${{env.DOTNET_ROOT}}/myapp + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: 'Staging' + url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + permissions: + id-token: write + contents: read + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: .net-app + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_15A959992577476DBE8A0461C48B98E2 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_0D7B12FDA6784264AD6F6B11A9FD4D54 }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_9763D46E278F4180A2B5D8CC4B8B9E88 }} + + - name: Deploy to Azure Web App + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'jobflow-api-staging' + slot-name: 'Production' + package: . + + deploy-prod: + runs-on: ubuntu-latest + needs: deploy + environment: + name: 'Production' + url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} + permissions: + id-token: write + contents: read + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: .net-app + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_15A959992577476DBE8A0461C48B98E2 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_0D7B12FDA6784264AD6F6B11A9FD4D54 }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_9763D46E278F4180A2B5D8CC4B8B9E88 }} + + - name: Deploy to Azure Web App (prod) + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'jobflow-api' + slot-name: 'Production' + package: . diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 4a6d3d7..b0fb398 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -79,27 +79,42 @@ // FIREBASE INITIALIZATION // ============================================================ -var firebaseFilePath = Path.Combine(env.ContentRootPath, "job-flow-firebase-adminsdk.json"); - -if (!System.IO.File.Exists(firebaseFilePath)) - throw new InvalidOperationException($"Firebase service account file not found: {firebaseFilePath}"); - +var firebaseAdminSdkJson = builder.Configuration[ConfigConstants.FIREBASE_ADMIN_SDK]; string firebaseProjectId; -using (var doc = JsonDocument.Parse(System.IO.File.ReadAllText(firebaseFilePath))) +GoogleCredential firebaseCredential; + +if (!string.IsNullOrWhiteSpace(firebaseAdminSdkJson)) { + using var doc = JsonDocument.Parse(firebaseAdminSdkJson); firebaseProjectId = doc.RootElement.GetProperty("project_id").GetString() ?? ""; + var credential = CredentialFactory.FromJson(firebaseAdminSdkJson); + firebaseCredential = credential.ToGoogleCredential(); +} +else +{ + var firebaseFilePath = Path.Combine(env.ContentRootPath, "job-flow-firebase-adminsdk.json"); + + if (!System.IO.File.Exists(firebaseFilePath)) + throw new InvalidOperationException( + $"Firebase admin credentials were not found. Configure '{ConfigConstants.FIREBASE_ADMIN_SDK}' in Key Vault or provide local file: {firebaseFilePath}"); + + var firebaseJson = System.IO.File.ReadAllText(firebaseFilePath); + + using var doc = JsonDocument.Parse(firebaseJson); + firebaseProjectId = doc.RootElement.GetProperty("project_id").GetString() ?? ""; + var credential = CredentialFactory.FromFile(firebaseFilePath); + firebaseCredential = credential.ToGoogleCredential(); } if (string.IsNullOrWhiteSpace(firebaseProjectId)) - throw new InvalidOperationException("Firebase project_id is missing in job-flow-firebase-adminsdk.json"); + throw new InvalidOperationException("Firebase project_id is missing in configured Firebase admin credentials."); // Create the Firebase Admin default app instance so FirebaseAuth.DefaultInstance is available. if (FirebaseApp.DefaultInstance is null) { - var credential = CredentialFactory.FromFile(firebaseFilePath); FirebaseApp.Create(new AppOptions { - Credential = credential.ToGoogleCredential() + Credential = firebaseCredential }); } @@ -119,7 +134,7 @@ }) .AddJwtBearer("ClientPortalJwt", options => { - var signingKey = builder.Configuration["Auth:ClientPortal:SigningKey"]; + var signingKey = builder.Configuration["Auth-ClientPortal-SigningKey"]; if (string.IsNullOrWhiteSpace(signingKey)) throw new InvalidOperationException("Missing configuration: Auth:ClientPortal:SigningKey"); @@ -259,6 +274,8 @@ return host == "localhost" || host == "gojobflow.com" || host == "www.gojobflow.com" + || host == "jobflow-ui-web-staging.web.app" + || host == "jobflow-ui-web-staging.firebaseapp.com" || host.EndsWith(".gojobflow.app") || host.EndsWith(".gojobflow.com"); }) diff --git a/JobFlow.API/appsettings.Staging.json b/JobFlow.API/appsettings.Staging.json new file mode 100644 index 0000000..69d6d4b --- /dev/null +++ b/JobFlow.API/appsettings.Staging.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "KeyVaultUri": "https://jobflow-staging.vault.azure.net/", + "Frontend": { + "BaseUrl": "https://staging.gojobflow.com" + }, + "Backend": { + "BaseUrl": "https://api.staging.gojobflow.com" + } +} \ No newline at end of file diff --git a/JobFlow.API/job-flow-firebase-adminsdk.json b/JobFlow.API/job-flow-firebase-adminsdk.json index 977d90a..7810b0d 100644 --- a/JobFlow.API/job-flow-firebase-adminsdk.json +++ b/JobFlow.API/job-flow-firebase-adminsdk.json @@ -1,13 +1,13 @@ { "type": "service_account", - "project_id": "jobflow-ui-web", - "private_key_id": "e9c3df7ce12c0500cc61cc285934a73ed8565480", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC66EWCPmbUwl25\nzROu+2tcDnbQlzMJ386pcx+YRGee5w/Q1al1zEls4/f8C7gOkTG4Ki9P9GCBOWGZ\nI41l+J3KiMsFVPb8amRtjyyLHjwD4tcbyl3DE5RGD2fS3LMNeWMxtwehKtV+6asc\nBEtMeswbrBC3vXQCe2AjDCYYsyxzOU+Qp2JQT2GNr3brZaow1nPqu6IAz7f92DBC\n44ezV3IJC0vYhIQq4bBngB97cC2hYI+f7ll3blYljccsDKQXxaNbQUJ1K+QJIlhg\nSSJ11SKcMk7a2l7KVknUligLrdllS/m652vV609nITmxlAGKUmjgle8YNLs0srFW\nSbjLZk53AgMBAAECggEAQPxScqMEuPPth5UUw3HaVbMXv5XaopPE9Ki48wXRq2+2\nUYuAdJs3altnFSTz9WipS1mrgpa62SNc2lSArNRA9LMUN8HfcEsDqQ4vVB2Ki2Vb\nGmgFqraLhsKDfE7NGKG8igQT7IcKnSpcmoypq6lEf1iXpXMDO3uvJPBr7ImbqmHK\nOJi+xO7z85fT//7gRh5I0I1RROJeH8go6OssnIVrgxSs8EI7Ai7HGMv1yxPlmGv1\n5naVvHpFRq18KrzU8LmirIwMByQC9IKJKPZtMmgNdPYc6YYKgfYIHhSXmJiBNPq3\nQQU/0bc3MTzUDNnnbIPwtj3San5jhRoiNVVX/rYEFQKBgQDc0wOaFJsoc82yn9RH\nwjNNHjyXllf6h4iNgcAYT8K7NVLg0KDppI64dFMw0ZKLd506QLevNFG/Kjcf9JgQ\ndQ2IkpGb4lK3vh8+lSdVK8Ngne35g6mPCTgNYUDbApb7KDf2rnwLcRh4WQTHP7w5\nljAXuzIN2wAyI+4onz84R8HJlQKBgQDYriig7qGdMNNfwfSHJEYmukWk/f61VQfX\n/rK48MXfDv4fq0YUHQvBDmQYAFN3hhbfCwa8Sw33Bbu49Zjydi8R+r7eBT7Vvv/e\nbeID1fYZdJQ5kotS3/IcYqck1ecd/X2SG9sc/0RVkeII1KcdYiVeyeCZr+zXQ8+a\nNTTUrERs2wKBgQDECCNjbjWLRLpvfwmRJmoqZNQ/cbzqb9UeYffo3S2uyZiocSzY\nHTiBsOqFJRal7urJ4tftllGXld9X4+f2fCMmgY73xoPOD95mzTwclPwd0jWHUoV8\nsB9taU+M3RCxJ7P+rkj6U0z40XW3d/IdYSGSf6DgwfC7kkADGdOin7j9vQKBgGR5\nFWPSY2RVQJ5VfIKxwkmw9BxWnqYMwK9abhstokMVW6bpr3wiH9IsTyOF+y4gIjjY\njw3+q4IQyYQxdfNv89GdeKXQvts0TscgIr5ul0gkc5ripfIO3+Bjqmd9PEb+xRxc\nCFVA1LntBGfd24PXf8adS6VYGzWSPxCdfVrkanIjAoGAHhrAzZqNJSQBISFWhk3G\n97EV8+AUSelIObsXQhQoEtlpEggffdKx0nC+i1nggkiB/WMa8ZxTCNB+ri3IfPeV\nrpjnDROjcWyZlHTp/4LPEDVIh4QvZWdial+A/v09/xErhDILyAlIrWEVCX9IcJm2\nJJ5pfEGgcIVSEGMV4cKPxAc=\n-----END PRIVATE KEY-----\n", - "client_email": "firebase-adminsdk-fbsvc@jobflow-ui-web.iam.gserviceaccount.com", - "client_id": "115912080472474571179", + "project_id": "jobflow-ui-web-staging", + "private_key_id": "9708ddf5828958edeb9a3fe8b2d2bb2c8be9933f", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJlI2Plx+znQi7\nrsq0ZOZeoLTqo5pbczPj42DwLf+YNEXvyRVrVruyqXhffKUxDGHmR3zxslUsT1f/\nKjWh09y2fnRXH1su2f90Kax4Lff1chhqMH6/J/CNK6KulBnjCcWks1F5GBCwsEw8\nCA44cJmVYnGAyqrQvNkELBYgWiIblE8kWFlPviZyyB9krEEJo7U8T9cA/sc3NI1j\nRKfx7S97koNgIsMht+TY92hDwClGVGWL7v1cgkeKmll6NhEEJpUr8amtrTplSjJB\nykq1FjmPmv6cao2JOGPNQRv2rt0xHKNCNQjPMgQetlX3HGauNDQk+PqPLxeq1b4S\npMqPMFPrAgMBAAECggEAE2uU5yBbmlhxfBy/BE8eOeqd/hGsms0yqBdYAlD5frE2\niv836Y3tv6zVCc9T3jGqXYmbRMZ2BIf7sHu2trRXCgaNGyGhuBX3fDpRlohzNY8m\n7AASY0SBR9B24qkmWfmvNAq0jt0zKm/pqvTpuHqcnp2L1X5w+Mg2LiaN1n291c3c\nTcak9qN1Fnfw/uhVuRr4Dws2+xOEVTwLEfJpzPa/hJnGhNUAdCR61GRgG/PQ1Xqw\n1BnP8w8dRjah5aeefUziUyiao/g3mXh2XtyXuA5OBEfGAFf01yWBkbB2Z6CEuP+U\nPtLcVSnmhQW0+MCeFovltokkjShpdis7KdjmBHg8EQKBgQDy0TRPM/6QTh7B9dOg\nRUNh6zpnOFC0Or0NXbFPJObzqnljeKjS1MPFJ5HtzEDerxfsyy8HVux91k8BkdrG\npFSrCtRyr2VC1cjK21sA2S8uAvQ+dVZIDY8ozhpKGuZMwiK03BF/n13Wk+MMRuAq\n9quOpHj6hKkCOQh3IRkOT/xP+wKBgQDUhjeZlQLGf0fMq4XzGs45ppIGw9dyu1yj\nvIF7rpzqnAgtqI4VzLkhcskEvDm+WdCgnjSvEKGbZq4Dm5v9Obm5T3RGZdIhkLSx\ns2taL6tbznq4SDGBGgMzJl8rAHXX2P+SHRyCIzrYnz6ZfAB2KffoTBmsHMjPikbq\nMsnWPEKY0QKBgAtjqLJ2W+Bk6ahrYWvJE+oJ4Ilq6M4rWya/WEvADV0sh9kUlcad\n2DjtLDkdNYW8bMDcnu4XM6yLWtVWBA8BMj97mI9wjq1d3bc2JsSZa08bMF2ln1Bt\n4mMll7IWJOtAx+P31pJH5VzlPucag/U/8LgWGt6VTmAeULlVwhkbw1f1AoGBALUS\nfgDO4wR4oZYSdhhBOIAKGdTFu6U3WaDwFWppxaxmsNkmCZktSnbjM75jGNfD8mtH\nICAgjXC4NX9Bb9B7BHCM78ajLjwG7M2Szt6SSu/3pruoVvVmUl+cS+15gO4dJvM4\n9ncyyQqT82QWMNZ8v4oefKkWBUo+yFj2WN29jghhAoGAVKAE8JxD20qiAbWk1RKm\n6T2TyDiZU/nG4MGPkRuR0OmtEszoLkk5o968IVEdK4v2y9XLCV+ke2PTgfVNEcKg\nLRBFitx6yVlzUddDTkdSnyYtl6S4LRX1XKk2Rur8fCgHd59bEb1CLxiFm9e7B5zW\ntHB937uJRztg2Dy8GnipZQw=\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@jobflow-ui-web-staging.iam.gserviceaccount.com", + "client_id": "115002767484169273887", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40jobflow-ui-web.iam.gserviceaccount.com", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40jobflow-ui-web-staging.iam.gserviceaccount.com", "universe_domain": "googleapis.com" } \ No newline at end of file From 608e78f08e1282d323ffd4754e9a43622663242c Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 20:20:51 -0400 Subject: [PATCH 26/26] feat(staging): Remove Playwright step from Actions --- .github/workflows/ci.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71fa75c..e09274a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,30 +80,3 @@ jobs: - name: Test run: dotnet test ./JobFlow.API/JobFlow.API.csproj -c Release --no-build - api-e2e-playwright: - name: API E2E (Playwright) - runs-on: ubuntu-latest - if: ${{ secrets.JOBFLOW_API_BASE_URL != '' && secrets.JOBFLOW_API_BEARER_TOKEN != '' }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: tests/e2e/package-lock.json - - - name: Install E2E dependencies - working-directory: ./tests/e2e - run: npm ci - - - name: Run API Playwright tests - working-directory: ./tests/e2e - env: - API_BASE_URL: ${{ secrets.JOBFLOW_API_BASE_URL }} - JOBFLOW_API_BEARER_TOKEN: ${{ secrets.JOBFLOW_API_BEARER_TOKEN }} - JOBFLOW_ORGANIZATION_ID: ${{ secrets.JOBFLOW_ORGANIZATION_ID }} - run: npm run test:e2e