Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cdea039
refactor(repositories): rename GetByIdAndUserIdAsync to GetTrackedByI…
evans-costa Apr 1, 2026
73c5742
refactor(repositories): update UpdateOnly explicit implementations wi…
evans-costa Apr 1, 2026
5238221
refactor(handlers): update client handlers to call GetTrackedByIdAndU…
evans-costa Apr 1, 2026
b5e60b9
refactor(handlers): update material handlers to call GetTrackedByIdAn…
evans-costa Apr 1, 2026
696afbe
test(clients): update mocks to use GetTrackedByIdAndUserIdAsync
evans-costa Apr 1, 2026
603e540
test(materials): update mocks to use GetTrackedByIdAndUserIdAsync
evans-costa Apr 1, 2026
6dfba74
refactor(pipeline): fix behavior registration order (Authorization be…
evans-costa Apr 1, 2026
6455cbd
test(budgets): add failing tests for Budget commands and queries
evans-costa Apr 1, 2026
ce42150
feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS resource message
evans-costa Apr 1, 2026
0608e7f
feat(budgets): add Client nav property to Budget and update BudgetCon…
evans-costa Apr 1, 2026
a07403b
feat(budgets): add GetByUserIdWithClientAsync and GetByIdWithItemsAnd…
evans-costa Apr 1, 2026
c91ac95
feat(budgets): implement GetByUserIdWithClientAsync and GetByIdWithIt…
evans-costa Apr 1, 2026
363afbc
feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS to ResourceErrorMessa…
evans-costa Apr 2, 2026
d08cf03
feat(budgets): add budget response DTOs
evans-costa Apr 2, 2026
cbd3592
feat(budgets): add BudgetMappingExtensions
evans-costa Apr 2, 2026
3f95be5
feat(budgets): implement RegisterBudgetCommand
evans-costa Apr 2, 2026
dd1e433
feat(budgets): implement DeleteBudgetCommand
evans-costa Apr 2, 2026
1b17500
feat(budgets): implement GetBudgetsQuery
evans-costa Apr 2, 2026
4b73500
feat(budgets): implement GetBudgetByIdQuery
evans-costa Apr 2, 2026
ff38c97
feat(budgets): add BudgetsController
evans-costa Apr 2, 2026
621a9b0
feat(budgets): enhance security requirements and update README with s…
evans-costa Apr 10, 2026
f9dfb12
style(budgets): fix formatting in UpdateClient and UpdateMaterial com…
evans-costa Apr 10, 2026
387c0b9
feat(budgets): add development seed script for initial data population
evans-costa Apr 10, 2026
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,10 @@ logs/

# Copilot instructions
.github/copilot-instructions.md
# Squad: ignore runtime state (logs, inbox, sessions)
.squad/orchestration-log/
.squad/log/
.squad/decisions/inbox/
.squad/sessions/
# Squad: SubSquad activation file (local to this machine)
.squad-workstream
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ dotnet run --project src/Voltiq.API

---

## Seed de Desenvolvimento

O script `scripts/seed-dev.cs` popula o banco com dados de exemplo usando a feature de **file-based app** do .NET 10 — sem `.csproj`, sem dependências adicionais.

```bash
dotnet run scripts/seed-dev.cs
```

Por padrão conecta em `Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres`. Para usar outra connection string:

```bash
VOLTIQ_CONNECTION_STRING="Host=..." dotnet run scripts/seed-dev.cs
```

**Dados inseridos:**

| Entidade | Qtd | Destaques |
|---|---|---|
| Usuário | 1 | `dev@voltiq.dev` / `senha@123` |
| Clientes | 3 | Construtora ABC Ltda, João da Silva, Empresa XYZ S.A. |
| Materiais | 4 | Cabo 2,5mm, Cabo 4mm, Tomada 2P+T, Disjuntor 20A |
| Orçamentos | 2 | Um com 3 itens (materiais + customizado), outro com 1 item |

O script é **idempotente**: se o usuário `dev@voltiq.dev` já existir, encerra sem inserir nada.

---

## Camadas

### Domain
Expand Down
157 changes: 157 additions & 0 deletions scripts/seed-dev.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#:sdk Microsoft.NET.Sdk
#:property TargetFramework=net10.0
#:property Nullable=enable
#:property ImplicitUsings=enable
#:property PublishAot=false
#:project ../src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj
#:project ../src/Voltiq.Domain/Voltiq.Domain.csproj
#:project ../src/Voltiq.Application/Voltiq.Application.csproj

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Enums;
using Voltiq.Domain.ValueObjects;
using Voltiq.Infrastructure.Auth;
using Voltiq.Infrastructure.Persistence;
using Voltiq.Infrastructure.Persistence.Interceptors;

// ─── Configuração ────────────────────────────────────────────────────────────

const string SEED_EMAIL = "dev@voltiq.dev";
const string SEED_PASSWORD = "senha@123";

var configuration = new ConfigurationBuilder()
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../src/Voltiq.Api"))
.AddJsonFile("appsettings.json", false)
.AddJsonFile("appsettings.Development.json", true)
.AddEnvironmentVariables()
.Build();

var connectionString =
configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres";

// ─── Stub de serviços ────────────────────────────────────────────────────────

var currentUserService = new SeedCurrentUserService();

var interceptor = new SoftDeleteInterceptor();

var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(connectionString)
.AddInterceptors(interceptor)
.Options;

await using var db = new ApplicationDbContext(dbOptions, currentUserService);

// ─── Idempotência ─────────────────────────────────────────────────────────────

var email = Email.Create(SEED_EMAIL).Value;
var existingUser = await db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email == email);

