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
93 changes: 92 additions & 1 deletion JobFlow.API/Controllers/ClientHubController.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using JobFlow.API.Extensions;
using JobFlow.API.Hubs;
using JobFlow.Business.Extensions;
using JobFlow.Business.Models.DTOs;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;

namespace JobFlow.API.Controllers;

Expand All @@ -13,17 +16,23 @@ namespace JobFlow.API.Controllers;
public class ClientHubController : ControllerBase
{
private readonly IEstimateService _estimates;
private readonly IEstimateRevisionService _estimateRevisions;
private readonly IInvoiceService _invoices;
private readonly IOrganizationClientService _clients;
private readonly IHubContext<NotifierHub> _hubContext;

public ClientHubController(
IEstimateService estimates,
IEstimateRevisionService estimateRevisions,
IInvoiceService invoices,
IOrganizationClientService clients)
IOrganizationClientService clients,
IHubContext<NotifierHub> hubContext)
{
_estimates = estimates;
_estimateRevisions = estimateRevisions;
_invoices = invoices;
_clients = clients;
_hubContext = hubContext;
}

[HttpGet("me")]
Expand Down Expand Up @@ -121,6 +130,84 @@ public async Task<IResult> DeclineEstimate(Guid id)
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[HttpPost("estimates/{id:guid}/revision-requests")]
[RequestSizeLimit(55_000_000)]
public async Task<IResult> RequestEstimateRevision(Guid id, [FromForm] CreateEstimateRevisionFormRequest request)
{
var organizationId = HttpContext.GetOrganizationId();
var orgClientId = HttpContext.GetUserId();

var attachments = new List<EstimateRevisionAttachmentUpload>();

if (request.Attachments is not null)
{
foreach (var file in request.Attachments)
{
if (file.Length <= 0)
continue;

await using var stream = new MemoryStream();
await file.CopyToAsync(stream);

attachments.Add(new EstimateRevisionAttachmentUpload(
file.FileName,
file.ContentType,
stream.ToArray(),
file.Length));
}
}

var result = await _estimateRevisions.CreateAsync(
id,
organizationId,
orgClientId,
new CreateEstimateRevisionRequest(request.Message ?? string.Empty, attachments));

if (!result.IsSuccess)
return result.ToProblemDetails();

await _hubContext.Clients.Group($"org:{organizationId}:dashboard")
.SendAsync("EstimateRevisionRequested", new
{
estimateId = id,
revisionRequestId = result.Value.Id,
revisionNumber = result.Value.RevisionNumber,
requestedAt = result.Value.RequestedAt,
message = result.Value.RequestMessage
});

return Results.Ok(result.Value);
}

[HttpGet("estimates/{id:guid}/revision-requests")]
public async Task<IResult> GetEstimateRevisionRequests(Guid id)
{
var organizationId = HttpContext.GetOrganizationId();
var orgClientId = HttpContext.GetUserId();

var result = await _estimateRevisions.GetByEstimateAsync(id, organizationId, orgClientId);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

[HttpGet("estimates/{estimateId:guid}/revision-requests/{revisionRequestId:guid}/attachments/{attachmentId:guid}")]
public async Task<IResult> DownloadEstimateRevisionAttachment(Guid estimateId, Guid revisionRequestId, Guid attachmentId)
{
var organizationId = HttpContext.GetOrganizationId();
var orgClientId = HttpContext.GetUserId();

var result = await _estimateRevisions.GetAttachmentAsync(
estimateId,
revisionRequestId,
attachmentId,
organizationId,
orgClientId);

if (!result.IsSuccess)
return result.ToProblemDetails();

return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
}

[HttpGet("invoices")]
public async Task<IResult> GetMyInvoices()
{
Expand All @@ -140,3 +227,7 @@ public record UpdateOrganizationClientRequest(
string? City,
string? State,
string? ZipCode);

public record CreateEstimateRevisionFormRequest(
string? Message,
List<IFormFile>? Attachments);
1 change: 1 addition & 0 deletions JobFlow.API/Hubs/ChatHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ public async Task LeaveConversation(Guid conversationId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, conversationId.ToString());
}

}
23 changes: 23 additions & 0 deletions JobFlow.API/Hubs/NotifierHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace JobFlow.API.Hubs;

