Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions JobFlow.API/Controllers/ClientHubController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotifierHub> _hubContext;
private readonly IHubContext<ChatHub> _chatHubContext;
Expand All @@ -35,6 +37,8 @@ public ClientHubController(
IEstimateService estimates,
IEstimateRevisionService estimateRevisions,
IInvoiceService invoices,
IJobService jobs,
IJobUpdateService jobUpdates,
IOrganizationClientService clients,
IHubContext<NotifierHub> hubContext,
IHubContext<ChatHub> chatHubContext,
Expand All @@ -45,6 +49,8 @@ public ClientHubController(
_estimates = estimates;
_estimateRevisions = estimateRevisions;
_invoices = invoices;
_jobs = jobs;
_jobUpdates = jobUpdates;
_clients = clients;
_hubContext = hubContext;
_chatHubContext = chatHubContext;
Expand Down Expand Up @@ -412,6 +418,221 @@ public async Task<IResult> DownloadEstimateRevisionAttachment(Guid estimateId, G
return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
}

[HttpGet("jobs")]
public async Task<IResult> 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<IResult> 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<IResult> 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<JobTimelineItemDto>
{
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<IResult> 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<IResult> GetMyInvoices()
{
Expand Down
90 changes: 88 additions & 2 deletions JobFlow.API/Controllers/JobController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -128,5 +135,84 @@ public async Task<IActionResult> UpdateStatus(Guid jobId, [FromBody] UpdateJobSt
return Ok(result.Value);
}

[HttpGet("{jobId:guid}/updates")]
public async Task<IActionResult> 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<IActionResult> CreateJobUpdate(
Guid jobId,
[FromForm] CreateJobUpdateFormRequest request)
{
var organizationId = HttpContext.GetOrganizationId();
if (organizationId == Guid.Empty)
return Unauthorized("Organization context missing.");

var uploads = new List<JobUpdateAttachmentUpload>();
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<IActionResult> 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);
}


}

}
public record CreateJobUpdateFormRequest(
JobUpdateType Type,
string? Message,
JobLifecycleStatus? Status,
List<IFormFile>? Attachments);
11 changes: 11 additions & 0 deletions JobFlow.Business/ModelErrors/JobUpdateErrors.cs
Original file line number Diff line number Diff line change
@@ -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.");
}
Loading
Loading