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
27 changes: 14 additions & 13 deletions Voltiq.slnx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Voltiq.API/Voltiq.API.csproj" />
<Project Path="src/Voltiq.Application/Voltiq.Application.csproj" />
<Project Path="src/Voltiq.Domain/Voltiq.Domain.csproj" />
<Project Path="src/Voltiq.Exceptions/Voltiq.Exceptions.csproj" />
<Project Path="src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Voltiq.Application.Tests/Voltiq.Application.Tests.csproj" />
<Project Path="tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj" />
<Project Path="tests/Voltiq.Domain.Tests/Voltiq.Domain.Tests.csproj" />
<Project Path="tests/Voltiq.Infrastructure.Tests/Voltiq.Infrastructure.Tests.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Voltiq.API/Voltiq.API.csproj"/>
<Project Path="src/Voltiq.Application/Voltiq.Application.csproj"/>
<Project Path="src/Voltiq.Domain/Voltiq.Domain.csproj"/>
<Project Path="src/Voltiq.Exceptions/Voltiq.Exceptions.csproj"/>
<Project Path="src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj"/>
<Project Path="src/Voltiq.Functions/Voltiq.Functions.csproj"/>
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Voltiq.Application.Tests/Voltiq.Application.Tests.csproj"/>
<Project Path="tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj"/>
<Project Path="tests/Voltiq.Domain.Tests/Voltiq.Domain.Tests.csproj"/>
<Project Path="tests/Voltiq.Infrastructure.Tests/Voltiq.Infrastructure.Tests.csproj"/>
</Folder>
</Solution>
20 changes: 18 additions & 2 deletions src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;
using Voltiq.Application.Features.Budgets.Commands.ApproveBudget;
using Voltiq.Application.Features.Budgets.Commands.RejectBudget;
using Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf;
using Voltiq.Application.Features.Budgets.Queries.GetBudgetById;
using Voltiq.Application.Features.Budgets.Queries.GetBudgets;
using Voltiq.Application.Mappings.Budgets;
Expand Down Expand Up @@ -90,7 +91,7 @@ public async Task<IActionResult> Update(

/// <summary>Finalizes a budget, making it read-only.</summary>
[HttpPut("{id:guid}/finalize")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand All @@ -100,7 +101,22 @@ public async Task<IActionResult> Finalize(
{
var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
return result.Match(_ => Accepted(), ToErrorResult);
}

/// <summary>Generates/Regenerates the PDF for a finalized budget.</summary>
[HttpPost("{id:guid}/generate-pdf")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GeneratePdf(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new GenerateBudgetPdfCommand(id), cancellationToken);

return result.Match(_ => Accepted(), ToErrorResult);
}

/// <summary>Approves a finalized budget.</summary>
Expand Down
14 changes: 14 additions & 0 deletions src/Voltiq.API/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ services:
db:
image: postgres:17-alpine
container_name: voltiq-db
restart: unless-stopped
environment:
POSTGRES_DB: VoltiqDb
POSTGRES_USER: postgres
Expand All @@ -11,5 +12,18 @@ services:
volumes:
- voltiq-db-data:/var/lib/postgresql/data

azurite:
image: mcr.microsoft.com/azure-storage/azurite
container_name: voltiq-azurite
restart: unless-stopped
command: azurite --skipApiVersionCheck --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0
ports:
- "10000:10000" # Blob service
- "10001:10001" # Queue service
- "10002:10002" # Table service
volumes:
- voltiq-azurite-data:/data

volumes:
voltiq-db-data:
voltiq-azurite-data:
2 changes: 2 additions & 0 deletions src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Voltiq.Application.Features.Budgets;
public sealed record BudgetSummaryResponse(
Guid Id,
BudgetStatus Status,
PdfGenerationStatus? PdfGenerationStatus,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientSummaryResponse Client);
Expand All @@ -14,6 +15,7 @@ public sealed record BudgetClientSummaryResponse(Guid Id, string Name);
public sealed record BudgetDetailResponse(
Guid Id,
BudgetStatus Status,
PdfGenerationStatus? PdfGenerationStatus,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientDetailResponse Client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;

public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Updated>>
public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Success>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces.Queue;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;
Expand All @@ -8,10 +9,11 @@ namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;

public sealed class FinalizeBudgetCommandHandler(
IBudgetUpdateOnlyRepository budgetUpdateOnly,
IUnitOfWork unitOfWork)
: IRequestHandler<FinalizeBudgetCommand, ErrorOr<Updated>>
IUnitOfWork unitOfWork,
IQueueService queueService)
: IRequestHandler<FinalizeBudgetCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Updated>> Handle(
public async Task<ErrorOr<Success>> Handle(
FinalizeBudgetCommand command, CancellationToken cancellationToken)
{
var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync(
Expand All @@ -24,6 +26,9 @@ public async Task<ErrorOr<Updated>> Handle(

await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Updated;
var message = new { BudgetId = budget.Id };
await queueService.SendMessageAsync("budget-reports", message, cancellationToken);

return Result.Success;
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Common.Interfaces.Queue;
using Voltiq.Domain.Enums;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf;

public record GenerateBudgetPdfCommand(Guid BudgetId) : IRequest<ErrorOr<Success>>;

public class GenerateBudgetPdfCommandHandler : IRequestHandler<GenerateBudgetPdfCommand, ErrorOr<Success>>
public record GenerateBudgetPdfCommand(Guid BudgetId) : IAuthenticatedRequest<ErrorOr<Success>>
{
private readonly IQueueService _queueService;
private readonly IBudgetReadOnlyRepository _budgetRepository;

public GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository)
{
_queueService = queueService;
_budgetRepository = budgetRepository;
}
public Guid UserId { get; set; }
}

public class GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository)
: IRequestHandler<GenerateBudgetPdfCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Success>> Handle(GenerateBudgetPdfCommand request, CancellationToken cancellationToken)
{
var budget = await _budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken);
var budget = await budgetRepository.GetByIdAndUserIdAsync(request.BudgetId, request.UserId, cancellationToken);
if (budget == null)
return Error.NotFound(description: ResourceErrorMessages.TITULO_NAO_ENCONTRADO);
return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO);

if (budget.Status != BudgetStatus.Finalized)
return Error.Validation(description: ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);

var message = new { request.BudgetId };

await _queueService.SendMessageAsync("budget-reports", message, cancellationToken);
await queueService.SendMessageAsync("budget-reports", message, cancellationToken);

return Result.Success;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ public UpdateBudgetCommand ToCommand(Guid id) =>
extension(Budget budget)
{
public BudgetSummaryResponse ToSummaryResponse() =>
new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt,
new(budget.Id, budget.Status, budget.PdfGenerationStatus, budget.TotalAmount, budget.CreatedAt,
new BudgetClientSummaryResponse(budget.Client!.Id, budget.Client!.Name));

public BudgetDetailResponse ToDetailResponse() =>
budget.ToDetailResponse(budget.Client!);

public BudgetDetailResponse ToDetailResponse(Client client) =>
new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt,
new(budget.Id, budget.Status, budget.PdfGenerationStatus, budget.TotalAmount, budget.CreatedAt,
new BudgetClientDetailResponse(
client.Id, client.Name, client.Phone, client.Email.Value),
budget.Items.Select(i => new BudgetItemResponse(
Expand Down
43 changes: 41 additions & 2 deletions src/Voltiq.Domain/Entities/Budget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed class Budget : AuditableEntity
public decimal TotalAmount { get; private set; }
public BudgetStatus Status { get; private set; }
public string? PdfUrl { get; private set; }
public PdfGenerationStatus? PdfGenerationStatus { get; private set; }

private readonly List<BudgetItem> _items = [];
public IReadOnlyCollection<BudgetItem> Items => _items.AsReadOnly();
Expand Down Expand Up @@ -82,24 +83,62 @@ public void FinalizeBudget()
throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS);

Status = BudgetStatus.Finalized;
PdfGenerationStatus = Enums.PdfGenerationStatus.Pending;
AddDomainEvent(new BudgetFinalizedEvent(Id));
}

public void Approve()
{
if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated)
if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO);

if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL);

Status = BudgetStatus.Approved;
AddDomainEvent(new BudgetApprovedEvent(Id));
}

public void Reject()
{
if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated)
if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO);

if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL);

Status = BudgetStatus.Rejected;
AddDomainEvent(new BudgetRejectedEvent(Id));
}