[Authorize]
public class NotifierHub : Hub
{
public async Task JoinOrganizationDashboard()
{
var organizationId = Context.User?.FindFirst("organizationId")?.Value;
if (Guid.TryParse(organizationId, out var orgId))
await Groups.AddToGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard");
}

public async Task LeaveOrganizationDashboard()
{
var organizationId = Context.User?.FindFirst("organizationId")?.Value;
if (Guid.TryParse(organizationId, out var orgId))
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"org:{orgId}:dashboard");
}
}
10 changes: 10 additions & 0 deletions JobFlow.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

builder.Services.AddValidatorsFromAssemblyContaining<OrganizationValidator>();

// ============================================================
// SIGNALR
// ============================================================
Expand Down Expand Up @@ -342,6 +344,13 @@

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<JobFlowDbContext>>();
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.Database.MigrateAsync();
}

StripeConfiguration.ApiKey = builder.Configuration["StripeSettings-ApiKey"];

if (app.Environment.IsDevelopment())
Expand All @@ -367,5 +376,6 @@

app.MapControllers();
app.MapHub<ChatHub>("/hubs/chat");
app.MapHub<NotifierHub>("/hubs/notifier");

app.Run();
4 changes: 0 additions & 4 deletions JobFlow.API/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,5 @@
},
"Backend": {
"BaseUrl": "https://localhost:44398"
},
"Turnstile": {
"SecretKey": "YOUR_TURNSTILE_SECRET",
"ExpectedHostname": "localhost"
}
}
4 changes: 0 additions & 4 deletions JobFlow.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,11 @@
},
"AllowedHosts": "*",
"KeyVaultUri": "https://katharix-vault.vault.azure.net/",
"WebhookKey": "whsec_449239427e6f306629fdd3cf4a2d4e8157b1817c8ae85de887bd76380a12bf9a",
"Frontend": {
"BaseUrl": "https://gojobflow.com"
},
"Backend": {
"BaseUrl": "https://api.gojobflow.com"
},
"Turnstile": {
"ExpectedHostname": "gojobflow.com"
}
}

46 changes: 46 additions & 0 deletions JobFlow.Business/ModelErrors/EstimateRevisionErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace JobFlow.Business.ModelErrors;

public static class EstimateRevisionErrors
{
public static readonly Error EstimateNotFound =
Error.NotFound("EstimateRevision.EstimateNotFound", "The estimate was not found.");

public static readonly Error UnauthorizedEstimateAccess =
Error.NotFound("EstimateRevision.UnauthorizedEstimateAccess", "The estimate was not found.");

public static readonly Error InvalidEstimateStatus =
Error.Conflict("EstimateRevision.InvalidEstimateStatus", "Revisions can only be requested for sent or accepted estimates.");

public static readonly Error OpenRevisionAlreadyExists =
Error.Conflict("EstimateRevision.OpenRevisionAlreadyExists", "A revision request is already open for this estimate.");

public static readonly Error MessageRequired =
Error.Validation("EstimateRevision.MessageRequired", "A revision message is required.");

public static readonly Error MessageTooLong =
Error.Validation("EstimateRevision.MessageTooLong", "Revision message must be 2000 characters or less.");

public static readonly Error TooManyAttachments =
Error.Validation("EstimateRevision.TooManyAttachments", "A maximum of 5 attachments are allowed per revision request.");

public static readonly Error AttachmentTooLarge =
Error.Validation("EstimateRevision.AttachmentTooLarge", "Each attachment must be 10 MB or less.");

public static readonly Error InvalidAttachmentContentType =
Error.Validation("EstimateRevision.InvalidAttachmentContentType", "One or more attachments use an unsupported file type.");

public static readonly Error InvalidAttachment =
Error.Validation("EstimateRevision.InvalidAttachment", "One or more attachments are invalid.");

public static readonly Error RevisionRequestNotFound =
Error.NotFound("EstimateRevision.RevisionRequestNotFound", "Revision request was not found.");

public static readonly Error AttachmentNotFound =
Error.NotFound("EstimateRevision.AttachmentNotFound", "Attachment was not found.");

public static readonly Error OrganizationNotFound =
Error.NotFound("EstimateRevision.OrganizationNotFound", "Organization was not found.");

public static readonly Error ClientNotFound =
Error.NotFound("EstimateRevision.ClientNotFound", "Organization client was not found.");
}
43 changes: 43 additions & 0 deletions JobFlow.Business/Models/DTOs/EstimateRevisionDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using JobFlow.Domain.Enums;

