Skip to content

Commit 0d8dfe3

Browse files
authored
Refactor budget repository and handlers; add new budget features (#21)
1 parent 194b9df commit 0d8dfe3

43 files changed

Lines changed: 1209 additions & 92 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,10 @@ logs/
7171

7272
# Copilot instructions
7373
.github/copilot-instructions.md
74+
# Squad: ignore runtime state (logs, inbox, sessions)
75+
.squad/orchestration-log/
76+
.squad/log/
77+
.squad/decisions/inbox/
78+
.squad/sessions/
79+
# Squad: SubSquad activation file (local to this machine)
80+
.squad-workstream

‎README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,33 @@ dotnet run --project src/Voltiq.API
240240

241241
---
242242

243+
## Seed de Desenvolvimento
244+
245+
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.
246+
247+
```bash
248+
dotnet run scripts/seed-dev.cs
249+
```
250+
251+
Por padrão conecta em `Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres`. Para usar outra connection string:
252+
253+
```bash
254+
VOLTIQ_CONNECTION_STRING="Host=..." dotnet run scripts/seed-dev.cs
255+
```
256+
257+
**Dados inseridos:**
258+
259+
| Entidade | Qtd | Destaques |
260+
|---|---|---|
261+
| Usuário | 1 | `dev@voltiq.dev` / `senha@123` |
262+
| Clientes | 3 | Construtora ABC Ltda, João da Silva, Empresa XYZ S.A. |
263+
| Materiais | 4 | Cabo 2,5mm, Cabo 4mm, Tomada 2P+T, Disjuntor 20A |
264+
| Orçamentos | 2 | Um com 3 itens (materiais + customizado), outro com 1 item |
265+
266+
O script é **idempotente**: se o usuário `dev@voltiq.dev` já existir, encerra sem inserir nada.
267+
268+
---
269+
243270
## Camadas
244271

245272
### Domain

‎scripts/seed-dev.cs‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#:sdk Microsoft.NET.Sdk
2+
#:property TargetFramework=net10.0
3+
#:property Nullable=enable
4+
#:property ImplicitUsings=enable
5+
#:property PublishAot=false
6+
#:project ../src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj
7+
#:project ../src/Voltiq.Domain/Voltiq.Domain.csproj
8+
#:project ../src/Voltiq.Application/Voltiq.Application.csproj
9+
10+
using Microsoft.EntityFrameworkCore;
11+
using Microsoft.Extensions.Configuration;
12+
using Voltiq.Application.Common.Interfaces;
13+
using Voltiq.Domain.Entities;
14+
using Voltiq.Domain.Enums;
15+
using Voltiq.Domain.ValueObjects;
16+
using Voltiq.Infrastructure.Auth;
17+
using Voltiq.Infrastructure.Persistence;
18+
using Voltiq.Infrastructure.Persistence.Interceptors;
19+
20+
// ─── Configuração ────────────────────────────────────────────────────────────
21+
22+
const string SEED_EMAIL = "dev@voltiq.dev";
23+
const string SEED_PASSWORD = "senha@123";
24+
25+
var configuration = new ConfigurationBuilder()
26+
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../src/Voltiq.Api"))
27+
.AddJsonFile("appsettings.json", false)
28+
.AddJsonFile("appsettings.Development.json", true)
29+
.AddEnvironmentVariables()
30+
.Build();
31+
32+
var connectionString =
33+
configuration.GetConnectionString("DefaultConnection")
34+
?? "Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres";
35+
36+
// ─── Stub de serviços ────────────────────────────────────────────────────────
37+
38+
var currentUserService = new SeedCurrentUserService();
39+
40+
var interceptor = new SoftDeleteInterceptor();
41+
42+
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
43+
.UseNpgsql(connectionString)
44+
.AddInterceptors(interceptor)
45+
.Options;
46+
47+
await using var db = new ApplicationDbContext(dbOptions, currentUserService);
48+
49+
// ─── Idempotência ─────────────────────────────────────────────────────────────
50+
51+
var email = Email.Create(SEED_EMAIL).Value;
52+
var existingUser = await db.Users
53+
.AsNoTracking()
54+
.FirstOrDefaultAsync(u => u.Email == email);
55+
56+
if (existingUser is not null)
57+
{
58+
Console.WriteLine(
59+
$@"⚠️ Seed já aplicado (usuário '{SEED_EMAIL}' já existe). Nenhum dado foi inserido.");
60+
return;
61+
}
62+
63+
Console.WriteLine(@"🌱 Iniciando seed de desenvolvimento...");
64+
65+
// ─── Usuário ─────────────────────────────────────────────────────────────────
66+
67+
var hasher = new Argon2PasswordHasher();
68+
var passwordHash = hasher.Hash(SEED_PASSWORD);
69+
70+
var userDocument = Document.Create("529.982.247-25").Value;
71+
var user = User.Register("Dev Voltiq", email, userDocument, passwordHash);
72+
73+
await db.Users.AddAsync(user);
74+
await db.SaveChangesAsync();
75+
76+
currentUserService.SetUserId(user.Id);
77+
78+
Console.WriteLine($@"✅ Usuário criado: {user.Name} <{SEED_EMAIL}>");
79+
80+
// ─── Clientes ─────────────────────────────────────────────────────────────────
81+
82+
var clients = new[]
83+
{
84+
Client.Register(user.Id, "Construtora ABC Ltda", "(11) 3333-4444",
85+
Email.Create("contato@construtorabc.com.br").Value,
86+
Address.Create("Av. Paulista", "1000", "São Paulo", "SP", "01310-100")),
87+
88+
Client.Register(user.Id, "João da Silva", "(21) 99999-1234",
89+
Email.Create("joao.silva@gmail.com").Value,
90+
Address.Create("Rua das Flores", "45", "Rio de Janeiro", "RJ", "20040-020")),
91+
92+
Client.Register(user.Id, "Empresa XYZ S.A.", "(51) 3200-5678",
93+
Email.Create("financeiro@xyz.com.br").Value,
94+
Address.Create("Rua dos Andradas", "800", "Porto Alegre", "RS", "90020-004"))
95+
};
96+
97+
await db.Clients.AddRangeAsync(clients);
98+
await db.SaveChangesAsync();
99+
100+
Console.WriteLine($@"✅ {clients.Length} clientes criados.");
101+
102+
// ─── Materiais ────────────────────────────────────────────────────────────────
103+
104+
var materials = new[]
105+
{
106+
Material.Register(user.Id, "Cabo Flexível 2,5mm", 4.80m, MaterialUnit.Metro),
107+
Material.Register(user.Id, "Cabo Flexível 4mm", 7.20m, MaterialUnit.Metro),
108+
Material.Register(user.Id, "Tomada 2P+T", 18.50m, MaterialUnit.Unidade),
109+
Material.Register(user.Id, "Disjuntor 20A", 32.00m, MaterialUnit.Unidade)
110+
};
111+
112+
await db.Materials.AddRangeAsync(materials);
113+
await db.SaveChangesAsync();
114+
115+
Console.WriteLine($@"✅ {materials.Length} materiais criados.");
116+
117+
// ─── Orçamentos ───────────────────────────────────────────────────────────────
118+
119+
// Orçamento 1: Construtora ABC — mix de material vinculado + item customizado
120+
var budget1 = Budget.Register(user.Id, clients[0].Id);
121+
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm",
122+
MaterialUnit.Metro, 50, 4.80m));
123+
budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade,
124+
8, 18.50m));
125+
budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m));
126+
127+
await db.Budgets.AddAsync(budget1);
128+
await db.SaveChangesAsync();
129+
130+
// Orçamento 2: João da Silva — item customizado
131+
var budget2 = Budget.Register(user.Id, clients[1].Id);
132+
budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A",
133+
MaterialUnit.Unidade, 2, 32.00m));
134+
135+
await db.Budgets.AddAsync(budget2);
136+
await db.SaveChangesAsync();
137+
138+
Console.WriteLine(@"✅ 2 orçamentos criados.");
139+
Console.WriteLine();
140+
Console.WriteLine(@"🎉 Seed concluído! Acesse com:");
141+
Console.WriteLine($@" E-mail: {SEED_EMAIL}");
142+
Console.WriteLine($@" Senha: {SEED_PASSWORD}");
143+
144+
// ─── Stub ─────────────────────────────────────────────────────────────────────
145+
146+
internal sealed class SeedCurrentUserService : ICurrentUserService
147+
{
148+
public Guid UserId { get; private set; } = Guid.Empty;
149+
150+
public string UserName => "seed";
151+
public bool IsAuthenticated => true;
152+
153+
public void SetUserId(Guid id)
154+
{
155+
UserId = id;
156+
}
157+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Voltiq.Application.Features.Budgets;
4+
using Voltiq.Application.Features.Budgets.Commands.DeleteBudget;
5+
using Voltiq.Application.Features.Budgets.Commands.RegisterBudget;
6+
using Voltiq.Application.Features.Budgets.Queries.GetBudgetById;
7+
using Voltiq.Application.Features.Budgets.Queries.GetBudgets;
8+
using Voltiq.Application.Mappings.Budgets;
9+
10+
namespace Voltiq.API.Controllers.Budgets;
11+
12+
[ApiVersion("1.0")]
13+
public sealed class BudgetsController : BaseApiController
14+
{
15+
/// <summary>Creates a new budget with the specified client and items.</summary>
16+
[HttpPost]
17+
[ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status201Created)]
18+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
19+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
20+
[ProducesResponseType(StatusCodes.Status404NotFound)]
21+
public async Task<IActionResult> Register(
22+
[FromBody] RegisterBudgetRequest request,
23+
CancellationToken cancellationToken)
24+
{
25+
var result = await Sender.Send(request.ToCommand(), cancellationToken);
26+
27+
return result.Match(
28+
budget => CreatedAtAction(nameof(GetById), new { id = budget.Id }, budget),
29+
ToErrorResult);
30+
}
31+
32+
/// <summary>Returns all budgets belonging to the authenticated user.</summary>
33+
[HttpGet]
34+
[ProducesResponseType(typeof(IReadOnlyList<BudgetSummaryResponse>), StatusCodes.Status200OK)]
35+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
36+
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
37+
{
38+
var result = await Sender.Send(new GetBudgetsQuery(), cancellationToken);
39+
40+
return result.Match(Ok, ToErrorResult);
41+
}
42+
43+
/// <summary>Returns a specific budget by ID with full client and item details.</summary>
44+
[HttpGet("{id:guid}")]
45+
[ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status200OK)]
46+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
47+
[ProducesResponseType(StatusCodes.Status404NotFound)]
48+
public async Task<IActionResult> GetById(
49+
Guid id,
50+
CancellationToken cancellationToken)
51+
{
52+
var result = await Sender.Send(new GetBudgetByIdQuery(id), cancellationToken);
53+
54+
return result.Match(Ok, ToErrorResult);
55+
}
56+
57+
/// <summary>Deletes a budget (must belong to the authenticated user).</summary>
58+
[HttpDelete("{id:guid}")]
59+
[ProducesResponseType(StatusCodes.Status204NoContent)]
60+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
61+
[ProducesResponseType(StatusCodes.Status404NotFound)]
62+
public async Task<IActionResult> Delete(
63+
Guid id,
64+
CancellationToken cancellationToken)
65+
{
66+
var result = await Sender.Send(new DeleteBudgetCommand(id), cancellationToken);
67+
68+
return result.Match(_ => NoContent(), ToErrorResult);
69+
}
70+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.OpenApi;
3+
using Microsoft.OpenApi;
4+
5+
namespace Voltiq.API.Filters;
6+
7+
public class SecurityRequirementsOperationTransformer : IOpenApiOperationTransformer
8+
{
9+
public Task TransformAsync(OpenApiOperation operation,
10+
OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
11+
{
12+
var isAnonymous = context.Description.ActionDescriptor
13+
.EndpointMetadata
14+
.OfType<IAllowAnonymous>()
15+
.Any();
16+
17+
if (isAnonymous) return Task.CompletedTask;
18+
19+
operation.Security =
20+
[
21+
new OpenApiSecurityRequirement
22+
{
23+
{ new OpenApiSecuritySchemeReference("Bearer", context.Document), [] }
24+
}
25+
];
26+
27+
return Task.CompletedTask;
28+
}
29+
}

‎src/Voltiq.API/Program.cs‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.OpenApi;
44
using Serilog;
55
using Voltiq.API.ExceptionHandlers;
6+
using Voltiq.API.Filters;
67
using Voltiq.Application;
78
using Voltiq.Infrastructure;
89
using Voltiq.Infrastructure.Persistence;
@@ -83,16 +84,11 @@ prática e eficiente de gerir seus serviços.
8384
};
8485

