diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 79c0e2505..ab6c42927 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -187,6 +187,16 @@ from the account onto each order (JPY has 0 decimals, USD 2 — the snapshot records that too). Totals are recalculated from lines on every mutation, never incrementally patched. +**Audit log (#93)** — append-only record of every corrective, destructive, +or configuration change (the admin-gated action set): actor (user id + +email snapshot), UTC timestamp, action code, entity reference, the reason +where the command carried one, and a small details payload. Domain data, +not telemetry (tech spec): written **in the same transaction** as the +change — a rollback erases the event with the change, and there is no +update or delete surface anywhere. Admin-only viewer at /audit. The +entity-local snapshots (`AdjustedFromJson` etc.) remain — they are the +record's own history; the audit log is the cross-cutting trail. + **Hen-day % (#91)** — eggs collected ÷ hen-days × 100 (spec §19.3). A hen-day is one bird alive for one day; the day's bird count comes from the bird ledger (placements + movements). The production report shows it per diff --git a/src/Cluckwork.Api/Endpoints/Audit/AuditEndpoints.cs b/src/Cluckwork.Api/Endpoints/Audit/AuditEndpoints.cs new file mode 100644 index 000000000..49a0edd57 --- /dev/null +++ b/src/Cluckwork.Api/Endpoints/Audit/AuditEndpoints.cs @@ -0,0 +1,51 @@ +namespace Cluckwork.Api.Endpoints.Audit; + +using Cluckwork.Application.Features.Audit; +using Cluckwork.Domain.Auditing; +using Cluckwork.Infrastructure.Persistence; + +// #93 — read-only audit viewer. There is deliberately NO mutation surface: +// events append inside the transactions that create them and never change. +public static class AuditEndpoints +{ + private const int DefaultPageSize = 100; + private const int MaxPageSize = 500; + + public static RouteGroupBuilder MapAuditEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", ListAuditEvents) + .WithName("ListAuditEvents") + .WithSummary("List audit events newest first (optional action/entity/date filters, paged)."); + + return group; + } + + private static async Task ListAuditEvents( + IAuditEventRepository events, + TenantContext tenant, + CancellationToken ct, + string? action = null, + Guid? entityId = null, + DateOnly? from = null, + DateOnly? to = null, + int? limit = null, + int? offset = null) + { + if (!tenant.IsResolved) return Results.Unauthorized(); + + var take = Math.Clamp(limit ?? DefaultPageSize, 1, MaxPageSize); + var skip = Math.Max(offset ?? 0, 0); + + var list = await events.ListAsync(action, entityId, from, to, take, skip, ct); + return Results.Ok(list.Select(ToResponse)); + } + + private static AuditEventResponse ToResponse(AuditEvent e) => + new(e.Id, e.OccurredAtUtc, e.ActorEmail, e.Action, e.EntityType, e.EntityId, + e.Reason, e.DetailsJson); +} + +public sealed record AuditEventResponse( + Guid Id, DateTimeOffset OccurredAtUtc, string ActorEmail, + string Action, string EntityType, Guid EntityId, + string? Reason, string? DetailsJson); diff --git a/src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs b/src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs index 709badcce..f2ffafb3b 100644 --- a/src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs +++ b/src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs @@ -1,18 +1,26 @@ namespace Cluckwork.Api.Middleware; +using Cluckwork.Infrastructure.Identity; using Cluckwork.Infrastructure.Persistence; // Reads account_id claim from the authenticated principal and populates TenantContext -// before any endpoint handler runs (tech spec §4.2 point 1). +// before any endpoint handler runs (tech spec §4.2 point 1). Also resolves the +// acting user (sub + email) for the audit trail (#93). public sealed class TenantResolutionMiddleware(RequestDelegate next) { - public async Task InvokeAsync(HttpContext context, TenantContext tenant) + public async Task InvokeAsync(HttpContext context, TenantContext tenant, CurrentUserContext user) { if (context.User.Identity?.IsAuthenticated == true) { var claim = context.User.FindFirst("account_id")?.Value; if (Guid.TryParse(claim, out var accountId)) tenant.Resolve(accountId); + + // MapInboundClaims is off: the raw JWT claim names survive. + var sub = context.User.FindFirst("sub")?.Value; + var email = context.User.FindFirst("email")?.Value; + if (Guid.TryParse(sub, out var userId)) + user.Resolve(userId, email ?? ""); } await next(context); diff --git a/src/Cluckwork.Api/Program.cs b/src/Cluckwork.Api/Program.cs index 5f3a078f4..665db6bbf 100644 --- a/src/Cluckwork.Api/Program.cs +++ b/src/Cluckwork.Api/Program.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using Cluckwork.Api; using Cluckwork.Api.Endpoints.Accounts; +using Cluckwork.Api.Endpoints.Audit; using Cluckwork.Api.Endpoints.Auth; using Cluckwork.Api.Endpoints.Customers; using Cluckwork.Api.Endpoints.DailyEntries; @@ -92,6 +93,12 @@ // --- Multi-tenancy (scoped per request) --- builder.Services.AddScoped(); +// The acting user, resolved beside the tenant (#93 audit trail). +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + sp.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- EF Core --- var dbProvider = builder.Configuration["Database:Provider"] ?? "Postgres"; @@ -396,6 +403,12 @@ .RequireAuthorization() .MapReportEndpoints(); +// Audit trail (#93): read-only, admin-only. +app.MapGroup("/api/v1/audit") + .WithTags("Audit") + .RequireAuthorization(AuthPolicies.AdminOnly) + .MapAuditEndpoints(); + app.MapGroup("/api/v1/users") .WithTags("Users") .RequireAuthorization(AuthPolicies.AdminOnly) diff --git a/src/Cluckwork.Application/Common/IAuditWriter.cs b/src/Cluckwork.Application/Common/IAuditWriter.cs new file mode 100644 index 000000000..1f2dec183 --- /dev/null +++ b/src/Cluckwork.Application/Common/IAuditWriter.cs @@ -0,0 +1,12 @@ +namespace Cluckwork.Application.Common; + +// Appends an audit event to the CURRENT unit of work (#93): the row commits +// or rolls back with the change it records — callers never save through this +// port, they call it just before their own SaveChanges/transaction commit. +public interface IAuditWriter +{ + Task WriteAsync( + string action, string entityType, Guid entityId, + string? reason = null, object? details = null, + CancellationToken ct = default); +} diff --git a/src/Cluckwork.Application/Common/ICurrentUser.cs b/src/Cluckwork.Application/Common/ICurrentUser.cs new file mode 100644 index 000000000..750e89610 --- /dev/null +++ b/src/Cluckwork.Application/Common/ICurrentUser.cs @@ -0,0 +1,10 @@ +namespace Cluckwork.Application.Common; + +// The authenticated actor of the current request (#93) — resolved from the +// JWT beside TenantContext. Handlers use it only through IAuditWriter. +public interface ICurrentUser +{ + bool IsResolved { get; } + Guid UserId { get; } + string Email { get; } +} diff --git a/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs b/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs new file mode 100644 index 000000000..008e0f4db --- /dev/null +++ b/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs @@ -0,0 +1,12 @@ +namespace Cluckwork.Application.Features.Audit; + +using Cluckwork.Domain.Auditing; + +// Read side of the audit trail (#93). Append happens through IAuditWriter; +// there is intentionally no update/remove surface anywhere. +public interface IAuditEventRepository +{ + Task> ListAsync( + string? action, Guid? entityId, DateOnly? from, DateOnly? to, + int limit, int offset, CancellationToken ct = default); +} diff --git a/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs index b213fafcb..653eef42f 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs @@ -21,7 +21,8 @@ public sealed class AdjustDailyEntryHandler( IEggGradeRepository eggGrades, IBirdMovementRepository birdMovements, IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task> HandleAsync( AdjustDailyEntryCommand command, Guid accountId, CancellationToken ct) @@ -168,6 +169,10 @@ await birdMovements.AddAsync(BirdMovement.Create( dailyEntryId: entry.Id), transactionCt); } + // Same transaction as the change (#93): rolls back with it. + await audit.WriteAsync("DailyEntry.Adjust", nameof(DailyEntry), entry.Id, + command.Reason, ct: transactionCt); + return true; }, ct); diff --git a/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs index 4413aeb74..6bf0f90ff 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs @@ -20,7 +20,8 @@ public sealed class VoidDailyEntryHandler( IEggGradeRepository eggGrades, IBirdMovementRepository birdMovements, IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task> HandleAsync( VoidDailyEntryCommand command, Guid accountId, CancellationToken ct) @@ -96,6 +97,10 @@ await birdMovements.AddAsync(BirdMovement.Create( dailyEntryId: entry.Id), transactionCt); } + // Same transaction as the change (#93): rolls back with it. + await audit.WriteAsync("DailyEntry.Void", nameof(DailyEntry), entry.Id, + command.Reason, ct: transactionCt); + return true; }, ct); diff --git a/src/Cluckwork.Application/Features/EggGrades/SetEggGradeActive/SetEggGradeActiveHandler.cs b/src/Cluckwork.Application/Features/EggGrades/SetEggGradeActive/SetEggGradeActiveHandler.cs index e338595db..b2570e21c 100644 --- a/src/Cluckwork.Application/Features/EggGrades/SetEggGradeActive/SetEggGradeActiveHandler.cs +++ b/src/Cluckwork.Application/Features/EggGrades/SetEggGradeActive/SetEggGradeActiveHandler.cs @@ -9,7 +9,8 @@ namespace Cluckwork.Application.Features.EggGrades.SetEggGradeActive; // keep resolving the grade's name. public sealed class SetEggGradeActiveHandler( IEggGradeRepository grades, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(Guid eggGradeId, bool active, CancellationToken ct) { @@ -20,6 +21,10 @@ public async Task HandleAsync(Guid eggGradeId, bool active, Cancellation var result = active ? grade.Activate() : grade.Deactivate(); if (result.IsFailure) return result; + // Same SaveChanges as the change (#93). + await audit.WriteAsync(active ? "EggGrade.Activate" : "EggGrade.Deactivate", + "EggGrade", grade.Id, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/EggGrades/UpdateEggGrade/UpdateEggGradeHandler.cs b/src/Cluckwork.Application/Features/EggGrades/UpdateEggGrade/UpdateEggGradeHandler.cs index b5267b32b..84e3b475e 100644 --- a/src/Cluckwork.Application/Features/EggGrades/UpdateEggGrade/UpdateEggGradeHandler.cs +++ b/src/Cluckwork.Application/Features/EggGrades/UpdateEggGrade/UpdateEggGradeHandler.cs @@ -5,7 +5,8 @@ namespace Cluckwork.Application.Features.EggGrades.UpdateEggGrade; public sealed class UpdateEggGradeHandler( IEggGradeRepository grades, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(UpdateEggGradeCommand command, CancellationToken ct) { @@ -20,6 +21,10 @@ public async Task HandleAsync(UpdateEggGradeCommand command, Cancellatio var result = grade.Update(command.Name, command.SortOrder, command.IsSaleable); if (result.IsFailure) return result; + // Same SaveChanges as the change (#93). + await audit.WriteAsync("EggGrade.Update", "EggGrade", grade.Id, + reason: null, details: new { grade.Name, grade.SortOrder, grade.IsSaleable }, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Expenses/AdjustExpense/AdjustExpenseHandler.cs b/src/Cluckwork.Application/Features/Expenses/AdjustExpense/AdjustExpenseHandler.cs index 4eae8d6c4..a68bed865 100644 --- a/src/Cluckwork.Application/Features/Expenses/AdjustExpense/AdjustExpenseHandler.cs +++ b/src/Cluckwork.Application/Features/Expenses/AdjustExpense/AdjustExpenseHandler.cs @@ -8,7 +8,8 @@ public sealed class AdjustExpenseHandler( IExpenseRepository expenses, IExpenseCategoryRepository categories, IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(AdjustExpenseCommand command, CancellationToken ct) { @@ -43,11 +44,17 @@ public async Task HandleAsync(AdjustExpenseCommand command, Cancellation "Expense.UnknownFlock", "The flock does not exist on this farm.")); } + var previousAmount = expense.AmountMinorUnits; var result = expense.Adjust( command.ExpenseCategoryId, command.Date, command.Description, command.AmountMinorUnits, command.FlockId, command.Note); if (result.IsFailure) return result; + // Same SaveChanges as the change (#93): commits or fails with it. + await audit.WriteAsync("Expense.Adjust", nameof(Cluckwork.Domain.Expenses.Expense), expense.Id, + reason: null, + new { previousAmountMinorUnits = previousAmount, newAmountMinorUnits = expense.AmountMinorUnits }, ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Expenses/UpdateExpenseCategory/UpdateExpenseCategoryHandler.cs b/src/Cluckwork.Application/Features/Expenses/UpdateExpenseCategory/UpdateExpenseCategoryHandler.cs index f85da05ec..94aaea130 100644 --- a/src/Cluckwork.Application/Features/Expenses/UpdateExpenseCategory/UpdateExpenseCategoryHandler.cs +++ b/src/Cluckwork.Application/Features/Expenses/UpdateExpenseCategory/UpdateExpenseCategoryHandler.cs @@ -5,7 +5,8 @@ namespace Cluckwork.Application.Features.Expenses.UpdateExpenseCategory; public sealed class UpdateExpenseCategoryHandler( IExpenseCategoryRepository categories, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(UpdateExpenseCategoryCommand command, CancellationToken ct) { @@ -29,6 +30,11 @@ public async Task HandleAsync(UpdateExpenseCategoryCommand command, Canc if (flip.IsFailure) return flip; } + // Same SaveChanges as the change (#93). + await audit.WriteAsync("ExpenseCategory.Update", + nameof(Cluckwork.Domain.Expenses.ExpenseCategory), category.Id, + reason: null, new { category.Name, category.Active }, ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Flocks/ArchiveFlock/ArchiveFlockHandler.cs b/src/Cluckwork.Application/Features/Flocks/ArchiveFlock/ArchiveFlockHandler.cs index ce70c32db..7c295f106 100644 --- a/src/Cluckwork.Application/Features/Flocks/ArchiveFlock/ArchiveFlockHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/ArchiveFlock/ArchiveFlockHandler.cs @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.ArchiveFlock; public sealed class ArchiveFlockHandler( IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(Guid flockId, CancellationToken ct) { @@ -21,6 +22,10 @@ public async Task HandleAsync(Guid flockId, CancellationToken ct) return result; flocks.Update(flock); + // Same SaveChanges as the change (#93). + await audit.WriteAsync("Flock.Archive", "Flock", flock.Id, + reason: null, details: null, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Flocks/DepleteFlock/DepleteFlockHandler.cs b/src/Cluckwork.Application/Features/Flocks/DepleteFlock/DepleteFlockHandler.cs index 26b868437..4de151fd8 100644 --- a/src/Cluckwork.Application/Features/Flocks/DepleteFlock/DepleteFlockHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/DepleteFlock/DepleteFlockHandler.cs @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.DepleteFlock; public sealed class DepleteFlockHandler( IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(Guid flockId, CancellationToken ct) { @@ -21,6 +22,10 @@ public async Task HandleAsync(Guid flockId, CancellationToken ct) return result; flocks.Update(flock); + // Same SaveChanges as the change (#93). + await audit.WriteAsync("Flock.Deplete", "Flock", flock.Id, + reason: null, details: null, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Flocks/ReactivateFlock/ReactivateFlockHandler.cs b/src/Cluckwork.Application/Features/Flocks/ReactivateFlock/ReactivateFlockHandler.cs index cb3701222..a4048dc59 100644 --- a/src/Cluckwork.Application/Features/Flocks/ReactivateFlock/ReactivateFlockHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/ReactivateFlock/ReactivateFlockHandler.cs @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.ReactivateFlock; public sealed class ReactivateFlockHandler( IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(Guid flockId, CancellationToken ct) { @@ -20,6 +21,10 @@ public async Task HandleAsync(Guid flockId, CancellationToken ct) return result; flocks.Update(flock); + // Same SaveChanges as the change (#93). + await audit.WriteAsync("Flock.Reactivate", "Flock", flock.Id, + reason: null, details: null, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs index 1d974d018..af0e01add 100644 --- a/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs @@ -8,7 +8,8 @@ namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement; public sealed class RecordBirdMovementHandler( IFlockRepository flocks, IBirdMovementRepository movements, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task> HandleAsync( RecordBirdMovementCommand command, Guid accountId, CancellationToken ct) @@ -30,6 +31,10 @@ public async Task> HandleAsync( command.Date, type, command.Quantity, command.Note); await movements.AddAsync(movement, ct); + // Same SaveChanges as the change (#93). + await audit.WriteAsync("Flock.BirdMovement", "Flock", flock.Id, + reason: command.Note, details: new { command.Type, command.Quantity }, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(movement.Id); } diff --git a/src/Cluckwork.Application/Features/Flocks/UpdateFlock/UpdateFlockHandler.cs b/src/Cluckwork.Application/Features/Flocks/UpdateFlock/UpdateFlockHandler.cs index 17b32b46f..edc21fb08 100644 --- a/src/Cluckwork.Application/Features/Flocks/UpdateFlock/UpdateFlockHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/UpdateFlock/UpdateFlockHandler.cs @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.UpdateFlock; public sealed class UpdateFlockHandler( IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(UpdateFlockCommand command, CancellationToken ct) { @@ -21,6 +22,10 @@ public async Task HandleAsync(UpdateFlockCommand command, CancellationTo return result; flocks.Update(flock); + // Same SaveChanges as the change (#93). + await audit.WriteAsync("Flock.Update", "Flock", flock.Id, + reason: null, details: null, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs b/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs index 91cf04bc4..ec82dbc19 100644 --- a/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs @@ -13,7 +13,8 @@ public sealed class RecordAdjustmentHandler( IInventoryLotRepository lots, IInventoryMovementRepository movements, IUnitOfWork unitOfWork, - IClock clock) + IClock clock, + IAuditWriter audit) { public async Task> HandleAsync( RecordAdjustmentCommand command, Guid accountId, CancellationToken ct) @@ -63,6 +64,10 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => flockId: null, note: command.Reason); await movements.AddAsync(movement, transactionCt); + // Same transaction as the change (#93). + await audit.WriteAsync("InventoryItem.Adjust", nameof(InventoryItem), item.Id, + command.Reason, new { command.Type, command.QuantityDelta }, transactionCt); + outcome = Result.Success(movement.Id); return true; }, ct); diff --git a/src/Cluckwork.Application/Features/Inventory/UpdateWaterUsage/UpdateWaterUsageHandler.cs b/src/Cluckwork.Application/Features/Inventory/UpdateWaterUsage/UpdateWaterUsageHandler.cs index 06634eff9..952f0ed58 100644 --- a/src/Cluckwork.Application/Features/Inventory/UpdateWaterUsage/UpdateWaterUsageHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/UpdateWaterUsage/UpdateWaterUsageHandler.cs @@ -13,7 +13,8 @@ namespace Cluckwork.Application.Features.Inventory.UpdateWaterUsage; public sealed class UpdateWaterUsageHandler( IWaterUsageRepository waterUsages, IFlockRepository flocks, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync( UpdateWaterUsageCommand command, CancellationToken ct) @@ -45,6 +46,10 @@ public async Task HandleAsync( if (result.IsFailure) return result; + // Same SaveChanges as the change (#93). + await audit.WriteAsync("WaterUsage.Correct", nameof(WaterUsage), usage.Id, + reason: null, new { usage.Quantity, usage.Unit }, ct); + // The EF Version token still backstops the microsecond window between // the check above and this save. await unitOfWork.SaveChangesAsync(ct); diff --git a/src/Cluckwork.Application/Features/Sales/VoidPayment/VoidPaymentHandler.cs b/src/Cluckwork.Application/Features/Sales/VoidPayment/VoidPaymentHandler.cs index 8b4fef3b3..4d80f2dda 100644 --- a/src/Cluckwork.Application/Features/Sales/VoidPayment/VoidPaymentHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/VoidPayment/VoidPaymentHandler.cs @@ -8,7 +8,8 @@ namespace Cluckwork.Application.Features.Sales.VoidPayment; // the row stays for the ledger, and the order's outstanding grows back. public sealed class VoidPaymentHandler( IPaymentRepository payments, - IUnitOfWork unitOfWork) + IUnitOfWork unitOfWork, + IAuditWriter audit) { public async Task HandleAsync(VoidPaymentCommand command, CancellationToken ct) { @@ -26,6 +27,11 @@ public async Task HandleAsync(VoidPaymentCommand command, CancellationTo var result = payment.Void(command.Reason); if (result.IsFailure) return result; + // Same SaveChanges as the change (#93): commits or fails with it. + await audit.WriteAsync("Payment.Void", nameof(Payment), payment.Id, + command.Reason, + new { payment.SalesOrderId, payment.AmountMinorUnits, payment.CurrencyCode }, ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs index 5d02a0494..07d01e86b 100644 --- a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs @@ -20,7 +20,8 @@ public sealed class VoidSaleHandler( IEggLotRepository eggLots, IPaymentRepository payments, IUnitOfWork unitOfWork, - IClock clock) + IClock clock, + IAuditWriter audit) { public async Task> HandleAsync( VoidSaleCommand command, Guid accountId, CancellationToken ct) @@ -108,6 +109,10 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => foreach (var row in rows) row.MarkReleased(releasedAt); + // Same transaction as the change (#93): rolls back with it. + await audit.WriteAsync("SalesOrder.Void", nameof(SalesOrder), order.Id, + command.Reason, ct: transactionCt); + outcome = Result.Success(new VoidSaleResponse(order.Id, order.Status.ToString())); return true; }, ct); diff --git a/src/Cluckwork.Domain/Auditing/AuditEvent.cs b/src/Cluckwork.Domain/Auditing/AuditEvent.cs new file mode 100644 index 000000000..2cb867139 --- /dev/null +++ b/src/Cluckwork.Domain/Auditing/AuditEvent.cs @@ -0,0 +1,55 @@ +namespace Cluckwork.Domain.Auditing; + +// Append-only record of a critical change (#93). Domain data, not telemetry +// (tech spec): written by the handler IN THE SAME TRANSACTION as the change it +// records — a rolled-back change leaves no event. There is deliberately no +// mutation surface: no Version, no update, no delete. +public sealed class AuditEvent : AggregateRoot +{ + public const int MaxActionLength = 100; + public const int MaxEntityTypeLength = 100; + public const int MaxActorEmailLength = 256; + public const int MaxReasonLength = 500; + + public DateTimeOffset OccurredAtUtc { get; private set; } + public Guid ActorUserId { get; private set; } + // Snapshotted: user rows can be renamed/removed later; the trail must not. + public string ActorEmail { get; private set; } = string.Empty; + public string Action { get; private set; } = string.Empty; + public string EntityType { get; private set; } = string.Empty; + public Guid EntityId { get; private set; } + public string? Reason { get; private set; } + // Plain text, not jsonb — provider-portability rule; only the API reads it. + public string? DetailsJson { get; private set; } + + private AuditEvent() { } + + public static AuditEvent Create( + Guid id, Guid accountId, DateTimeOffset occurredAtUtc, + Guid actorUserId, string actorEmail, + string action, string entityType, Guid entityId, + string? reason = null, string? detailsJson = null) + { + if (string.IsNullOrWhiteSpace(action) || action.Length > MaxActionLength) + throw new ArgumentException("A valid action code is required.", nameof(action)); + if (string.IsNullOrWhiteSpace(entityType) || entityType.Length > MaxEntityTypeLength) + throw new ArgumentException("A valid entity type is required.", nameof(entityType)); + + return new AuditEvent + { + Id = id, AccountId = accountId, + OccurredAtUtc = occurredAtUtc, + ActorUserId = actorUserId, + ActorEmail = actorEmail.Length > MaxActorEmailLength + ? actorEmail[..MaxActorEmailLength] : actorEmail, + Action = action, + EntityType = entityType, + EntityId = entityId, + Reason = string.IsNullOrWhiteSpace(reason) + ? null + : reason.Trim().Length > MaxReasonLength + ? reason.Trim()[..MaxReasonLength] : reason.Trim(), + DetailsJson = detailsJson + }; + } +} diff --git a/src/Cluckwork.Infrastructure/Identity/CurrentUserContext.cs b/src/Cluckwork.Infrastructure/Identity/CurrentUserContext.cs new file mode 100644 index 000000000..33f4431d1 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Identity/CurrentUserContext.cs @@ -0,0 +1,19 @@ +namespace Cluckwork.Infrastructure.Identity; + +using Cluckwork.Application.Common; + +// Scoped per request, resolved by TenantResolutionMiddleware from the JWT's +// sub + email claims (TenantContext pattern). +public sealed class CurrentUserContext : ICurrentUser +{ + public bool IsResolved { get; private set; } + public Guid UserId { get; private set; } + public string Email { get; private set; } = string.Empty; + + public void Resolve(Guid userId, string email) + { + UserId = userId; + Email = email; + IsResolved = true; + } +} diff --git a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs index 7c1a70b14..cf1175ea6 100644 --- a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs +++ b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs @@ -14,7 +14,8 @@ public sealed class IdentityProvider( IJwtTokenService jwtTokenService, AppDbContext db, IOptions jwtOptions, - TimeProvider timeProvider) : IIdentityProvider + TimeProvider timeProvider, + Cluckwork.Application.Common.IAuditWriter audit) : IIdentityProvider { // Pre-computed V3 PBKDF2 hash used to equalize login timing when the email is not found, // preventing user enumeration via response-time analysis. @@ -116,6 +117,12 @@ public async Task> CreateUserAsync( return Result.Failure(Error.Validation("Users.CreateFailed", Describe(addedToRole))); } + // Same transaction as the creation (#93): the event needs its own + // SaveChanges because UserManager flushed its writes already. + await audit.WriteAsync("User.Create", "User", user.Id, + reason: null, details: new { email, role = isAdmin ? "Admin" : "Worker" }, ct: ct); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); return Result.Success(user.Id); } diff --git a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs index 5e91d9b23..3f768db0d 100644 --- a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs +++ b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs @@ -1,6 +1,7 @@ namespace Cluckwork.Infrastructure.Persistence; using Cluckwork.Domain.Accounts; +using Cluckwork.Domain.Auditing; using Cluckwork.Domain.Eggs; using Cluckwork.Domain.Expenses; using Cluckwork.Domain.Flocks; @@ -33,6 +34,7 @@ public class AppDbContext(DbContextOptions options, TenantContext public DbSet WaterUsages => Set(); public DbSet ExpenseCategories => Set(); public DbSet Expenses => Set(); + public DbSet AuditEvents => Set(); public DbSet IdempotencyRecords => Set(); public DbSet DurableJobs => Set(); public DbSet RefreshTokens => Set(); @@ -66,5 +68,6 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); + builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); } } diff --git a/src/Cluckwork.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs b/src/Cluckwork.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs new file mode 100644 index 000000000..720f54c78 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cs @@ -0,0 +1,27 @@ +namespace Cluckwork.Infrastructure.Persistence.Configurations; + +using Cluckwork.Domain.Auditing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public sealed class AuditEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.AccountId).IsRequired(); + builder.Property(e => e.OccurredAtUtc).IsRequired(); + builder.Property(e => e.ActorUserId).IsRequired(); + builder.Property(e => e.ActorEmail).HasMaxLength(AuditEvent.MaxActorEmailLength).IsRequired(); + builder.Property(e => e.Action).HasMaxLength(AuditEvent.MaxActionLength).IsRequired(); + builder.Property(e => e.EntityType).HasMaxLength(AuditEvent.MaxEntityTypeLength).IsRequired(); + builder.Property(e => e.EntityId).IsRequired(); + builder.Property(e => e.Reason).HasMaxLength(AuditEvent.MaxReasonLength); + // Plain text, not jsonb — provider-portability rule. + builder.Property(e => e.DetailsJson); + + // Viewer: newest-first per tenant; entity drill-down. + builder.HasIndex(e => new { e.AccountId, e.OccurredAtUtc }); + builder.HasIndex(e => new { e.AccountId, e.EntityId }); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.Designer.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.Designer.cs new file mode 100644 index 000000000..b4b04a9bb --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.Designer.cs @@ -0,0 +1,1651 @@ +// +using System; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260719233647_AddAuditEvents")] + partial class AddAuditEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DefaultCurrencyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultCurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Auditing.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EntityId"); + + b.HasIndex("AccountId", "OccurredAtUtc"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AdjustReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AdjustedFromJson") + .HasColumnType("text"); + + b.Property("CrackedEggs") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DirtyEggs") + .HasColumnType("integer"); + + b.Property("DiscardedEggs") + .HasColumnType("integer"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("LockedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MortalityCount") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TotalEggs") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") + .IsUnique() + .HasDatabaseName("IX_DailyEntries_NaturalKey") + .HasFilter("\"Status\" <> 'Voided'"); + + b.ToTable("DailyEntries"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("DailyEntryId", "EggGradeId") + .IsUnique(); + + b.ToTable("DailyEntryGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("GradeType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsSaleable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EggGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("ProductionDate") + .HasColumnType("date"); + + b.Property("QuantityAvailable") + .HasColumnType("integer"); + + b.Property("QuantityProduced") + .HasColumnType("integer"); + + b.Property("RestrictedUntil") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId") + .HasDatabaseName("IX_EggLots_DailyEntryId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") + .HasDatabaseName("IX_EggLots_Allocation"); + + b.ToTable("EggLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpenseCategoryId") + .HasColumnType("uuid"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseCategoryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("AccountId", "ExpenseCategoryId"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.ExpenseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ExpenseCategories"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ArchivedOn") + .HasColumnType("date"); + + b.Property("Breed") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DepletedOn") + .HasColumnType("date"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("InitialCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlacementDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId"); + + b.ToTable("Flocks"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.FeedUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId", "Date"); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("FeedUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId"); + + b.ToTable("InventoryItems"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("LotNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("QuantityAvailable") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("QuantityReceived") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReceivedDate") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId", "ReceivedDate"); + + b.ToTable("InventoryLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("InventoryLotId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuantityDelta") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("FlockId"); + + b.HasIndex("InventoryLotId"); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("InventoryMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("MeterEnd") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("MeterStart") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId", "Date"); + + b.ToTable("WaterUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Name"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PaymentDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Voided") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("AccountId", "CustomerId"); + + b.HasIndex("AccountId", "SalesOrderId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("OrderDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("AccountId", "ReferenceNumber") + .IsUnique(); + + b.ToTable("SalesOrders"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderAllocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggLotId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("ReleasedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggLotId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("SalesOrderItemId"); + + b.ToTable("SalesOrderAllocations"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("SalesOrderId"); + + b.ToTable("SalesOrderItems"); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Jobs.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "RunAfter"); + + b.ToTable("durable_jobs", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Persistence.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EndpointHash", "IdempotencyKeyHash") + .IsUnique(); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany("Grades") + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.Expense", b => + { + b.HasOne("Cluckwork.Domain.Expenses.ExpenseCategory", null) + .WithMany() + .HasForeignKey("ExpenseCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.FeedUsage", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "EstimatedCost", b1 => + { + b1.Property("FeedUsageId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("EstimatedCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("EstimatedCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("EstimatedCostMinorUnits"); + + b1.HasKey("FeedUsageId"); + + b1.ToTable("FeedUsages"); + + b1.WithOwner() + .HasForeignKey("FeedUsageId"); + }); + + b.Navigation("EstimatedCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.OwnsOne("Cluckwork.Domain.Common.Money", "DefaultUnitCost", b1 => + { + b1.Property("InventoryItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("DefaultCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("DefaultCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("DefaultCostMinorUnits"); + + b1.HasKey("InventoryItemId"); + + b1.ToTable("InventoryItems"); + + b1.WithOwner() + .HasForeignKey("InventoryItemId"); + }); + + b.Navigation("DefaultUnitCost"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitCost", b1 => + { + b1.Property("InventoryLotId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitCostMinorUnits"); + + b1.HasKey("InventoryLotId"); + + b1.ToTable("InventoryLots"); + + b1.WithOwner() + .HasForeignKey("InventoryLotId"); + }); + + b.Navigation("UnitCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryLot", null) + .WithMany() + .HasForeignKey("InventoryLotId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Payment", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "TotalAmount", b1 => + { + b1.Property("SalesOrderId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("TotalCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("TotalCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("TotalMinorUnits"); + + b1.HasKey("SalesOrderId"); + + b1.ToTable("SalesOrders"); + + b1.WithOwner() + .HasForeignKey("SalesOrderId"); + }); + + b.Navigation("TotalAmount") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderAllocation", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggLot", null) + .WithMany() + .HasForeignKey("EggLotId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrderItem", null) + .WithMany() + .HasForeignKey("SalesOrderItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany("Items") + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitPrice", b1 => + { + b1.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitPriceCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitPriceCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitPriceMinorUnits"); + + b1.HasKey("SalesOrderItemId"); + + b1.ToTable("SalesOrderItems"); + + b1.WithOwner() + .HasForeignKey("SalesOrderItemId"); + }); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.cs new file mode 100644 index 000000000..8ddb799fc --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260719233647_AddAuditEvents.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAuditEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ActorUserId = table.Column(type: "uuid", nullable: false), + ActorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EntityType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EntityId = table.Column(type: "uuid", nullable: false), + Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + DetailsJson = table.Column(type: "text", nullable: true), + AccountId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEvents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_AccountId_EntityId", + table: "AuditEvents", + columns: new[] { "AccountId", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_AccountId_OccurredAtUtc", + table: "AuditEvents", + columns: new[] { "AccountId", "OccurredAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditEvents"); + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 0bbc055b9..1ccadf77a 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -54,6 +54,55 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Accounts"); }); + modelBuilder.Entity("Cluckwork.Domain.Auditing.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EntityId"); + + b.HasIndex("AccountId", "OccurredAtUtc"); + + b.ToTable("AuditEvents"); + }); + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => { b.Property("Id") diff --git a/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs b/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs new file mode 100644 index 000000000..817f2535e --- /dev/null +++ b/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs @@ -0,0 +1,37 @@ +namespace Cluckwork.Infrastructure.Repositories; + +using Cluckwork.Application.Features.Audit; +using Cluckwork.Domain.Auditing; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +public sealed class AuditEventRepository(AppDbContext db) : IAuditEventRepository +{ + public async Task> ListAsync( + string? action, Guid? entityId, DateOnly? from, DateOnly? to, + int limit, int offset, CancellationToken ct = default) + { + // Date filters are inclusive calendar days over the UTC timestamp. + var fromUtc = from is { } f + ? new DateTimeOffset(f.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) + : (DateTimeOffset?)null; + // MaxValue guard: AddDays(1) on 9999-12-31 throws (codex review of #94). + var toUtc = to is { } t + ? t == DateOnly.MaxValue + ? DateTimeOffset.MaxValue + : new DateTimeOffset(t.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) + : (DateTimeOffset?)null; + + return await db.AuditEvents + .AsNoTracking() + .Where(e => (action == null || e.Action == action) + && (entityId == null || e.EntityId == entityId) + && (fromUtc == null || e.OccurredAtUtc >= fromUtc) + && (toUtc == null || e.OccurredAtUtc < toUtc)) + // Id tiebreaker: same-instant events must page stably. + .OrderByDescending(e => e.OccurredAtUtc).ThenByDescending(e => e.Id) + .Skip(offset) + .Take(limit) + .ToListAsync(ct); + } +} diff --git a/src/Cluckwork.Infrastructure/Repositories/AuditWriter.cs b/src/Cluckwork.Infrastructure/Repositories/AuditWriter.cs new file mode 100644 index 000000000..7243b2c4a --- /dev/null +++ b/src/Cluckwork.Infrastructure/Repositories/AuditWriter.cs @@ -0,0 +1,42 @@ +namespace Cluckwork.Infrastructure.Repositories; + +using System.Text.Json; +using Cluckwork.Application.Common; +using Cluckwork.Domain.Auditing; +using Cluckwork.Infrastructure.Persistence; + +// Appends to the handler's OWN unit of work — no SaveChanges here, so the +// event commits or rolls back with the change it records (tech spec: audit is +// domain data, same transaction). +public sealed class AuditWriter( + AppDbContext db, + TenantContext tenant, + ICurrentUser user, + IClock clock) : IAuditWriter +{ + private static readonly JsonSerializerOptions JsonOptions = + new(JsonSerializerDefaults.Web); + + public async Task WriteAsync( + string action, string entityType, Guid entityId, + string? reason = null, object? details = null, + CancellationToken ct = default) + { + // Every capture point sits behind an authenticated, tenant-resolved + // endpoint today; this guard keeps a future non-HTTP caller (job, + // seeder) from stamping AccountId = Guid.Empty rows that no tenant + // filter would ever surface (background security review of #94). + if (!tenant.IsResolved) + throw new InvalidOperationException( + "Audit events require a resolved tenant — do not call IAuditWriter outside a tenant-scoped request."); + + var actorId = user.IsResolved ? user.UserId : Guid.Empty; + var actorEmail = user.IsResolved ? user.Email : "(unresolved)"; + + await db.AuditEvents.AddAsync(AuditEvent.Create( + Guid.NewGuid(), tenant.AccountId, clock.UtcNow, + actorId, actorEmail, action, entityType, entityId, + reason, + details is null ? null : JsonSerializer.Serialize(details, JsonOptions)), ct); + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs b/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs new file mode 100644 index 000000000..ab60f21b4 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs @@ -0,0 +1,264 @@ +namespace Cluckwork.Api.IntegrationTests; + +using System.Net; +using Cluckwork.Api.IntegrationTests.Infrastructure; + +// #93 — the audit trail is domain data: written in the same transaction as +// the change (a failed action leaves nothing), actor captured from the JWT, +// viewer admin-only. +[Collection(IntegrationCollection.Name)] +public sealed class AuditTests(CluckworkWebApplicationFactory factory) +{ + private sealed record Created(Guid Id); + private sealed record EntryDto(Guid Id, int Version); + private sealed record AuditRow( + Guid Id, DateTimeOffset OccurredAtUtc, string ActorEmail, + string Action, string EntityType, Guid EntityId, + string? Reason, string? DetailsJson); + + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + private async Task<(HttpClient Client, string Email, Guid AccountId, Guid FarmId, Guid FlockId, Guid GradeId)> + SetupAsync() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var farmId = Guid.NewGuid(); + var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large"); + var flockId = await factory.SeedFlockAsync(accountId, farmId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + return (client, email, accountId, farmId, flockId, grades["Large"]); + } + + [Fact] + public async Task Adjust_WritesEvent_WithActorAndReason_FailedAdjustWritesNothing() + { + var (client, email, _, farmId, flockId, gradeId) = await SetupAsync(); + + var record = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = Guid.NewGuid(), flockId, date = Today, + totalEggs = 100, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = gradeId, quantity = 90 } } + }); + var entryId = (await record.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + var version = (await client.GetFromJsonAsync($"/api/v1/daily-entries/{entryId}"))!.Version; + + // A FAILED adjust (stale version → 409) must leave no event. + var failed = await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{entryId}/adjust", Guid.NewGuid().ToString(), new + { + version = version + 7, totalEggs = 90, crackedEggs = 0, dirtyEggs = 0, + discardedEggs = 0, mortalityCount = 0, reason = "stale" + }); + Assert.Equal(HttpStatusCode.Conflict, failed.StatusCode); + + var afterFailure = await client.GetFromJsonAsync>( + $"/api/v1/audit?entityId={entryId}"); + Assert.Empty(afterFailure!); + + // The successful adjust writes exactly one, with actor + reason. + var ok = await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{entryId}/adjust", Guid.NewGuid().ToString(), new + { + version, totalEggs = 90, crackedEggs = 0, dirtyEggs = 0, + discardedEggs = 0, mortalityCount = 0, reason = "recount at pickup" + }); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + + var rows = await client.GetFromJsonAsync>($"/api/v1/audit?entityId={entryId}"); + var row = Assert.Single(rows!); + Assert.Equal("DailyEntry.Adjust", row.Action); + Assert.Equal("DailyEntry", row.EntityType); + Assert.Equal(email, row.ActorEmail); + Assert.Equal("recount at pickup", row.Reason); + } + + [Fact] + public async Task CriticalActions_LandInTheTrail_FilteredByAction() + { + var (client, _, _, farmId, flockId, _) = await SetupAsync(); + + // Flock edit + cull + deplete → three distinct actions. + var flockGet = await client.GetAsync($"/api/v1/flocks/{flockId}"); + Assert.Equal(HttpStatusCode.OK, flockGet.StatusCode); + var putBody = new + { + name = "Audited flock", breed = "Test Breed", + placementDate = Today.AddDays(-30), initialCount = 100 + }; + var put = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/flocks/{flockId}") + { Content = JsonContent.Create(putBody) }; + put.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(put)).StatusCode); + + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { type = "Cull", quantity = 3, date = Today, note = "culled sick birds" }); + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/deplete", Guid.NewGuid().ToString()); + + var updates = await client.GetFromJsonAsync>("/api/v1/audit?action=Flock.Update"); + Assert.Contains(updates!, r => r.EntityId == flockId); + + var movements = await client.GetFromJsonAsync>("/api/v1/audit?action=Flock.BirdMovement"); + var cull = Assert.Single(movements!, r => r.EntityId == flockId); + Assert.Equal("culled sick birds", cull.Reason); + Assert.Contains("Cull", cull.DetailsJson); + + var depletions = await client.GetFromJsonAsync>("/api/v1/audit?action=Flock.Deplete"); + Assert.Contains(depletions!, r => r.EntityId == flockId); + } + + // Cross-tenant isolation: the trail is scoped by the same global filter as + // every entity, and a foreign entityId is indistinguishable from a + // nonexistent one (no existence oracle). + [Fact] + public async Task Viewer_NeverCrossesTenants() + { + var (clientA, _, _, farmA, flockA, gradeA) = await SetupAsync(); + + // Tenant A produces an audited action. + var record = await clientA.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId = farmA, houseId = Guid.NewGuid(), flockId = flockA, date = Today, + totalEggs = 50, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = gradeA, quantity = 40 } } + }); + var entryId = (await record.Content.ReadFromJsonAsync())!.Id; + await clientA.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + var version = (await clientA.GetFromJsonAsync($"/api/v1/daily-entries/{entryId}"))!.Version; + await clientA.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/adjust", Guid.NewGuid().ToString(), new + { + version, totalEggs = 45, crackedEggs = 0, dirtyEggs = 0, + discardedEggs = 0, mortalityCount = 0, reason = "tenant A only" + }); + Assert.Single((await clientA.GetFromJsonAsync>($"/api/v1/audit?entityId={entryId}"))!); + + // Tenant B's admin sees nothing — not by list, not by A's entity id. + var (clientB, _, _, _, _, _) = await SetupAsync(); + Assert.Empty((await clientB.GetFromJsonAsync>("/api/v1/audit"))!); + Assert.Empty((await clientB.GetFromJsonAsync>($"/api/v1/audit?entityId={entryId}"))!); + } + + // Paging over the append-only trail: limit/offset windows are disjoint and + // their concatenation is exactly the full newest-first list (Id tiebreak + // keeps same-instant rows stable). + [Fact] + public async Task Paging_WindowsAreDisjoint_AndStitchBackNewestFirst() + { + var (client, _, _, _, flockId, _) = await SetupAsync(); + for (var i = 0; i < 3; i++) + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { type = "Cull", quantity = 1, date = Today, note = $"cull {i}" }); + + var all = await client.GetFromJsonAsync>( + "/api/v1/audit?action=Flock.BirdMovement"); + Assert.Equal(3, all!.Count); + Assert.True(all.Zip(all.Skip(1)).All(p => p.First.OccurredAtUtc >= p.Second.OccurredAtUtc)); + + var page1 = await client.GetFromJsonAsync>( + "/api/v1/audit?action=Flock.BirdMovement&limit=2&offset=0"); + var page2 = await client.GetFromJsonAsync>( + "/api/v1/audit?action=Flock.BirdMovement&limit=2&offset=2"); + Assert.Equal(2, page1!.Count); + Assert.Single(page2!); + Assert.Equal(all.Select(r => r.Id), page1.Concat(page2!).Select(r => r.Id)); + } + + // Date filters are inclusive calendar days; to=9999-12-31 must not 500 + // (AddDays overflow guard from the codex round). + [Fact] + public async Task DateRange_IsInclusive_AndMaxDateIsSafe() + { + var (client, _, _, _, flockId, _) = await SetupAsync(); + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { type = "Cull", quantity = 1, date = Today, note = "dated" }); + + // Anchor on the row's actual UTC day so a midnight rollover can't flake. + var row = Assert.Single((await client.GetFromJsonAsync>( + "/api/v1/audit?action=Flock.BirdMovement"))!); + var day = DateOnly.FromDateTime(row.OccurredAtUtc.UtcDateTime); + + Assert.Single((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=Flock.BirdMovement&from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}"))!); + Assert.Empty((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=Flock.BirdMovement&from={day.AddDays(1):yyyy-MM-dd}"))!); + Assert.Empty((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=Flock.BirdMovement&to={day.AddDays(-1):yyyy-MM-dd}"))!); + Assert.Single((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=Flock.BirdMovement&to=9999-12-31"))!); + } + + // A MID-transaction domain failure (order void refused because payments + // exist — the guard runs inside the transaction) must leave no audit row; + // the eventual success writes exactly one. Also pins that Payment.Void + // details carry the voided amount for the admin viewer. + [Fact] + public async Task MidTransactionFailure_WritesNothing_EventualVoidWritesOne() + { + var (client, _, _, farmId, flockId, gradeId) = await SetupAsync(); + + var record = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = Guid.NewGuid(), flockId, date = Today, + totalEggs = 100, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = gradeId, quantity = 90 } } + }); + var entryId = (await record.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + + var customer = await client.PostWithKeyAsync("/api/v1/customers", Guid.NewGuid().ToString(), + new { name = "Audit Buyer", phone = "555-0100" }); + var customerId = (await customer.Content.ReadFromJsonAsync())!.Id; + var order = await client.PostWithKeyAsync("/api/v1/sales", Guid.NewGuid().ToString(), + new { customerId, orderDate = Today }); + var orderId = (await order.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/items", Guid.NewGuid().ToString(), + new { eggGradeId = gradeId, quantity = 10, unitPriceMinorUnits = 100 }); + await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/confirm", Guid.NewGuid().ToString()); + await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/payments", Guid.NewGuid().ToString(), + new { paymentDate = Today, amountMinorUnits = 500, method = "Cash" }); + + // Void refused (payments exist) → the guard fails INSIDE the + // transaction, after work has been done — nothing may persist. + var refused = await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/void", + Guid.NewGuid().ToString(), new { reason = "wrong order" }); + Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.StatusCode); + Assert.Empty((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=SalesOrder.Void&entityId={orderId}"))!); + + var payments = await client.GetFromJsonAsync($"/api/v1/sales/{orderId}/payments"); + var payment = Assert.Single(payments!.Items); + await client.PostWithKeyAsync($"/api/v1/payments/{payment.Id}/void", Guid.NewGuid().ToString(), + new { version = payment.Version, reason = "clearing for order void" }); + + var paymentVoid = Assert.Single((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=Payment.Void&entityId={payment.Id}"))!); + Assert.Contains("\"amountMinorUnits\":500", paymentVoid.DetailsJson); + + var allowed = await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/void", + Guid.NewGuid().ToString(), new { reason = "wrong order" }); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + var orderVoid = Assert.Single((await client.GetFromJsonAsync>( + $"/api/v1/audit?action=SalesOrder.Void&entityId={orderId}"))!); + Assert.Equal("wrong order", orderVoid.Reason); + } + + private sealed record PaymentRow(Guid Id, int Version); + private sealed record PaymentsPage(List Items); + + [Fact] + public async Task Viewer_IsAdminOnly() + { + var (adminClient, _, accountId, _, _, _) = await SetupAsync(); + var workerEmail = $"w-{Guid.NewGuid():N}@test.local"; + await factory.SeedUserAsync(accountId, workerEmail, asAdmin: false); + var worker = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(workerEmail)); + + Assert.Equal(HttpStatusCode.Forbidden, (await worker.GetAsync("/api/v1/audit")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await adminClient.GetAsync("/api/v1/audit")).StatusCode); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 5adceb63b..7e3baa7e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { HelpPage } from "./routes/HelpPage"; import { WaterPage } from "./routes/WaterPage"; import { ExpensesPage } from "./routes/ExpensesPage"; import { ReportsPage } from "./routes/ReportsPage"; +import { AuditPage } from "./routes/AuditPage"; import { UsersPage } from "./routes/UsersPage"; export function App() { @@ -38,6 +39,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index d2c75f12c..a21c3d099 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -435,6 +435,33 @@ export function formatMoney(minorUnits: number, currencyCode: string, minorUnit: return `${value.toFixed(minorUnit)} ${currencyCode}`; } +// --- Audit trail (#93, admin-only, read-only) --- + +export interface AuditEvent { + id: string; + occurredAtUtc: string; + actorEmail: string; + action: string; + entityType: string; + entityId: string; + reason: string | null; + detailsJson: string | null; +} + +export const listAuditEvents = (params?: { + action?: string; entityId?: string; from?: string; to?: string; + limit?: number; offset?: number; +}) => { + const q = new URLSearchParams(); + if (params?.action) q.set("action", params.action); + if (params?.entityId) q.set("entityId", params.entityId); + if (params?.from) q.set("from", params.from); + if (params?.to) q.set("to", params.to); + if (params?.limit) q.set("limit", String(params.limit)); + if (params?.offset) q.set("offset", String(params.offset)); + return apiGet(`/audit${q.size > 0 ? `?${q}` : ""}`); +}; + // --- Reports (#91) --- export interface ProductionDay { diff --git a/web/src/routes/AppLayout.tsx b/web/src/routes/AppLayout.tsx index ef798fdf7..9ea7d81b1 100644 --- a/web/src/routes/AppLayout.tsx +++ b/web/src/routes/AppLayout.tsx @@ -35,6 +35,7 @@ export function AppLayout() { {isAdmin && Expenses} {isAdmin && Grades} {isAdmin && Users} + {isAdmin && Audit} Help + )} + + )} + + ); +} diff --git a/web/src/routes/HelpPage.tsx b/web/src/routes/HelpPage.tsx index b5889159d..4d2524060 100644 --- a/web/src/routes/HelpPage.tsx +++ b/web/src/routes/HelpPage.tsx @@ -294,6 +294,17 @@ export function HelpPage() { +

Audit log (admin)

+
    +
  • + Every corrective, destructive, or configuration change lands in the + audit log automatically: who did it, when (UTC), what it touched, + and the reason where one was given. Written together with the change + itself — a failed action leaves no trace, a successful one always + does — and never editable, by anyone. +
  • +
+

Fixing mistakes

Every fix in this table needs an admin sign-in (see "Who can do what")