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 =>