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
3 changes: 2 additions & 1 deletion docs/schema/README.md

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

3 changes: 3 additions & 0 deletions docs/schema/public.Customers.md

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

1 change: 1 addition & 0 deletions docs/schema/public.Payments.md

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

1 change: 1 addition & 0 deletions docs/schema/public.SalesOrders.md

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

3 changes: 2 additions & 1 deletion docs/schema/viewpoint-2.md

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

6 changes: 4 additions & 2 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,10 @@ and feed.

## Sales

**Customer** — name + phone required; email/address/note optional. No
payments and balances (spec §10.11, shipped #89).
**Customer** — name + phone required; email/address/note optional, all
**editable in place** after creation via `PUT /customers/{id}` (Version-guarded
optimistic concurrency, shipped #625) — no delete/archive/deactivate, no
duplicate merge. No payments and balances (spec §10.11, shipped #89).

**Sales order lifecycle** — `Draft → Confirmed → Voided` (or `Draft →
Cancelled`): drafts are fully editable (add/edit/remove lines, cancel);
Expand Down
31 changes: 29 additions & 2 deletions src/Cluckwork.Api/Endpoints/Customers/CustomerEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace Cluckwork.Api.Endpoints.Customers;
using Cluckwork.Api.Validation;
using Cluckwork.Application.Features.Customers;
using Cluckwork.Application.Features.Customers.CreateCustomer;
using Cluckwork.Application.Features.Customers.UpdateCustomer;
using Cluckwork.Domain.Sales;
using Cluckwork.Infrastructure.Persistence;
using FluentValidation;
Expand Down Expand Up @@ -32,9 +33,32 @@ public static RouteGroupBuilder MapCustomerEndpoints(this RouteGroupBuilder grou
.WithSummary("Get a single customer by id.")
.RequireAuthorization(AuthPolicies.SalesFlow);

group.MapPut("/{id:guid}", UpdateCustomer)
.WithName("UpdateCustomer")
.WithSummary("Update a customer's details.")
.RequireAuthorization(AuthPolicies.SalesFlow);

return group;
}

private static async Task<IResult> UpdateCustomer(
Guid id, UpdateCustomerRequest request, UpdateCustomerHandler handler,
IValidator<UpdateCustomerCommand> validator, TenantContext tenant, CancellationToken ct)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var command = new UpdateCustomerCommand(
id, request.Version, request.Name, request.Phone, request.Email, request.Address, request.Note);
var validation = await validator.ValidateAsync(command, ct);
if (!validation.IsValid) return ValidationResponse.Problem(validation);
var result = await handler.HandleAsync(command, ct);
return result.IsSuccess ? Results.NoContent()
: result.Error.Code == "Customer.VersionMismatch"
? Results.Problem(result.Error.Description, statusCode: StatusCodes.Status409Conflict, title: result.Error.Code)
: result.Error.Code.EndsWith(".NotFound", StringComparison.Ordinal)
? Results.NotFound()
: Results.Problem(result.Error.Description, statusCode: StatusCodes.Status400BadRequest, title: result.Error.Code);
}

private static async Task<IResult> CreateCustomer(
CreateCustomerRequest request,
CreateCustomerHandler handler,
Expand Down Expand Up @@ -79,11 +103,14 @@ private static async Task<IResult> GetCustomer(
}

private static CustomerResponse ToResponse(Customer c) =>
new(c.Id, c.Name, c.Phone, c.Email, c.Address, c.Note);
new(c.Id, c.Name, c.Phone, c.Email, c.Address, c.Note, c.Version);
}

public sealed record CreateCustomerRequest(
string Name, string Phone, string? Email = null, string? Address = null, string? Note = null);

public sealed record UpdateCustomerRequest(
int? Version, string Name, string Phone, string? Email = null, string? Address = null, string? Note = null);

public sealed record CustomerResponse(
Guid Id, string Name, string Phone, string? Email, string? Address, string? Note);
Guid Id, string Name, string Phone, string? Email, string? Address, string? Note, int Version);
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Cluckwork.Api.Hosting;
using Cluckwork.Application.Features.Accounts;
using Cluckwork.Application.Features.Customers;
using Cluckwork.Application.Features.Customers.CreateCustomer;
using Cluckwork.Application.Features.Customers.UpdateCustomer;
using Cluckwork.Application.Features.DailyEntries;
using Cluckwork.Application.Features.DailyEntries.AdjustDailyEntry;
using Cluckwork.Application.Features.DailyEntries.RecordDailyEntry;
Expand Down Expand Up @@ -160,6 +161,9 @@ private static void AddValidators(IServiceCollection services)
services.AddScoped<
IValidator<CreateCustomerCommand>,
CreateCustomerValidator>();
services.AddScoped<
IValidator<UpdateCustomerCommand>,
UpdateCustomerValidator>();
services.AddScoped<
IValidator<CreateSalesOrderCommand>,
CreateSalesOrderValidator>();
Expand Down Expand Up @@ -270,6 +274,7 @@ private static void AddHandlers(IServiceCollection services)
services.AddScoped<RecordDailyEntryHandler>();
services.AddScoped<SubmitDailyEntryHandler>();
services.AddScoped<CreateCustomerHandler>();
services.AddScoped<UpdateCustomerHandler>();
services.AddScoped<CreateSalesOrderHandler>();
services.AddScoped<AddOrderItemHandler>();
services.AddScoped<CancelSalesOrderHandler>();
Expand Down
2 changes: 2 additions & 0 deletions src/Cluckwork.Application/Common/AuditActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,6 @@ public static class AuditActions
public const string SalesOrderCancel = "SalesOrder.Cancel";
public const string ExpenseCreate = "Expense.Create";
public const string EggGradeCreate = "EggGrade.Create";
public const string CustomerCreate = "Customer.Create";
public const string CustomerUpdate = "Customer.Update";
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Customers.CreateCustomer;

public sealed class CreateCustomerHandler(
ICustomerRepository customers,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result<Guid>> HandleAsync(
CreateCustomerCommand command, Guid accountId, CancellationToken ct)
Expand All @@ -17,6 +18,8 @@ public async Task<Result<Guid>> HandleAsync(
command.Name, command.Phone, command.Email, command.Address, command.Note);

await customers.AddAsync(customer, ct);
await audit.WriteAsync(AuditActions.CustomerCreate, nameof(Customer), customer.Id,
details: new { customer.Name, customer.Phone, customer.Email, customer.Address, customer.Note }, ct: ct);
await unitOfWork.SaveChangesAsync(ct);
return Result.Success(customer.Id);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Cluckwork.Application.Features.Customers.UpdateCustomer;

public sealed record UpdateCustomerCommand(
Guid CustomerId, int? Version, string Name, string Phone,
string? Email = null, string? Address = null, string? Note = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Cluckwork.Application.Features.Customers.UpdateCustomer;

using Cluckwork.Application.Common;
using Cluckwork.Application.Features.Customers;
using Cluckwork.Domain.Common;
using Cluckwork.Domain.Sales;

public sealed class UpdateCustomerHandler(
ICustomerRepository customers,
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(UpdateCustomerCommand command, CancellationToken ct)
{
var customer = await customers.GetByIdAsync(command.CustomerId, ct);
if (customer is null)
return Result.Failure(Error.NotFound(nameof(Customer), command.CustomerId));
if (customer.Version != command.Version)
return Result.Failure(Error.Conflict(
"Customer.VersionMismatch", "This customer was changed since you loaded it — reload and retry."));

var result = customer.Update(command.Name, command.Phone, command.Email, command.Address, command.Note);
if (result.IsFailure) return result;

await audit.WriteAsync(AuditActions.CustomerUpdate, nameof(Customer), customer.Id,
details: new { customer.Name, customer.Phone, customer.Email, customer.Address, customer.Note }, ct: ct);
await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Cluckwork.Application.Features.Customers.UpdateCustomer;

using Cluckwork.Domain.Sales;
using FluentValidation;

public sealed class UpdateCustomerValidator : AbstractValidator<UpdateCustomerCommand>
{
public UpdateCustomerValidator()
{
RuleFor(x => x.CustomerId).NotEmpty().WithErrorCode("Customer.CustomerId.Required");
// An omitted Version binds to null (never the framework default 0),
// so a caller who never actually loaded the row cannot silently pass
// as if it had — see #625 review round 5 (CodeRabbit CR-1).
RuleFor(x => x.Version).NotNull().WithErrorCode("Customer.Version.Required")
.GreaterThanOrEqualTo(0).WithErrorCode("Customer.Version.NonNegative");
RuleFor(x => x.Name).Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("Name is required.")
.WithErrorCode("Customer.Name.Required").MaximumLength(Customer.MaxNameLength)
.WithErrorCode("Customer.Name.MaxLength");
RuleFor(x => x.Phone).Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("Phone is required.")
.WithErrorCode("Customer.Phone.Required").MaximumLength(Customer.MaxPhoneLength)
.WithErrorCode("Customer.Phone.MaxLength");
RuleFor(x => x.Email).EmailAddress().WithErrorCode("Customer.Email.Format")
.MaximumLength(Customer.MaxEmailLength).WithErrorCode("Customer.Email.MaxLength")
.When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.Address).MaximumLength(Customer.MaxAddressLength).WithErrorCode("Customer.Address.MaxLength");
RuleFor(x => x.Note).MaximumLength(Customer.MaxNoteLength).WithErrorCode("Customer.Note.MaxLength");
}
}
19 changes: 19 additions & 0 deletions src/Cluckwork.Domain/Sales/Customer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace Cluckwork.Domain.Sales;

using Cluckwork.Domain.Common;

// MVP customer (issue #10): reference-app shape — name + phone required,
// email/address/note optional. Balances, credit terms, and payments are
// Phase 1.1.
Expand All @@ -16,6 +18,7 @@ public sealed class Customer : AggregateRoot<Guid>
public string? Email { get; private set; }
public string? Address { get; private set; }
public string? Note { get; private set; }
public int Version { get; private set; }

private Customer() { }

Expand All @@ -39,6 +42,22 @@ public static Customer Create(
};
}

public Result Update(string name, string phone, string? email, string? address, string? note)
{
if (string.IsNullOrWhiteSpace(name))
return Result.Failure(Error.Validation("Customer.Name.Required", "Customer name is required."));
if (string.IsNullOrWhiteSpace(phone))
return Result.Failure(Error.Validation("Customer.Phone.Required", "Customer phone is required."));

Name = name.Trim();
Phone = phone.Trim();
Email = Normalize(email);
Address = Normalize(address);
Note = Normalize(note);
Version++;
return Result.Success();
}

private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public void Configure(EntityTypeBuilder<Cluckwork.Domain.Sales.Customer> builder
builder.Property(c => c.Email).HasMaxLength(Cluckwork.Domain.Sales.Customer.MaxEmailLength);
builder.Property(c => c.Address).HasMaxLength(Cluckwork.Domain.Sales.Customer.MaxAddressLength);
builder.Property(c => c.Note).HasMaxLength(Cluckwork.Domain.Sales.Customer.MaxNoteLength);
builder.Property(c => c.Version).IsConcurrencyToken();

builder.HasIndex(c => new { c.AccountId, c.Name });
}
Expand Down
Loading
Loading