if (existingUser is not null)
{
Console.WriteLine(
$@"⚠️ Seed já aplicado (usuário '{SEED_EMAIL}' já existe). Nenhum dado foi inserido.");
return;
}

Console.WriteLine(@"🌱 Iniciando seed de desenvolvimento...");

// ─── Usuário ─────────────────────────────────────────────────────────────────

var hasher = new Argon2PasswordHasher();
var passwordHash = hasher.Hash(SEED_PASSWORD);

var userDocument = Document.Create("529.982.247-25").Value;
var user = User.Register("Dev Voltiq", email, userDocument, passwordHash);

await db.Users.AddAsync(user);
await db.SaveChangesAsync();

currentUserService.SetUserId(user.Id);

Console.WriteLine($@"✅ Usuário criado: {user.Name} <{SEED_EMAIL}>");

// ─── Clientes ─────────────────────────────────────────────────────────────────

var clients = new[]
{
Client.Register(user.Id, "Construtora ABC Ltda", "(11) 3333-4444",
Email.Create("contato@construtorabc.com.br").Value,
Address.Create("Av. Paulista", "1000", "São Paulo", "SP", "01310-100")),

Client.Register(user.Id, "João da Silva", "(21) 99999-1234",
Email.Create("joao.silva@gmail.com").Value,
Address.Create("Rua das Flores", "45", "Rio de Janeiro", "RJ", "20040-020")),

Client.Register(user.Id, "Empresa XYZ S.A.", "(51) 3200-5678",
Email.Create("financeiro@xyz.com.br").Value,
Address.Create("Rua dos Andradas", "800", "Porto Alegre", "RS", "90020-004"))
};

await db.Clients.AddRangeAsync(clients);
await db.SaveChangesAsync();

Console.WriteLine($@"✅ {clients.Length} clientes criados.");

// ─── Materiais ────────────────────────────────────────────────────────────────

var materials = new[]
{
Material.Register(user.Id, "Cabo Flexível 2,5mm", 4.80m, MaterialUnit.Metro),
Material.Register(user.Id, "Cabo Flexível 4mm", 7.20m, MaterialUnit.Metro),
Material.Register(user.Id, "Tomada 2P+T", 18.50m, MaterialUnit.Unidade),
Material.Register(user.Id, "Disjuntor 20A", 32.00m, MaterialUnit.Unidade)
};

await db.Materials.AddRangeAsync(materials);
await db.SaveChangesAsync();

Console.WriteLine($@"✅ {materials.Length} materiais criados.");

// ─── Orçamentos ───────────────────────────────────────────────────────────────

// Orçamento 1: Construtora ABC — mix de material vinculado + item customizado
var budget1 = Budget.Register(user.Id, clients[0].Id);
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm",
MaterialUnit.Metro, 50, 4.80m));
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade,
8, 18.50m));
budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m));

await db.Budgets.AddAsync(budget1);
await db.SaveChangesAsync();

// Orçamento 2: João da Silva — item customizado
var budget2 = Budget.Register(user.Id, clients[1].Id);
budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A",
MaterialUnit.Unidade, 2, 32.00m));

await db.Budgets.AddAsync(budget2);
await db.SaveChangesAsync();

Console.WriteLine(@"✅ 2 orçamentos criados.");
Console.WriteLine();
Console.WriteLine(@"🎉 Seed concluído! Acesse com:");
Console.WriteLine($@" E-mail: {SEED_EMAIL}");
Console.WriteLine($@" Senha: {SEED_PASSWORD}");

