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
45 changes: 44 additions & 1 deletion JobFlow.API/Controllers/ClientHubController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JobFlow.API.Extensions;
using JobFlow.API.Mappings;
using JobFlow.API.Hubs;
using JobFlow.API.Models;
using JobFlow.Business.Extensions;
Expand All @@ -19,6 +20,7 @@ namespace JobFlow.API.Controllers;
[Authorize(AuthenticationSchemes = "ClientPortalJwt", Policy = "OrganizationClientOnly")]
public class ClientHubController : ControllerBase
{
private readonly ILogger<ClientHubController> _logger;
private readonly IEstimateService _estimates;
private readonly IEstimateRevisionService _estimateRevisions;
private readonly IInvoiceService _invoices;
Expand All @@ -29,6 +31,7 @@ public class ClientHubController : ControllerBase
private readonly IUnitOfWork _unitOfWork;

public ClientHubController(
ILogger<ClientHubController> logger,
IEstimateService estimates,
IEstimateRevisionService estimateRevisions,
IInvoiceService invoices,
Expand All @@ -38,6 +41,7 @@ public ClientHubController(
IHubContext<ClientChatHub> clientChatHubContext,
IUnitOfWork unitOfWork)
{
_logger = logger;
_estimates = estimates;
_estimateRevisions = estimateRevisions;
_invoices = invoices;
Expand Down Expand Up @@ -413,7 +417,46 @@ public async Task<IResult> GetMyInvoices()
{
var orgClientId = HttpContext.GetUserId();
var result = await _invoices.GetInvoicesByClientAsync(orgClientId);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
return result.IsSuccess ? Results.Ok(result.Value.ToDto()) : result.ToProblemDetails();
}

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

_logger.LogInformation(
"ClientHub invoice detail requested. InvoiceId={InvoiceId} OrgId={OrgId} ClientId={ClientId}",
id,
organizationId,
orgClientId);

var result = await _invoices.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
{
_logger.LogWarning(
"ClientHub invoice not found. InvoiceId={InvoiceId} OrgId={OrgId} ClientId={ClientId}",
id,
organizationId,
orgClientId);
return result.ToProblemDetails();
}

var invoice = result.Value;
if (invoice.OrganizationId != organizationId || invoice.OrganizationClientId != orgClientId)
{
_logger.LogWarning(
"ClientHub invoice mismatch. InvoiceId={InvoiceId} InvoiceOrgId={InvoiceOrgId} InvoiceClientId={InvoiceClientId} OrgId={OrgId} ClientId={ClientId}",
id,
invoice.OrganizationId,
invoice.OrganizationClientId,
organizationId,
orgClientId);
return Results.NotFound();
}

return Results.Ok(invoice.ToDto());
}

private async Task<Conversation> FindOrCreateClientConversationAsync(Guid orgClientId, Guid organizationId)
Expand Down
54 changes: 46 additions & 8 deletions JobFlow.API/Controllers/InvoiceComtroller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class InvoiceController : ControllerBase
private readonly IInvoiceLineItemService lineItemService;
private readonly IJobService _jobService;
private readonly INotificationService notificationService;
private readonly IOrganizationClientPortalService _clientPortal;
private readonly IInvoiceNumberGenerator numberGenerator;
private readonly IPdfGenerator pdfGenerator;
private readonly IMapper _mapper;
Expand All @@ -25,6 +26,7 @@ public InvoiceController(
IInvoiceNumberGenerator numberGenerator,
IPdfGenerator pdfGenerator,
INotificationService notificationService,
IOrganizationClientPortalService clientPortal,
IJobService jobService,
IMapper mapper
)
Expand All @@ -34,6 +36,7 @@ IMapper mapper
this.numberGenerator = numberGenerator;
this.pdfGenerator = pdfGenerator;
this.notificationService = notificationService;
_clientPortal = clientPortal;
this._jobService = jobService;
this._mapper = mapper;
}
Expand Down Expand Up @@ -79,9 +82,16 @@ public async Task<IActionResult> Upsert(

var result = await invoiceService.UpsertInvoiceAsync(invoice);

return result.IsSuccess
? Ok(result.Value.ToDto())
: BadRequest(result.Error);
if (!result.IsSuccess)
return BadRequest(result.Error);

var hydratedInvoice = await invoiceService.GetInvoiceByIdAsync(result.Value.Id);
if (hydratedInvoice.IsSuccess)
{
await invoiceService.SendInvoiceToClientAsync(hydratedInvoice.Value.Id);
}

return Ok(result.Value.ToDto());
}

[HttpPost("organization")]
Expand All @@ -96,20 +106,48 @@ public Task<IActionResult> UpsertForOrganization([FromBody] CreateInvoiceRequest

[HttpPost("{id:guid}/send")]
public async Task<IActionResult> SendInvoice(Guid id)
{
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);

await invoiceService.SendInvoiceToClientAsync(result.Value.Id);

return Ok();
}

[HttpPost("{id:guid}/remind")]
public async Task<IActionResult> SendInvoiceReminder(Guid id)
{
var result = await invoiceService.GetInvoiceByIdAsync(id);
if (!result.IsSuccess)
return NotFound(result.Error);

var invoice = result.Value;

await notificationService.SendClientInvoiceCreatedNotificationAsync(
string? linkOverride = null;
var email = invoice.OrganizationClient?.EmailAddress;
if (!string.IsNullOrWhiteSpace(email))
{
var returnUrl = $"/client-hub/invoices/{invoice.Id}";
var linkResult = await _clientPortal.CreateMagicLinkAsync(
invoice.OrganizationId,
invoice.OrganizationClientId,
email,
returnUrl);

if (linkResult.IsSuccess)
{
linkOverride = linkResult.Value;
}
}

await notificationService.SendClientInvoiceReminderNotificationAsync(
invoice.OrganizationClient,
invoice
invoice,
linkOverride
);

await invoiceService.MarkInvoiceSentAsync(invoice.Id);

return Ok();
}

Expand All @@ -133,7 +171,7 @@ public async Task<IActionResult> GeneratePdf(Guid id)
//46455c4d-58c0-49ef-b18a-84704dbd50aa
var pdf = await pdfGenerator.GenerateInvoicePdfAsync(result.Value);
var invoice = result.Value;
await notificationService.SendClientInvoiceCreatedNotificationAsync(invoice.OrganizationClient, invoice);
await invoiceService.SendInvoiceToClientAsync(invoice.Id);
var pdfName = $"{invoice.OrganizationClient.Organization.OrganizationName}-Invoice-{invoice.InvoiceNumber}.pdf";
return File(pdf, "application/pdf", $"{pdfName}");
}
Expand Down
42 changes: 42 additions & 0 deletions JobFlow.API/Controllers/InvoicingSettingsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using JobFlow.API.Extensions;
using JobFlow.Business.Models.DTOs;
using JobFlow.Business.Services.ServiceInterfaces;
using Microsoft.AspNetCore.Mvc;

namespace JobFlow.API.Controllers;

[ApiController]
[Route("api/invoicing-settings")]
public class InvoicingSettingsController : ControllerBase
{
private readonly IInvoicingSettingsService _settings;

public InvoicingSettingsController(IInvoicingSettingsService settings)
{
_settings = settings;
}

[HttpGet]
public async Task<IActionResult> Get()
{
var organizationId = HttpContext.GetOrganizationId();

var result = await _settings.GetInvoicingSettingsAsync(organizationId);
if (result.IsFailure)
return BadRequest(result.Error);

return Ok(result.Value);
}

[HttpPut]
public async Task<IActionResult> Update([FromBody] InvoicingSettingsUpsertRequestDto dto)
{
var organizationId = HttpContext.GetOrganizationId();

var result = await _settings.UpsertInvoicingSettingsAsync(organizationId, dto);
if (result.IsFailure)
return BadRequest(result.Error);

return Ok(result.Value);
}
}
85 changes: 83 additions & 2 deletions JobFlow.API/Controllers/OnboardingController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using JobFlow.Business.Extensions;
using JobFlow.API.Extensions;
using JobFlow.Business.Extensions;
using JobFlow.Business.Models.DTOs;
using JobFlow.Business.Services.ServiceInterfaces;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JobFlow.API.Controllers;
Expand All @@ -9,10 +12,12 @@ namespace JobFlow.API.Controllers;
public class OnboardingController : ControllerBase
{
private readonly IOnboardingService onboarding;
private readonly IOrganizationService organizations;

public OnboardingController(IOnboardingService onboarding)
public OnboardingController(IOnboardingService onboarding, IOrganizationService organizations)
{
this.onboarding = onboarding;
this.organizations = organizations;
}

[HttpGet("{organizationId:guid}")]
Expand All @@ -23,4 +28,80 @@ public async Task<IResult> Get(Guid organizationId)
? Results.Ok(result.Value)
: result.ToProblemDetails();
}

[HttpGet("quick-start")]
public async Task<IResult> GetQuickStartState()
{
var organizationId = HttpContext.GetOrganizationId();
var result = await onboarding.GetQuickStartStateAsync(organizationId);
return result.IsSuccess
? Results.Ok(result.Value)
: result.ToProblemDetails();
}

[HttpPost("quick-start")]
public async Task<IResult> ApplyQuickStart([FromBody] OnboardingQuickStartApplyRequestDto request)
{
var organizationId = HttpContext.GetOrganizationId();

var orgResult = await organizations.GetOrganizationDtoById(organizationId);
if (orgResult.IsFailure)
{
return orgResult.ToProblemDetails();
}

if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow"))
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Subscription Required",
detail: "A Flow plan is required to apply quick-start presets.");
}

var result = await onboarding.ApplyQuickStartAsync(organizationId, request);
return result.IsSuccess
? Results.Ok(result.Value)
: result.ToProblemDetails();
}

[HttpPost("complete")]
public async Task<IResult> CompleteOnboarding()
{
var organizationId = HttpContext.GetOrganizationId();

var result = await onboarding.MarkOrganizationCompleteIfEligibleAsync(organizationId);
if (!result.IsSuccess)
return result.ToProblemDetails();

if (!result.Value)
{
return Results.Conflict(new
{
completed = false,
message = "Onboarding checklist is not complete yet."
});
}

var orgResult = await organizations.GetOrganizationDtoById(organizationId);
return orgResult.IsSuccess
? Results.Ok(orgResult.Value)
: orgResult.ToProblemDetails();
}

private static bool HasMinPlan(string? planName, string required)
{
static int Rank(string? plan)
{
var value = (plan ?? string.Empty).Trim().ToLowerInvariant();
return value switch
{
"go" => 0,
"flow" => 1,
"max" => 2,
_ => -1
};
}

return Rank(planName) >= Rank(required);
}
}
10 changes: 8 additions & 2 deletions JobFlow.API/Controllers/OrganizationClientController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,14 @@ public async Task<IResult> SendClientHubLink(Guid organizationClientId)
if (string.IsNullOrWhiteSpace(clientResult.Value.EmailAddress))
return Results.BadRequest("Client email address is required.");

var result = await _clientPortal.SendMagicLinkAsync(organizationId, organizationClientId, clientResult.Value.EmailAddress);
return result.IsSuccess ? Results.Ok() : result.ToProblemDetails();
var result = await _clientPortal.SendMagicLinkWithUrlAsync(
organizationId,
organizationClientId,
clientResult.Value.EmailAddress);

return result.IsSuccess
? Results.Ok(new { magicLink = result.Value })
: result.ToProblemDetails();
}

[HttpPost]
Expand Down
30 changes: 30 additions & 0 deletions JobFlow.API/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,36 @@ public async Task<IActionResult> Checkout([FromBody] PaymentSessionRequest reque
var org = await _organizationService.GetOrganiztionById(orgId);
if (org.IsFailure) return NotFound("Organization not found.");

if (request.InvoiceId.HasValue)
{
var invoiceResult = await _invoiceService.GetInvoiceByIdAsync(request.InvoiceId.Value);
if (!invoiceResult.IsSuccess)
return NotFound("Invoice not found.");

var invoice = invoiceResult.Value;
if (invoice.OrganizationId != orgId)
return Unauthorized();

if (User.IsInRole(UserRoles.OrganizationClient))
{
var clientId = HttpContext.GetUserId();
if (invoice.OrganizationClientId != clientId)
return Unauthorized();
}

if (!request.Amount.HasValue)
{
request.Amount = invoice.BalanceDue;
}

if (request.Amount <= 0)
return BadRequest("Payment amount is required.");

request.OrganizationId = invoice.OrganizationId;
request.OrganizationClientId = invoice.OrganizationClientId;
request.ProductName ??= $"Invoice {invoice.InvoiceNumber}";
}

var processor = _processorFactory.GetProcessor(org.Value.PaymentProvider.ToString());

string checkoutUrl;
Expand Down
Loading
Loading