8586
document.Security ??= new List<OpenApiSecurityRequirement>();
86-
document.Security.Add(new OpenApiSecurityRequirement
87-
{
88-
{
89-
new OpenApiSecuritySchemeReference("Bearer", document),
90-
[]
91-
}
92-
});
9387

9488
return Task.CompletedTask;
9589
});
90+
91+
o.AddOperationTransformer<SecurityRequirementsOperationTransformer>();
9692
});
9793

9894
builder.Services.AddCors(options =>

‎src/Voltiq.Application/DependencyInjection.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public static void AddApplication(this IServiceCollection services,
1818
configuration["MediatR:LicenseKey"] ??
1919
throw new InvalidOperationException("MediatR license key is not configured.");
2020
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
21-
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
2221
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
22+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
2323
});
2424

2525
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Voltiq.Domain.Enums;
2+
3+
namespace Voltiq.Application.Features.Budgets;
4+
5+
public sealed record BudgetSummaryResponse(
6+
Guid Id,
7+
BudgetStatus Status,
8+
decimal TotalAmount,
9+
DateTime CreatedAt,
10+
BudgetClientSummaryResponse Client);
11+
12+
public sealed record BudgetClientSummaryResponse(Guid Id, string Name);
13+
14+
public sealed record BudgetDetailResponse(
15+
Guid Id,
16+
BudgetStatus Status,
17+
decimal TotalAmount,
18+
DateTime CreatedAt,
19+
BudgetClientDetailResponse Client,
20+
IReadOnlyList<BudgetItemResponse> Items);
21+
22+
public sealed record BudgetClientDetailResponse(Guid Id, string Name, string Phone, string Email);
23+
24+
public sealed record BudgetItemResponse(
25+
Guid Id,
26+
Guid? MaterialId,
27+
string MaterialName,
28+
MaterialUnit? Unit,
29+
int Quantity,
30+
decimal UnitPrice,
31+
decimal TotalPrice);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using ErrorOr;
2+
using Voltiq.Application.Common.Interfaces;
3+
4+
namespace Voltiq.Application.Features.Budgets.Commands.DeleteBudget;
5+
6+
public sealed record DeleteBudgetCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Deleted>>
7+
{
8+
public Guid UserId { get; set; }
9+
}

0 commit comments

Comments
 (0)