// ─── Stub ─────────────────────────────────────────────────────────────────────

internal sealed class SeedCurrentUserService : ICurrentUserService
{
public Guid UserId { get; private set; } = Guid.Empty;

public string UserName => "seed";
public bool IsAuthenticated => true;

public void SetUserId(Guid id)
{
UserId = id;
}
}
70 changes: 70 additions & 0 deletions src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Voltiq.Application.Features.Budgets;
using Voltiq.Application.Features.Budgets.Commands.DeleteBudget;
using Voltiq.Application.Features.Budgets.Commands.RegisterBudget;
using Voltiq.Application.Features.Budgets.Queries.GetBudgetById;
using Voltiq.Application.Features.Budgets.Queries.GetBudgets;
using Voltiq.Application.Mappings.Budgets;

namespace Voltiq.API.Controllers.Budgets;

[ApiVersion("1.0")]
public sealed class BudgetsController : BaseApiController
{
/// <summary>Creates a new budget with the specified client and items.</summary>
[HttpPost]
[ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Register(
[FromBody] RegisterBudgetRequest request,
CancellationToken cancellationToken)
{
var result = await Sender.Send(request.ToCommand(), cancellationToken);

return result.Match(
budget => CreatedAtAction(nameof(GetById), new { id = budget.Id }, budget),
ToErrorResult);
}

/// <summary>Returns all budgets belonging to the authenticated user.</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<BudgetSummaryResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
{
var result = await Sender.Send(new GetBudgetsQuery(), cancellationToken);

return result.Match(Ok, ToErrorResult);
}

/// <summary>Returns a specific budget by ID with full client and item details.</summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new GetBudgetByIdQuery(id), cancellationToken);

return result.Match(Ok, ToErrorResult);
}

/// <summary>Deletes a budget (must belong to the authenticated user).</summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(
Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new DeleteBudgetCommand(id), cancellationToken);

return result.Match(_ => NoContent(), ToErrorResult);
}
}
29 changes: 29 additions & 0 deletions src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace Voltiq.API.Filters;

public class SecurityRequirementsOperationTransformer : IOpenApiOperationTransformer
{
public Task TransformAsync(OpenApiOperation operation,
OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
{
var isAnonymous = context.Description.ActionDescriptor
.EndpointMetadata
.OfType<IAllowAnonymous>()
.Any();

if (isAnonymous) return Task.CompletedTask;

operation.Security =
[
new OpenApiSecurityRequirement
{
{ new OpenApiSecuritySchemeReference("Bearer", context.Document), [] }
}
];

return Task.CompletedTask;
}
}
10 changes: 3 additions & 7 deletions src/Voltiq.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.OpenApi;
using Serilog;
using Voltiq.API.ExceptionHandlers;
using Voltiq.API.Filters;
using Voltiq.Application;
using Voltiq.Infrastructure;
using Voltiq.Infrastructure.Persistence;
Expand Down Expand Up @@ -83,16 +84,11 @@ prática e eficiente de gerir seus serviços.
};

document.Security ??= new List<OpenApiSecurityRequirement>();
document.Security.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecuritySchemeReference("Bearer", document),
[]
}
});

return Task.CompletedTask;
});

o.AddOperationTransformer<SecurityRequirementsOperationTransformer>();
});

builder.Services.AddCors(options =>
Expand Down
2 changes: 1 addition & 1 deletion src/Voltiq.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public static void AddApplication(this IServiceCollection services,
configuration["MediatR:LicenseKey"] ??
throw new InvalidOperationException("MediatR license key is not configured.");
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});

services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
Expand Down
31 changes: 31 additions & 0 deletions src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Voltiq.Domain.Enums;

namespace Voltiq.Application.Features.Budgets;

public sealed record BudgetSummaryResponse(
Guid Id,
BudgetStatus Status,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientSummaryResponse Client);

public sealed record BudgetClientSummaryResponse(Guid Id, string Name);

public sealed record BudgetDetailResponse(
Guid Id,
BudgetStatus Status,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientDetailResponse Client,
IReadOnlyList<BudgetItemResponse> Items);

public sealed record BudgetClientDetailResponse(Guid Id, string Name, string Phone, string Email);

public sealed record BudgetItemResponse(
Guid Id,
Guid? MaterialId,
string MaterialName,
MaterialUnit? Unit,
int Quantity,
decimal UnitPrice,
decimal TotalPrice);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using ErrorOr;
using Voltiq.Application.Common.Interfaces;

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

public sealed record DeleteBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Deleted>>
{
public Guid UserId { get; set; }
}
Loading
Loading