namespace JobFlow.Business.Models.DTOs;

public sealed record EstimateRevisionAttachmentUpload(
string FileName,
string ContentType,
byte[] Content,
long SizeBytes
);

public sealed record CreateEstimateRevisionRequest(
string Message,
IReadOnlyList<EstimateRevisionAttachmentUpload> Attachments
);

public sealed record EstimateRevisionAttachmentDto(
Guid Id,
string FileName,
string ContentType,
long FileSizeBytes
);

public sealed record EstimateRevisionRequestDto(
Guid Id,
Guid EstimateId,
Guid OrganizationId,
Guid OrganizationClientId,
int RevisionNumber,
EstimateRevisionStatus Status,
string RequestMessage,
string? OrganizationResponseMessage,
DateTimeOffset RequestedAt,
DateTimeOffset? ReviewedAt,
DateTimeOffset? ResolvedAt,
IReadOnlyList<EstimateRevisionAttachmentDto> Attachments
);

public sealed record EstimateRevisionAttachmentDownloadDto(
string FileName,
string ContentType,
byte[] Content
);
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public interface INotificationMessageBuilder
NotificationMessage BuildEmployeeInvite(EmployeeInvite invite);

NotificationMessage BuildClientEstimateSent(OrganizationClient client, Estimate estimate);
NotificationMessage BuildOrganizationEstimateRevisionRequested(Organization organization, OrganizationClient client, Estimate estimate, string revisionMessage);

NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink);
}
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,29 @@ Your estimate is ready.
};
}

public NotificationMessage BuildOrganizationEstimateRevisionRequested(
Organization organization,
OrganizationClient client,
Estimate estimate,
string revisionMessage)
{
return new NotificationMessage
{
Name = organization.OrganizationName,
Email = organization.EmailAddress,
Phone = organization.PhoneNumber,
Subject = $"Estimate Revision Requested: {estimate.EstimateNumber}",
Body = $"""
Client {client.ClientFullName()} requested estimate revisions.

Estimate: {estimate.EstimateNumber}
Message: {revisionMessage}
""",
Sms = $"Estimate revision requested for {estimate.EstimateNumber}.",
TemplateId = EmailTemplate.Default
};
}

public NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink)
{
return new NotificationMessage
Expand Down
10 changes: 10 additions & 0 deletions JobFlow.Business/Notifications/NotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ public async Task SendOrganizationSubscriptionPaymentFailedNotificationAsync(Org
await SendNotificationAsync(message);
}

public async Task SendOrganizationEstimateRevisionRequestedNotificationAsync(
Organization organization,
OrganizationClient client,
Estimate estimate,
string revisionMessage)
{
var message = _builder.BuildOrganizationEstimateRevisionRequested(organization, client, estimate, revisionMessage);
await SendNotificationAsync(message);
}

/// <summary>
/// Shared helper for sending email and SMS.
/// </summary>
Expand Down
Loading
Loading