public void StartPdfProcessing()
{
if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);

PdfGenerationStatus = Enums.PdfGenerationStatus.Processing;
AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Processing));
}

public void SetPdfGenerationSuccess(string pdfUrl)
{
if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);

if (string.IsNullOrWhiteSpace(pdfUrl))
throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_URL_OBRIGATORIA);

PdfUrl = pdfUrl;
PdfGenerationStatus = Enums.PdfGenerationStatus.Success;
AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Success));
}

public void SetPdfGenerationFailed()
{
if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);

PdfGenerationStatus = Enums.PdfGenerationStatus.Failed;
AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Failed));
}
}
5 changes: 2 additions & 3 deletions src/Voltiq.Domain/Enums/BudgetStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ public enum BudgetStatus
{
Draft = 1,
Finalized = 2,
PdfGenerated = 3,
Approved = 4,
Rejected = 5,
Approved = 3,
Rejected = 4
}
9 changes: 9 additions & 0 deletions src/Voltiq.Domain/Enums/PdfGenerationStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Voltiq.Domain.Enums;

public enum PdfGenerationStatus
{
Pending = 1,
Processing = 2,
Success = 3,
Failed = 4
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Voltiq.Domain.Enums;

namespace Voltiq.Domain.Events;

public sealed record BudgetPdfGenerationStatusChangedEvent(Guid BudgetId, PdfGenerationStatus Status) : BaseDomainEvent;
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ public interface IBudgetUpdateOnlyRepository
Task<Entities.Budget?> GetTrackedByIdWithItemsAndUserIdAsync(Guid id, Guid userId,
CancellationToken cancellationToken = default);

Task<Entities.Budget?> GetTrackedByIdAsync(Guid id, CancellationToken cancellationToken = default);

void Remove(Entities.Budget entity);
}
31 changes: 29 additions & 2 deletions src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading