diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 746e87058..01110905d 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -416,6 +416,69 @@ 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. +**Record history (#494)** — the "Created by … / Last changed by …" column on +the Flocks, Egg grades, Daily entry history, Sales and Expenses tables. Not a +stored field: it is **derived from the audit log**. Creation is the record's +`*.Create` event — identified by the action, never by being first on the trail, +because two events can share an instant and their order is then unknowable. +Creation itself became an audited action for these five in the same change +(`Flock.Create` and siblings) — before that only corrections were on the trail. + +The last change is the latest event that is neither that creation **nor a +promotion by the person who created it**. A *promotion* is the action that turns +a draft into the official record — `DailyEntry.Submit` and `SalesOrder.Confirm`, +the two moments that mint or allocate stock. That exception is what makes the +common case read correctly: saving a daily entry and submitting it are two events +but one act, so a farmhand who writes the day's numbers and makes them official — +changing nothing in between — is shown as the creator with no change against +them. When somebody *else* submits the draft, that submit is a real change and +both people are named, which is the accountability the submit step exists for. + +Suppressing the promoter's *name* must not lose the promotion's *time*, so the +column also carries **when** the record became official — "Submitted …" on a +daily entry, "Confirmed …" on a sales order — shown whoever did it. That instant +is when stock is minted or allocated, and it lives nowhere else on the record's +own page: a daily entry stores no submission timestamp of its own, only +`LockedAtUtc`. Blank for a draft awaiting promotion, and absent entirely on +flocks, egg grades and expenses, which have no promotion step. + +The exception covers **drafting** — the creator's own edits to their own draft, +and the promotion that ends it — and nothing else. Correcting a locked entry +always shows, including when the corrector created it. So does **cancelling** a +draft order: `SalesOrder.Cancel` kills the record rather than making it official, +so cancelling your own order stays a reportable change. It is keyed on the action +*and* the actor together, which is why the same draft edit is hidden for the +person who created the record and shown for anybody else. + +Editing a draft **is** recorded, even though it alters no stock, because it is +the only thing binding a person to the numbers. Without it, someone who rewrites +a colleague's draft before it is submitted leaves no trace and the submitter is +credited with their work. The rule above is what keeps that quiet in the ordinary +case: your own edits to your own draft are part of writing it, so they are hidden +along with your own submit, and only a **different** person's edit surfaces as a +change. + +That hiding stops the moment the draft stops being yours alone. **Once somebody +else has edited it, your own later edits are shown too** — because by then they +are the answer to "whose numbers are these". Hiding them named the colleague +whose work you overwrote and dated the record to an edit that no longer exists +in it, while the numbers that went into stock were yours. Your own *promotion* +stays hidden even here: it has its own "Submitted …" line, and repeating it as +the last change would say that making a record official changed it. + +Two further consequences follow from being derived rather than stored. A record +created **before #494** has no creation event and is **never backfilled**, so it +shows no "created by" line — but that half is independent of the rest: if it has +since been changed, that change still shows, with the created line simply absent. +The column is entirely blank only for a record with nothing on the trail at all. +And who can see it is exactly who could already read the record — it adds +no gate of its own, so a worker reading their own daily entry also sees which +manager corrected it. The actor is shown as the plain **email snapshot** the +audit log already keeps, deliberately not a live-joined name: a later rename or +disable must not rewrite what old history displays. Distinct from the audit +log's own admin-only viewer at /audit, which is the full cross-cutting trail — +this is the one-line summary on the record's own page. + **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/DailyEntries/DailyEntryEndpoints.cs b/src/Cluckwork.Api/Endpoints/DailyEntries/DailyEntryEndpoints.cs index 815f7f6c9..85ebdbc77 100644 --- a/src/Cluckwork.Api/Endpoints/DailyEntries/DailyEntryEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/DailyEntries/DailyEntryEndpoints.cs @@ -54,16 +54,21 @@ public static RouteGroupBuilder MapDailyEntryEndpoints(this RouteGroupBuilder gr private static async Task GetDailyEntry( Guid id, Cluckwork.Application.Features.DailyEntries.IDailyEntryRepository entries, + Cluckwork.Application.Features.Audit.IAuditEventRepository audit, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var entry = await entries.GetReadOnlyAsync(id, ct); - return entry is null ? Results.NotFound() : Results.Ok(ToResponse(entry)); + if (entry is null) return Results.NotFound(); + var provenance = await audit.GetProvenanceAsync( + nameof(Cluckwork.Domain.Eggs.DailyEntry), [id], ct); + return Results.Ok(ToResponse(entry, provenance.GetValueOrDefault(id))); } private static async Task ListDailyEntries( Cluckwork.Application.Features.DailyEntries.IDailyEntryRepository entries, + Cluckwork.Application.Features.Audit.IAuditEventRepository audit, TenantContext tenant, CancellationToken ct, Guid? flockId = null, @@ -78,10 +83,14 @@ private static async Task ListDailyEntries( var skip = Math.Max(offset ?? 0, 0); var list = await entries.ListAsync(flockId, from, to, take, skip, ct); - return Results.Ok(list.Select(ToResponse)); + var provenance = await audit.GetProvenanceAsync( + nameof(Cluckwork.Domain.Eggs.DailyEntry), list.Select(e => e.Id).ToList(), ct); + return Results.Ok(list.Select(e => ToResponse(e, provenance.GetValueOrDefault(e.Id)))); } - private static DailyEntryResponse ToResponse(Cluckwork.Domain.Eggs.DailyEntry e) => new( + private static DailyEntryResponse ToResponse( + Cluckwork.Domain.Eggs.DailyEntry e, + Cluckwork.Application.Features.Audit.EntityProvenance? p) => new( e.Id, e.FarmId, e.HouseId, e.FlockId, e.Date, e.Status.ToString(), e.TotalEggs, e.CrackedEggs, e.DirtyEggs, e.DiscardedEggs, e.MortalityCount, // #396 — which grade each condition counter resolved to when this entry @@ -92,7 +101,9 @@ private static async Task ListDailyEntries( e.Grades.Select(g => new GradeLineResponse(g.EggGradeId, g.Quantity)).ToList(), e.Version, e.AdjustReason, e.VoidReason, e.LockedAtUtc, // The audit snapshot is stored as JSON; embed it as an object, not a string. - e.AdjustedFromJson is null ? null : JsonSerializer.Deserialize(e.AdjustedFromJson)); + e.AdjustedFromJson is null ? null : JsonSerializer.Deserialize(e.AdjustedFromJson), + p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc, + p?.MadeOfficialAtUtc); private static async Task AdjustDailyEntry( Guid id, @@ -230,7 +241,15 @@ public sealed record DailyEntryResponse( Guid? CrackedGradeId, Guid? DirtyGradeId, IReadOnlyList Grades, int Version, string? AdjustReason, string? VoidReason, DateTimeOffset? LockedAtUtc, - JsonElement? AdjustedFrom); + JsonElement? AdjustedFrom, + // #494 provenance, derived from the audit trail: null together for a + // record created before that shipped (no backfill). + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc, + // #494 — when the entry was submitted, i.e. when its eggs entered stock. + // Carried separately because a self-submit is excluded from LastChanged*, + // and the entry itself stores no SubmittedAt. + DateTimeOffset? MadeOfficialAtUtc); public sealed record GradeLineResponse(Guid EggGradeId, int Quantity); diff --git a/src/Cluckwork.Api/Endpoints/EggGrades/EggGradeEndpoints.cs b/src/Cluckwork.Api/Endpoints/EggGrades/EggGradeEndpoints.cs index 3165d021b..b40ca8202 100644 --- a/src/Cluckwork.Api/Endpoints/EggGrades/EggGradeEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/EggGrades/EggGradeEndpoints.cs @@ -1,6 +1,7 @@ namespace Cluckwork.Api.Endpoints.EggGrades; using Cluckwork.Api.Validation; +using Cluckwork.Application.Features.Audit; using Cluckwork.Application.Features.EggGrades; using Cluckwork.Application.Features.EggGrades.CreateEggGrade; using Cluckwork.Application.Features.EggGrades.SetEggGradeActive; @@ -47,22 +48,30 @@ public static RouteGroupBuilder MapEggGradeEndpoints(this RouteGroupBuilder grou } private static async Task ListEggGrades( - IEggGradeRepository grades, TenantContext tenant, CancellationToken ct, + IEggGradeRepository grades, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct, bool includeInactive = false) { if (!tenant.IsResolved) return Results.Unauthorized(); var list = includeInactive ? await grades.ListAllAsync(ct) : await grades.ListActiveAsync(farmId: null, ct); - return Results.Ok(list.Select(ToResponse)); + // This list is unpaginated, so the batch is however many grades the + // farm has; the lookup chunks internally rather than refusing a big one. + var provenance = await audit.GetProvenanceAsync( + nameof(EggGrade), list.Select(g => g.Id).ToList(), ct); + return Results.Ok(list.Select(g => ToResponse(g, provenance.GetValueOrDefault(g.Id)))); } private static async Task GetEggGrade( - Guid id, IEggGradeRepository grades, TenantContext tenant, CancellationToken ct) + Guid id, IEggGradeRepository grades, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var grade = await grades.GetByIdAsync(id, ct); - return grade is null ? Results.NotFound() : Results.Ok(ToResponse(grade)); + if (grade is null) return Results.NotFound(); + var provenance = await audit.GetProvenanceAsync(nameof(EggGrade), [id], ct); + return Results.Ok(ToResponse(grade, provenance.GetValueOrDefault(id))); } private static async Task CreateEggGrade( @@ -126,9 +135,10 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) : Results.Problem(error.Description, statusCode: 422, title: error.Code); } - private static EggGradeResponse ToResponse(EggGrade g) => + private static EggGradeResponse ToResponse(EggGrade g, EntityProvenance? p) => new(g.Id, g.FarmId, g.Name, g.GradeType.ToString(), g.SortOrder, g.IsSaleable, - g.DailyEntryKind.ToString(), g.Active); + g.DailyEntryKind.ToString(), g.Active, + p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc); } // FarmId included from day one — grades are farm-scoped (spec §9.1) and a @@ -141,7 +151,11 @@ public sealed record EggGradeResponse( // lets the Grading pane leave the two counter-fed grades out; the server // refuses them regardless, so this is the affordance, not the enforcement. string DailyEntryKind, - bool Active); + bool Active, + // #494 provenance, derived from the audit trail: null together for a + // record created before that shipped (no backfill). + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc); public sealed record CreateEggGradeRequest( string Name, string GradeType, int SortOrder, bool IsSaleable); diff --git a/src/Cluckwork.Api/Endpoints/Expenses/ExpenseEndpoints.cs b/src/Cluckwork.Api/Endpoints/Expenses/ExpenseEndpoints.cs index 93627b140..2e518d087 100644 --- a/src/Cluckwork.Api/Endpoints/Expenses/ExpenseEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Expenses/ExpenseEndpoints.cs @@ -2,6 +2,7 @@ namespace Cluckwork.Api.Endpoints.Expenses; using Cluckwork.Api.Validation; using Cluckwork.Application.Features.Accounts; +using Cluckwork.Application.Features.Audit; using Cluckwork.Application.Features.Expenses; using Cluckwork.Application.Features.Expenses.AdjustExpense; using Cluckwork.Application.Features.Expenses.CreateExpense; @@ -114,6 +115,7 @@ private static async Task UpdateCategory( private static async Task ListExpenses( IExpenseRepository expenses, IAccountRepository accounts, + IAuditEventRepository audit, TenantContext tenant, CancellationToken ct, DateOnly? from = null, @@ -132,20 +134,25 @@ private static async Task ListExpenses( // Single-farm MVP: every expense carries the account currency, so one // label fits the total. Multi-currency totals arrive with multi-farm. var account = await accounts.GetCurrentAsync(ct); + var provenance = await audit.GetProvenanceAsync( + nameof(Expense), list.Select(e => e.Id).ToList(), ct); return Results.Ok(new ExpenseListResponse( - list.Select(ToResponse).ToList(), + list.Select(e => ToResponse(e, provenance.GetValueOrDefault(e.Id))).ToList(), total, account?.DefaultCurrencyCode ?? "", account?.DefaultCurrencyMinorUnit ?? 2)); } private static async Task GetExpense( - Guid id, IExpenseRepository expenses, TenantContext tenant, CancellationToken ct) + Guid id, IExpenseRepository expenses, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var expense = await expenses.GetByIdAsync(id, ct); - return expense is null ? Results.NotFound() : Results.Ok(ToResponse(expense)); + if (expense is null) return Results.NotFound(); + var provenance = await audit.GetProvenanceAsync(nameof(Expense), [id], ct); + return Results.Ok(ToResponse(expense, provenance.GetValueOrDefault(id))); } private static async Task CreateExpense( @@ -177,6 +184,7 @@ private static async Task AdjustExpense( AdjustExpenseHandler handler, IValidator validator, IExpenseRepository expenses, + IAuditEventRepository audit, TenantContext tenant, CancellationToken ct) { @@ -194,9 +202,12 @@ private static async Task AdjustExpense( if (result.IsFailure) return MapFailure(result.Error); // The corrected row (fresh version) so the client can rebind its edit - // state without a second round trip. + // state without a second round trip. The adjust just wrote its own + // audit event, so this read reports the correction as the last change. var updated = await expenses.GetByIdAsync(id, ct); - return updated is null ? Results.NotFound() : Results.Ok(ToResponse(updated)); + if (updated is null) return Results.NotFound(); + var provenance = await audit.GetProvenanceAsync(nameof(Expense), [id], ct); + return Results.Ok(ToResponse(updated, provenance.GetValueOrDefault(id))); } private static IResult MapFailure(Cluckwork.Domain.Common.Error error) @@ -212,10 +223,11 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) private static ExpenseCategoryResponse ToResponse(ExpenseCategory c) => new(c.Id, c.FarmId, c.Name, c.Active); - private static ExpenseResponse ToResponse(Expense e) => + private static ExpenseResponse ToResponse(Expense e, EntityProvenance? p) => new(e.Id, e.FarmId, e.ExpenseCategoryId, e.Date, e.Description, e.AmountMinorUnits, e.CurrencyCode, e.CurrencyMinorUnit, - e.FlockId, e.Note, e.Version); + e.FlockId, e.Note, e.Version, + p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc); } public sealed record ExpenseCategoryResponse(Guid Id, Guid FarmId, string Name, bool Active); @@ -225,7 +237,11 @@ public sealed record UpdateExpenseCategoryRequest(string Name, bool Active); public sealed record ExpenseResponse( Guid Id, Guid FarmId, Guid ExpenseCategoryId, DateOnly Date, string Description, long AmountMinorUnits, string CurrencyCode, int CurrencyMinorUnit, - Guid? FlockId, string? Note, int Version); + Guid? FlockId, string? Note, int Version, + // #494 provenance, derived from the audit trail: null together for a + // record created before that shipped (no backfill). + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc); public sealed record ExpenseListResponse( List Items, long TotalMinorUnits, string CurrencyCode, int CurrencyMinorUnit); diff --git a/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs b/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs index 7f9c201ef..66527d0be 100644 --- a/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs @@ -1,6 +1,7 @@ namespace Cluckwork.Api.Endpoints.Flocks; using Cluckwork.Api.Validation; +using Cluckwork.Application.Features.Audit; using Cluckwork.Application.Features.Flocks; using Cluckwork.Application.Features.Flocks.ArchiveFlock; using Cluckwork.Application.Features.Flocks.CreateFlock; @@ -92,7 +93,8 @@ private static async Task CreateFlock( } private static async Task ListFlocks( - IFlockRepository flocks, IBirdMovementRepository movements, TenantContext tenant, + IFlockRepository flocks, IBirdMovementRepository movements, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct, int? limit = null, int? offset = null, bool includeArchived = false) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -102,19 +104,22 @@ private static async Task ListFlocks( var list = await flocks.ListAsync(take, skip, includeArchived, ct); var removed = await movements.RemovedByFlockAsync(ct); + var provenance = await audit.GetProvenanceAsync( + nameof(Flock), list.Select(f => f.Id).ToList(), ct); return Results.Ok(list.Select(f => - ToResponse(f, removed.GetValueOrDefault(f.Id, 0)))); + ToResponse(f, removed.GetValueOrDefault(f.Id, 0), provenance.GetValueOrDefault(f.Id)))); } private static async Task GetFlock( Guid id, IFlockRepository flocks, IBirdMovementRepository movements, - TenantContext tenant, CancellationToken ct) + IAuditEventRepository audit, TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var flock = await flocks.GetByIdAsync(id, ct); if (flock is null) return Results.NotFound(); var removed = await movements.RemovedForFlockAsync(id, ct); - return Results.Ok(ToResponse(flock, removed)); + var provenance = await audit.GetProvenanceAsync(nameof(Flock), [id], ct); + return Results.Ok(ToResponse(flock, removed, provenance.GetValueOrDefault(id))); } private static async Task ReactivateFlock( @@ -212,9 +217,10 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) : Results.Problem(error.Description, statusCode: 422, title: error.Code); } - private static FlockResponse ToResponse(Flock f, long removed) => new( + private static FlockResponse ToResponse(Flock f, long removed, EntityProvenance? p) => new( f.Id, f.FarmId, f.HouseId, f.Name, f.Breed, - f.PlacementDate, f.InitialCount, f.InitialCount - removed, f.Status.ToString()); + f.PlacementDate, f.InitialCount, f.InitialCount - removed, f.Status.ToString(), + p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc); } public sealed record CreateFlockRequest( @@ -230,9 +236,13 @@ public sealed record UpdateFlockRequest( int InitialCount); // CurrentBirds = InitialCount − Σ movement quantities (bird ledger, #54). +// The four trailing fields are #494 provenance, derived from the audit trail: +// null together for a record created before that shipped (no backfill). public sealed record FlockResponse( Guid Id, Guid FarmId, Guid HouseId, string Name, string Breed, - DateOnly PlacementDate, int InitialCount, long CurrentBirds, string Status); + DateOnly PlacementDate, int InitialCount, long CurrentBirds, string Status, + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc); public sealed record RecordBirdMovementRequest( DateOnly Date, string Type, int Quantity, string? Note = null); diff --git a/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs b/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs index eb0039617..ca964875a 100644 --- a/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Sales/SaleEndpoints.cs @@ -1,6 +1,7 @@ namespace Cluckwork.Api.Endpoints.Sales; using Cluckwork.Api.Validation; +using Cluckwork.Application.Features.Audit; using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.AddOrderItem; using Cluckwork.Application.Features.Sales.CancelSalesOrder; @@ -182,15 +183,19 @@ private static async Task CancelSalesOrder( } private static async Task GetSalesOrder( - Guid id, ISalesOrderRepository orders, TenantContext tenant, CancellationToken ct) + Guid id, ISalesOrderRepository orders, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var order = await orders.GetReadOnlyAsync(id, ct); - return order is null ? Results.NotFound() : Results.Ok(ToResponse(order)); + if (order is null) return Results.NotFound(); + var provenance = await audit.GetProvenanceAsync(nameof(SalesOrder), [id], ct); + return Results.Ok(ToResponse(order, provenance.GetValueOrDefault(id))); } private static async Task ListSalesOrders( - ISalesOrderRepository orders, TenantContext tenant, CancellationToken ct, + ISalesOrderRepository orders, IAuditEventRepository audit, + TenantContext tenant, CancellationToken ct, string? status = null, Guid? customerId = null, DateOnly? from = null, DateOnly? to = null, int? limit = null, int? offset = null) @@ -214,17 +219,21 @@ private static async Task ListSalesOrders( var skip = Math.Max(offset ?? 0, 0); var list = await orders.ListAsync(statusFilter, customerId, from, to, take, skip, ct); - return Results.Ok(list.Select(ToResponse)); + var provenance = await audit.GetProvenanceAsync( + nameof(SalesOrder), list.Select(o => o.Id).ToList(), ct); + return Results.Ok(list.Select(o => ToResponse(o, provenance.GetValueOrDefault(o.Id)))); } - private static SalesOrderResponse ToResponse(SalesOrder o) => new( + private static SalesOrderResponse ToResponse(SalesOrder o, EntityProvenance? p) => new( o.Id, o.CustomerId, o.ReferenceNumber, o.OrderDate, o.Status.ToString(), o.TotalAmount.MinorUnits, o.TotalAmount.CurrencyCode, o.TotalAmount.CurrencyMinorUnit, o.VoidReason, o.Items.Select(i => new SalesOrderItemResponse( i.Id, i.ProductId, i.EggGradeId, i.Unit.ToString(), i.BaseUnitFactor, i.Quantity, i.QuantityBase, - i.UnitPrice.MinorUnits, i.UnitPrice.CurrencyCode, i.UnitPrice.CurrencyMinorUnit)).ToList()); + i.UnitPrice.MinorUnits, i.UnitPrice.CurrencyCode, i.UnitPrice.CurrencyMinorUnit)).ToList(), + p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc, + p?.MadeOfficialAtUtc); private static async Task VoidSale( Guid id, @@ -297,7 +306,14 @@ public sealed record SalesOrderResponse( Guid Id, Guid CustomerId, string ReferenceNumber, DateOnly OrderDate, string Status, long TotalMinorUnits, string CurrencyCode, int CurrencyMinorUnit, string? VoidReason, - IReadOnlyList Items); + IReadOnlyList Items, + // #494 provenance, derived from the audit trail: null together for a + // record created before that shipped (no backfill). + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc, + // #494 — when the order was confirmed, i.e. when its stock was allocated. + // Carried separately because a self-confirm is excluded from LastChanged*. + DateTimeOffset? MadeOfficialAtUtc); public sealed record CreateSalesOrderRequest(Guid CustomerId, DateOnly OrderDate); diff --git a/src/Cluckwork.Application/Common/AuditActions.cs b/src/Cluckwork.Application/Common/AuditActions.cs index 61ff43868..fccaf0e46 100644 --- a/src/Cluckwork.Application/Common/AuditActions.cs +++ b/src/Cluckwork.Application/Common/AuditActions.cs @@ -55,4 +55,30 @@ public static class AuditActions public const string ProductDeactivate = "Product.Deactivate"; public const string EggUnitConversionUpdate = "EggUnitConversion.Update"; public const string EggLotMovement = "EggLot.Movement"; + public const string FlockCreate = "Flock.Create"; + public const string DailyEntryCreate = "DailyEntry.Create"; + // Submitting mints the egg lots, so it alters stock and belongs in the + // trail on its own merits. It is also the ONLY record of who made a day's + // numbers official, which #494 shows as the entry's last change whenever + // that is someone other than the person who drafted it. + public const string DailyEntrySubmit = "DailyEntry.Submit"; + // Editing a DRAFT. Recorded even though it moves no stock, because it is + // the only thing that binds a person to the numbers: without it, a farmhand + // who rewrites somebody else's draft before it is submitted leaves no trace + // at all, and the submitter is credited with their work. #494's record + // history hides these when the editor is the record's own creator, so the + // common case of drafting your own entry stays quiet. + public const string DailyEntryUpdate = "DailyEntry.Update"; + public const string SalesOrderAddItem = "SalesOrder.AddItem"; + public const string SalesOrderUpdateItem = "SalesOrder.UpdateItem"; + public const string SalesOrderRemoveItem = "SalesOrder.RemoveItem"; + public const string SalesOrderCreate = "SalesOrder.Create"; + // Confirming allocates stock FIFO — the order's equivalent of submitting a + // daily entry, and treated identically by #494's record history. + public const string SalesOrderConfirm = "SalesOrder.Confirm"; + // Cancel is Draft-only so it releases nothing, but it is terminal: a record + // somebody killed must say who. + public const string SalesOrderCancel = "SalesOrder.Cancel"; + public const string ExpenseCreate = "Expense.Create"; + public const string EggGradeCreate = "EggGrade.Create"; } diff --git a/src/Cluckwork.Application/Features/Audit/EntityProvenance.cs b/src/Cluckwork.Application/Features/Audit/EntityProvenance.cs new file mode 100644 index 000000000..118715726 --- /dev/null +++ b/src/Cluckwork.Application/Features/Audit/EntityProvenance.cs @@ -0,0 +1,36 @@ +namespace Cluckwork.Application.Features.Audit; + +// #494 — derived from the audit trail, never stored. Creation is the entity's +// "*.Create" event, identified by its ACTION and NOT by being earliest on the +// trail: events sharing an instant have no knowable order, so position would +// name creator and changer at random (codex review of PR #503). +// +// LastChanged* is null when nothing REPORTABLE has happened since creation, +// which is not the same as nothing having happened. Two cases produce it: +// there is genuinely no later event, or the only later event is a PROMOTION by +// the person who created the record — submitting your own daily entry, or +// confirming your own sales order. Those are two distinct events but one act, +// and reporting them as a change reads as if somebody corrected your work. +// +// So this is deliberately NOT a "same event at both ends" test, and a caller +// must not re-derive it by comparing timestamps or ids — it needs the actions +// and the actor, which only the repository query has. See +// AuditEventRepository.GetProvenanceChunkAsync for the exclusion, including the +// known same-instant residual it does not solve (#508). +// +// MadeOfficialAtUtc is WHEN the promotion happened, reported whether or not the +// promoter was named above. Suppressing a self-promotion from "last changed by" +// must not also lose the instant stock was minted, which is recorded nowhere +// else on the record's own page — DailyEntry has no SubmittedAt, only +// LockedAtUtc. Null for anything with no promotion step (flocks, egg grades, +// expenses) and for a draft still awaiting one. +// Created* is nullable because the two halves are INDEPENDENT. A record from +// before #494 has no creation event and never gets one — but it may well have +// changes with real attribution, and an earlier revision discarded those too by +// keying the whole result off the creation. Refusing to invent a creator and +// throwing away a change we can prove are separate decisions; only the first +// was intended (adversarial review of PR #503). +public sealed record EntityProvenance( + string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc, + DateTimeOffset? MadeOfficialAtUtc); diff --git a/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs b/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs index 008e0f4db..c9964eb94 100644 --- a/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs +++ b/src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs @@ -6,7 +6,18 @@ namespace Cluckwork.Application.Features.Audit; // there is intentionally no update/remove surface anywhere. public interface IAuditEventRepository { + // Ids per round trip. NOT a caller contract — the egg-grade list is + // unpaginated and grades are never deleted, so that catalog outgrows any + // fixed cap eventually; a larger input is chunked, never refused. + public const int MaxBatchIds = 500; + Task> ListAsync( string? action, Guid? entityId, DateOnly? from, DateOnly? to, int limit, int offset, CancellationToken ct = default); + + // #494 — created/last-changed per entity id, for one entity type. An id + // with no events is ABSENT from the result rather than present-and-blank: + // records that predate #494 have no creation event and get no backfill. + Task> GetProvenanceAsync( + string entityType, IReadOnlyCollection entityIds, CancellationToken ct = default); } diff --git a/src/Cluckwork.Application/Features/DailyEntries/RecordDailyEntry/RecordDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/RecordDailyEntry/RecordDailyEntryHandler.cs index 2914d3843..8013bc414 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/RecordDailyEntry/RecordDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/RecordDailyEntry/RecordDailyEntryHandler.cs @@ -14,6 +14,7 @@ public sealed class RecordDailyEntryHandler( IEggGradeRepository eggGrades, IFlockRepository flocks, IFlockScopeGuard flockScope, + IAuditWriter audit, IUnitOfWork unitOfWork, ILogger logger) { @@ -86,6 +87,9 @@ public async Task> HandleAsync( command.FarmId, command.HouseId, command.FlockId, command.Date); await repository.AddAsync(entry, ct); + // #494 — only the first record for a natural key is a creation; + // a later call appends to the same entry and is not audited here. + await audit.WriteAsync(AuditActions.DailyEntryCreate, nameof(DailyEntry), entry.Id, ct: ct); } else { @@ -104,6 +108,16 @@ public async Task> HandleAsync( if (result.IsFailure) return Result.Failure(result.Error).LogFailure(logger, "RecordDailyEntry"); + // #494 — a later call against the same natural key REWRITES an existing + // draft. Recorded after the failure check above, so a rejected re-record + // leaves no trace. It moves no stock, but it is the only thing binding a + // person to the numbers: without it, someone rewriting a colleague's + // draft before submission is invisible and the submitter is credited + // with their work. Record history hides this when the editor IS the + // creator, so drafting your own entry stays quiet. + if (existing is not null) + await audit.WriteAsync(AuditActions.DailyEntryUpdate, nameof(DailyEntry), entry.Id, ct: ct); + await unitOfWork.SaveChangesAsync(ct); logger.LogInformation( "Daily entry {DailyEntryId} recorded for flock {FlockId} on {EntryDate}: {TotalEggs} eggs", diff --git a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs index 02ffa3a7a..3076dca42 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs @@ -26,6 +26,7 @@ public sealed class SubmitDailyEntryHandler( IFlockRepository flocks, IFlockScopeGuard flockScope, IClock clock, + IAuditWriter audit, IUnitOfWork unitOfWork, ILogger logger) { @@ -127,6 +128,12 @@ await birdMovements.AddAsync(BirdMovement.Create( dailyEntryId: entry.Id), ct); } + // #494 — appended to THIS unit of work, so the event commits with the + // lots and movements or not at all. Placed after every failure return + // above: a submit that never happened must leave no trace. + await audit.WriteAsync( + AuditActions.DailyEntrySubmit, nameof(DailyEntry), entry.Id, ct: ct); + // A concurrent submit that loses the Version race throws // DbUpdateConcurrencyException here; the API's global error handler maps it // to 409 and nothing from the losing request is persisted. A retry then diff --git a/src/Cluckwork.Application/Features/EggGrades/CreateEggGrade/CreateEggGradeHandler.cs b/src/Cluckwork.Application/Features/EggGrades/CreateEggGrade/CreateEggGradeHandler.cs index d63e52d5f..1cf19e7b9 100644 --- a/src/Cluckwork.Application/Features/EggGrades/CreateEggGrade/CreateEggGradeHandler.cs +++ b/src/Cluckwork.Application/Features/EggGrades/CreateEggGrade/CreateEggGradeHandler.cs @@ -7,6 +7,7 @@ namespace Cluckwork.Application.Features.EggGrades.CreateEggGrade; public sealed class CreateEggGradeHandler( IEggGradeRepository grades, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -28,6 +29,7 @@ public async Task> HandleAsync( command.Name, gradeType, command.SortOrder, command.IsSaleable); await grades.AddAsync(grade, ct); + await audit.WriteAsync(AuditActions.EggGradeCreate, nameof(EggGrade), grade.Id, ct: ct); await unitOfWork.SaveChangesAsync(ct); return Result.Success(grade.Id); } diff --git a/src/Cluckwork.Application/Features/Expenses/CreateExpense/CreateExpenseHandler.cs b/src/Cluckwork.Application/Features/Expenses/CreateExpense/CreateExpenseHandler.cs index 885713239..b220abad3 100644 --- a/src/Cluckwork.Application/Features/Expenses/CreateExpense/CreateExpenseHandler.cs +++ b/src/Cluckwork.Application/Features/Expenses/CreateExpense/CreateExpenseHandler.cs @@ -12,6 +12,7 @@ public sealed class CreateExpenseHandler( IExpenseCategoryRepository categories, IFlockRepository flocks, IAccountRepository accounts, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -59,6 +60,8 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => command.FlockId, command.Note); await expenses.AddAsync(expense, transactionCt); + await audit.WriteAsync( + AuditActions.ExpenseCreate, nameof(Expense), expense.Id, ct: transactionCt); outcome = Result.Success(expense.Id); return true; }, ct); diff --git a/src/Cluckwork.Application/Features/Flocks/CreateFlock/CreateFlockHandler.cs b/src/Cluckwork.Application/Features/Flocks/CreateFlock/CreateFlockHandler.cs index 60a7f8b53..df41d9381 100644 --- a/src/Cluckwork.Application/Features/Flocks/CreateFlock/CreateFlockHandler.cs +++ b/src/Cluckwork.Application/Features/Flocks/CreateFlock/CreateFlockHandler.cs @@ -8,6 +8,7 @@ namespace Cluckwork.Application.Features.Flocks.CreateFlock; public sealed class CreateFlockHandler( IFlockRepository flocks, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -20,6 +21,7 @@ public async Task> HandleAsync( command.PlacementDate, command.InitialCount); await flocks.AddAsync(flock, ct); + await audit.WriteAsync(AuditActions.FlockCreate, nameof(Flock), flock.Id, ct: ct); await unitOfWork.SaveChangesAsync(ct); return Result.Success(flock.Id); } diff --git a/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs b/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs index 1857fffc2..8264ca230 100644 --- a/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/AddOrderItem/AddOrderItemHandler.cs @@ -13,6 +13,7 @@ public sealed class AddOrderItemHandler( IProductRepository products, IEggGradeRepository eggGrades, IEggUnitConversionRepository conversions, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -101,6 +102,9 @@ public async Task> HandleAsync( if (result.IsFailure) return Result.Failure(result.Error); + // #494 — see RemoveOrderItemHandler: draft-only, recorded for attribution. + await audit.WriteAsync(AuditActions.SalesOrderAddItem, nameof(SalesOrder), order.Id, ct: ct); + // EF assigns the item id during save (deliberately not client-set). await unitOfWork.SaveChangesAsync(ct); return Result.Success(result.Value.Id); diff --git a/src/Cluckwork.Application/Features/Sales/CancelSalesOrder/CancelSalesOrderHandler.cs b/src/Cluckwork.Application/Features/Sales/CancelSalesOrder/CancelSalesOrderHandler.cs index a1de04904..5d281f39e 100644 --- a/src/Cluckwork.Application/Features/Sales/CancelSalesOrder/CancelSalesOrderHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/CancelSalesOrder/CancelSalesOrderHandler.cs @@ -8,6 +8,7 @@ namespace Cluckwork.Application.Features.Sales.CancelSalesOrder; public sealed class CancelSalesOrderHandler( ISalesOrderRepository orders, + IAuditWriter audit, IUnitOfWork unitOfWork, ILogger logger) { @@ -23,6 +24,12 @@ public async Task HandleAsync(Guid orderId, CancellationToken ct) if (result.IsFailure) return result.LogFailure(logger, "CancelSalesOrder"); + // #494 — appended to THIS unit of work, after the failure returns above, + // so a cancel that never happened leaves no trace. Cancel is Draft-only + // and releases no stock, but it is terminal: the record must say who + // killed it. + await audit.WriteAsync(AuditActions.SalesOrderCancel, nameof(SalesOrder), order.Id, ct: ct); + await unitOfWork.SaveChangesAsync(ct); logger.LogInformation("Sales order {SalesOrderId} cancelled", orderId); return Result.Success(); diff --git a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs index 4c91d848e..ae8b4bd3d 100644 --- a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs @@ -15,6 +15,7 @@ public sealed class ConfirmSaleHandler( IEggGradeRepository eggGrades, ISalesOrderAllocationRepository allocations, IEggInventoryMovementRepository eggMovements, + IAuditWriter audit, IUnitOfWork unitOfWork, IClock clock, IFarmClock farmClock, @@ -111,6 +112,13 @@ await eggMovements.AddAsync(EggInventoryMovement.Create( } await allocations.AddRangeAsync(allocationRows, transactionCt); + + // #494 — INSIDE the transaction, so the event commits with the FIFO + // allocations or rolls back with them. Every failure path above + // returns false before reaching here, so a confirm that did not + // happen leaves no trace. + await audit.WriteAsync( + AuditActions.SalesOrderConfirm, nameof(SalesOrder), order.Id, ct: transactionCt); return true; }, ct); diff --git a/src/Cluckwork.Application/Features/Sales/CreateSalesOrder/CreateSalesOrderHandler.cs b/src/Cluckwork.Application/Features/Sales/CreateSalesOrder/CreateSalesOrderHandler.cs index 4be315336..07c1cbe32 100644 --- a/src/Cluckwork.Application/Features/Sales/CreateSalesOrder/CreateSalesOrderHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/CreateSalesOrder/CreateSalesOrderHandler.cs @@ -9,6 +9,7 @@ namespace Cluckwork.Application.Features.Sales.CreateSalesOrder; public sealed class CreateSalesOrderHandler( ICustomerRepository customers, IAccountRepository accounts, + IAuditWriter audit, IUnitOfWork unitOfWork, ISalesOrderRepository orders) { @@ -43,6 +44,8 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => account.DefaultCurrencyCode, account.DefaultCurrencyMinorUnit); await orders.AddAsync(order, transactionCt); + await audit.WriteAsync( + AuditActions.SalesOrderCreate, nameof(SalesOrder), order.Id, ct: transactionCt); outcome = Result.Success(order.Id); return true; }, ct); diff --git a/src/Cluckwork.Application/Features/Sales/RemoveOrderItem/RemoveOrderItemHandler.cs b/src/Cluckwork.Application/Features/Sales/RemoveOrderItem/RemoveOrderItemHandler.cs index ebf9ed62d..b61b101d8 100644 --- a/src/Cluckwork.Application/Features/Sales/RemoveOrderItem/RemoveOrderItemHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/RemoveOrderItem/RemoveOrderItemHandler.cs @@ -7,6 +7,7 @@ namespace Cluckwork.Application.Features.Sales.RemoveOrderItem; public sealed class RemoveOrderItemHandler( ISalesOrderRepository orders, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task HandleAsync(Guid orderId, Guid itemId, CancellationToken ct) @@ -19,6 +20,11 @@ public async Task HandleAsync(Guid orderId, Guid itemId, CancellationTok if (result.IsFailure) return result; + // #494 — a draft-only edit, recorded so that reworking somebody else's + // order is attributable. Hidden by record history when the editor is the + // order's own creator. + await audit.WriteAsync(AuditActions.SalesOrderRemoveItem, nameof(SalesOrder), order.Id, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Application/Features/Sales/UpdateOrderItem/UpdateOrderItemHandler.cs b/src/Cluckwork.Application/Features/Sales/UpdateOrderItem/UpdateOrderItemHandler.cs index 824b264be..0af463898 100644 --- a/src/Cluckwork.Application/Features/Sales/UpdateOrderItem/UpdateOrderItemHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/UpdateOrderItem/UpdateOrderItemHandler.cs @@ -7,6 +7,7 @@ namespace Cluckwork.Application.Features.Sales.UpdateOrderItem; public sealed class UpdateOrderItemHandler( ISalesOrderRepository orders, + IAuditWriter audit, IUnitOfWork unitOfWork) { public async Task HandleAsync(UpdateOrderItemCommand command, CancellationToken ct) @@ -25,6 +26,9 @@ public async Task HandleAsync(UpdateOrderItemCommand command, Cancellati if (result.IsFailure) return result; + // #494 — see RemoveOrderItemHandler: draft-only, recorded for attribution. + await audit.WriteAsync(AuditActions.SalesOrderUpdateItem, nameof(SalesOrder), order.Id, ct: ct); + await unitOfWork.SaveChangesAsync(ct); return Result.Success(); } diff --git a/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs b/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs index 817f2535e..d6bfac908 100644 --- a/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/AuditEventRepository.cs @@ -1,11 +1,12 @@ namespace Cluckwork.Infrastructure.Repositories; +using Cluckwork.Application.Common; using Cluckwork.Application.Features.Audit; using Cluckwork.Domain.Auditing; using Cluckwork.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; -public sealed class AuditEventRepository(AppDbContext db) : IAuditEventRepository +public sealed class AuditEventRepository(AppDbContext db, TenantContext tenant) : IAuditEventRepository { public async Task> ListAsync( string? action, Guid? entityId, DateOnly? from, DateOnly? to, @@ -34,4 +35,266 @@ public async Task> ListAsync( .Take(limit) .ToListAsync(ct); } + + // #494 — who created a record and who last changed it, read out of the + // append-only trail rather than from new columns on every aggregate. + // + // MaxBatchIds is how many ids go into ONE round trip, not a limit on the + // caller: the egg-grade list has no pagination and grades are never + // deleted, so that catalog outgrows any fixed cap eventually and must not + // fail the whole endpoint when it does. Oversized inputs are chunked. + public async Task> GetProvenanceAsync( + string entityType, IReadOnlyCollection entityIds, CancellationToken ct = default) + { + var all = new Dictionary(); + foreach (var chunk in entityIds.Distinct().Chunk(IAuditEventRepository.MaxBatchIds)) + { + foreach (var (id, provenance) in await GetProvenanceChunkAsync(entityType, chunk, ct)) + all[id] = provenance; + } + return all; + } + + // One chunk, three round trips. Raw SQL because DISTINCT ON is Postgres-only + // and has no LINQ translation. EF would otherwise compose the tenant query + // filter over this — IgnoreQueryFilters opts out of that, so the hand-written + // AccountId predicate is then the ONLY thing scoping the read to the tenant. + // + // There are THREE such predicates below, one per round trip, and the same is + // true of the EntityType and EntityId predicates. Keeping it to one per query + // is deliberate rather than tidy: this scoping triple was twice shipped + // mostly unguarded, both times for the identical reason — the obvious test + // gives each account (or type) a DISTINCT entityId, so a duplicate predicate + // never runs in it and survives deletion against the whole suite. Copies of a + // predicate are what let that happen, so the last-change query derives its + // creator and its "was this draft shared" set from ONE `scoped` CTE rather + // than re-stating the triple per CTE. + // + // What actually holds the three: Provenance_LastChange_IsScopedToTheTenant + // and Provenance_EveryQueryIsScopedToTheEntityType share ONE id across + // accounts and types respectively; Provenance_MadeOfficial_IsScopedToThe- + // Tenant holds `promoted`. Do not trust this comment over a mutation run. + private async Task> GetProvenanceChunkAsync( + string entityType, Guid[] ids, CancellationToken ct) + { + var accountId = tenant.AccountId; + // Creation is identified by its ACTION, never by its position in the + // trail (codex review of PR #503). Position cannot answer it: events + // sharing an OccurredAtUtc have no knowable order, because AuditWriter + // mints a random v4 Guid — so an Id tiebreaker would name whichever + // event happens to sort first, reversing creator and changer at random. + // The action is unambiguous, and there is at most one per entity. + var createAction = $"%.Create"; + + var created = await db.AuditEvents.FromSqlInterpolated($""" + SELECT DISTINCT ON ("EntityId") * + FROM "AuditEvents" + WHERE "AccountId" = {accountId} + AND "EntityType" = {entityType} + AND "EntityId" = ANY({ids}) + AND "Action" LIKE {createAction} + ORDER BY "EntityId", "OccurredAtUtc" ASC, "Id" ASC + """) + .IgnoreQueryFilters() + .AsNoTracking() + .ToListAsync(ct); + + // The last change is the latest event that is neither the creation NOR + // the creator's OWN DRAFTING of it. That second exclusion is the whole of + // #494's "writing a record and making it official is one act, not a + // change" rule: filling in the day's numbers and submitting them should + // not read as if somebody corrected your work. + // + // Drafting = editing a draft, plus the promotion that ends it + // (DailyEntry.Submit, SalesOrder.Confirm — the moments stock is minted or + // allocated). Excluded ONLY when the actor is the creator, which is what + // makes a shared draft attributable: rewrite a colleague's numbers before + // they submit and your edit is the last change, even though they are + // still the creator and their own submit stays hidden. + // + // ...and only while the creator is the ONLY person who drafted it, which + // is what the `shared` CTE decides. Once somebody else has edited the + // draft, the creator's own later edits stop being quiet housekeeping and + // become the answer to "whose numbers are these" — hiding them named the + // colleague whose work was overwritten, and dated the record to an edit + // that no longer exists in it. Their PROMOTION stays hidden either way: + // it has its own line, and repeating it as the last change would say that + // making a record official changed it. Held by Provenance_WhenTheCreator- + // RevertsSomeoneElsesEdit_NamesTheCreatorsOwnEdit. + // + // "Somebody else" is IS DISTINCT FROM, so a creatorless legacy record + // counts as shared. Harmless rather than overlooked: the exclusion this + // gates on cannot fire there anyway — nothing IS NOT DISTINCT FROM a + // creator that does not exist. + // + // `shared` counts DRAFTING by somebody else, not any event by somebody + // else, and that narrowing has NO test — deliberately, because no fixture + // can currently tell the two apart without being fiction. Every foreign + // non-drafting action on these two aggregates (Adjust, Void, Cancel) + // only becomes possible once drafting has ended, so the creator has no + // later draft edit left for the wider rule to unhide. The line stays + // because the narrow rule is the one intended, and the day something + // audits an entry it did not draft — a Lock event from the sweep, a + // payment against an order — the wider version would silently start + // reporting the creator's own edits as changes. Reported as a surviving + // mutant rather than papered over with a contrived test. + // + // Deliberately NOT every draft-to-terminal move — SalesOrder.Cancel kills + // a draft rather than making it official, so cancelling your own order + // stays a reportable change. + // + // Stated as an EXCLUSION from the candidate set, deliberately — not as + // "if the last event is a self-promotion, report nothing". The latter + // also discards any earlier change the promotion happens to sit on top + // of, which would hide a real edit by a different person. + // + // Keyed on the action, never on the actor alone: correcting a locked + // entry is a stock-altering change and must show even when the person + // correcting it is the one who created it. + // + // Identity is ActorUserId, not the email — the email is a snapshot label. + // + // IS NOT DISTINCT FROM rather than "=" keeps the predicate total when the + // LEFT JOIN finds no creator, and it IS load-bearing. It was not while + // the result was keyed off the creation event — such records never + // reached the output — but that keying is gone (see below), so a legacy + // record whose only event is a drafting action now does reach it. Under a + // plain "=", Guid = NULL yields SQL NULL, NOT (drafting AND NULL) is NULL, + // and WHERE drops the row outright, losing a real attributable change + // rather than merely failing to exclude it. Held by + // Provenance_ForALegacyRecordWhoseOnlyEventIsDrafting_StillReportsIt. + // + // This comment previously asserted the opposite and was left behind when + // the keying changed in the same branch — the sentence that would have + // licensed the next person to simplify the line. + // + // KNOWN RESIDUAL, accepted rather than overlooked (#508). Which candidate + // wins is still decided by ORDER BY, and the "Id" tiebreak carries no + // chronology — AuditWriter mints a random v4 Guid. So two reportable + // changes sharing an OccurredAtUtc resolve arbitrarily, and the wrong + // actor can be named as the last changer. The timestamp shown is right + // either way; only the name is at risk. + // + // It is narrow but genuinely reachable, so do not dismiss it: most + // concurrent changes to one record cannot collide, because the aggregate + // Version token serialises them and the loser 409s having written + // nothing. RecordBirdMovement escapes that — it writes an audit event + // against the FLOCK's id while only inserting a movement row, never + // touching Flock.Version, so it does not serialise against Flock.Update. + // + // Fixing it properly needs a durable monotonic column on AuditEvents, + // i.e. a migration — the one thing #494 was specified not to do. Tracked + // separately in #508 rather than smuggled in here. There is deliberately + // NO test pinning the current arbitrary outcome: that would promote a + // known loss into a specification. + // EXACT actions, not a "%.Submit"/"%.Confirm" pattern. Creation rightly + // uses a wildcard — any ".Create" IS a creation — but these are closed + // sets, and a pattern would silently swallow a future action that merely + // ends the same way (a Payment.Confirm, say), dropping its actor from + // history with no error (pi review of PR #503). + // + // TWO sets, deliberately not one. Promotion alone answers "when did this + // become official"; drafting is what the creator may do to their own + // record without it counting as a change. + var promotionActions = new[] + { + AuditActions.DailyEntrySubmit, + AuditActions.SalesOrderConfirm, + }; + var draftingActions = promotionActions.Concat(new[] + { + AuditActions.DailyEntryUpdate, + AuditActions.SalesOrderAddItem, + AuditActions.SalesOrderUpdateItem, + AuditActions.SalesOrderRemoveItem, + }).ToArray(); + var latest = await db.AuditEvents.FromSqlInterpolated($""" + WITH scoped AS ( + SELECT * + FROM "AuditEvents" + WHERE "AccountId" = {accountId} + AND "EntityType" = {entityType} + AND "EntityId" = ANY({ids}) + ), + creator AS ( + SELECT DISTINCT ON ("EntityId") "EntityId", "ActorUserId" + FROM scoped + WHERE "Action" LIKE {createAction} + ORDER BY "EntityId", "OccurredAtUtc" ASC, "Id" ASC + ), + shared AS ( + SELECT DISTINCT s."EntityId" + FROM scoped s + LEFT JOIN creator c ON c."EntityId" = s."EntityId" + WHERE s."Action" = ANY({draftingActions}) + AND s."ActorUserId" IS DISTINCT FROM c."ActorUserId" + ) + SELECT DISTINCT ON (e."EntityId") e.* + FROM scoped e + LEFT JOIN creator c ON c."EntityId" = e."EntityId" + LEFT JOIN shared sh ON sh."EntityId" = e."EntityId" + WHERE e."Action" NOT LIKE {createAction} + AND NOT (e."Action" = ANY({draftingActions}) + AND e."ActorUserId" IS NOT DISTINCT FROM c."ActorUserId" + AND (e."Action" = ANY({promotionActions}) + OR sh."EntityId" IS NULL)) + ORDER BY e."EntityId", e."OccurredAtUtc" DESC, e."Id" DESC + """) + .IgnoreQueryFilters() + .AsNoTracking() + .ToListAsync(ct); + + // WHEN the record became official, reported whether or not the promoter + // was named as the last changer. Excluding a self-promotion from "last + // changed by" must not lose the instant stock was minted — it appears + // nowhere else on the record's own page, since DailyEntry carries no + // SubmittedAt (only LockedAtUtc). + // + // A separate round trip rather than something clever folded into the + // queries above: those two are the ones under mutation guard, and this + // is a plain indexed lookup on the same (AccountId, EntityId) index. + // At most one promotion exists per entity — neither DailyEntry nor + // SalesOrder has a path back to Draft — so ordering here is a formality. + var promoted = await db.AuditEvents.FromSqlInterpolated($""" + SELECT DISTINCT ON ("EntityId") * + FROM "AuditEvents" + WHERE "AccountId" = {accountId} + AND "EntityType" = {entityType} + AND "EntityId" = ANY({ids}) + AND "Action" = ANY({promotionActions}) + ORDER BY "EntityId", "OccurredAtUtc" ASC, "Id" ASC + """) + .IgnoreQueryFilters() + .AsNoTracking() + .ToListAsync(ct); + + // Keyed off EVERY id with any event, not off `created` alone. A record + // predating #494 has no creation event and never gets one — but it can + // still have changes with real attribution, and keying off `created` + // discarded those too, rendering the column completely blank for a flock + // somebody archived yesterday (adversarial review of PR #503). + // + // Refusing to invent a creator and throwing away a provable change are + // separate decisions. Only the first is intended: absent creation stays + // null rather than being filled in from the first correction. + var createdById = created.ToDictionary(e => e.EntityId); + var latestById = latest.ToDictionary(e => e.EntityId); + var promotedById = promoted.ToDictionary(e => e.EntityId); + + return createdById.Keys + .Concat(latestById.Keys) + .Concat(promotedById.Keys) + .Distinct() + .ToDictionary( + id => id, + id => + { + var create = createdById.GetValueOrDefault(id); + var last = latestById.GetValueOrDefault(id); + return new EntityProvenance( + create?.ActorEmail, create?.OccurredAtUtc, + last?.ActorEmail, last?.OccurredAtUtc, + promotedById.GetValueOrDefault(id)?.OccurredAtUtc); + }); + } } diff --git a/tests/Cluckwork.Api.IntegrationTests/AuditProvenanceTests.cs b/tests/Cluckwork.Api.IntegrationTests/AuditProvenanceTests.cs new file mode 100644 index 000000000..f9f56bcb0 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/AuditProvenanceTests.cs @@ -0,0 +1,1120 @@ +namespace Cluckwork.Api.IntegrationTests; + +using System.Net; +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Application.Features.Audit; +using Cluckwork.Domain.Auditing; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +// #494 — "who created this, and who last changed it" derived from the +// append-only audit trail rather than from new columns on every aggregate. +// The lookup is raw SQL (DISTINCT ON), so it bypasses the EF tenant query +// filter: the AccountId predicate in that SQL is the only thing scoping it, +// which is what Provenance_IsScopedToTheTenant exists to hold in place. +[Collection(IntegrationCollection.Name)] +public sealed class AuditProvenanceTests(CluckworkWebApplicationFactory factory) +{ + private static readonly DateTimeOffset Base = + new(2026, 5, 1, 8, 0, 0, TimeSpan.Zero); + + private async Task SeedEventsAsync(Guid accountId, params AuditEvent[] events) => + await factory.WithTenantScopeAsync(accountId, async db => + { + db.AuditEvents.AddRange(events); + await db.SaveChangesAsync(); + }); + + // actorUserId defaults to a fresh id, so events are by DIFFERENT people + // unless a test deliberately passes the same one. Identity is the user id, + // never the email — the email is a snapshot label that can be re-pointed. + private static AuditEvent Event( + Guid accountId, Guid entityId, string action, string email, int minutesFromBase, + string entityType = "Flock", Guid? actorUserId = null) => + AuditEvent.Create( + Guid.NewGuid(), accountId, Base.AddMinutes(minutesFromBase), + actorUserId ?? Guid.NewGuid(), email, action, entityType, entityId); + + private async Task WithRepositoryAsync( + Guid accountId, Func> action) + { + using var scope = factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Resolve(accountId); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + // Never changed since creation: the trail's two ends are the SAME event, so + // there is no change to report. Deciding that here rather than in the UI is + // deliberate — only this layer can tell one event from two that happen to + // share an instant (see Provenance_WhenTwoEventsShareAnInstant...). + [Fact] + public async Task Provenance_WithOneEvent_ReportsNoChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, Event(accountId, entityId, "Flock.Create", "ana@farm.test", 0)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal(Base, provenance.CreatedAtUtc); + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // A record that predates #494 has no ".Create" event, only corrections. + // Naming its first corrector as its author would be a claim the trail does + // not support — an outright false attribution, not merely missing data. + // + // But refusing to invent a creator is a separate decision from discarding a + // change we DO have attribution for, and an earlier revision made both at + // once by keying the whole result off the creation event: a flock archived + // by cy yesterday rendered completely blank (adversarial review of PR #503). + // Now the two halves are independent — no creator, real last change. + [Fact] + public async Task Provenance_ForALegacyRecordWithOnlyCorrections_NamesNoCreatorButKeepsTheChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "Flock.Update", "bo@farm.test", 30), + Event(accountId, entityId, "Flock.Archive", "cy@farm.test", 90)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + var provenance = result[entityId]; + Assert.Null(provenance.CreatedByEmail); + Assert.Null(provenance.CreatedAtUtc); + Assert.Equal("cy@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(90), provenance.LastChangedAtUtc); + } + + // A legacy record whose ONLY event is a drafting action — no ".Create", so + // the LEFT JOIN finds no creator and c."ActorUserId" is NULL. + // + // This is what makes IS NOT DISTINCT FROM load-bearing, which it was NOT + // before the result stopped being keyed off the creation event. With a plain + // "=", NULL comparison yields SQL NULL, NOT (drafting AND NULL) is NULL, and + // WHERE drops the row entirely — so a real, attributable change silently + // disappears rather than merely failing to be excluded (conventions review of + // PR #503, which caught the comment claiming this could not happen). + [Fact] + public async Task Provenance_ForALegacyRecordWhoseOnlyEventIsDrafting_StillReportsIt() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Submit", "bo@farm.test", 30, "DailyEntry")); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Null(provenance.CreatedByEmail); + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(30), provenance.LastChangedAtUtc); + } + + [Fact] + public async Task Provenance_WithSeveralEvents_ReportsTheEarliestAndTheLatest() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + // Deliberately inserted out of order: the SQL orders, not the insert. + await SeedEventsAsync(accountId, + Event(accountId, entityId, "Flock.Update", "bo@farm.test", 30), + Event(accountId, entityId, "Flock.Create", "ana@farm.test", 0), + Event(accountId, entityId, "Flock.Archive", "cy@farm.test", 90)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal(Base, provenance.CreatedAtUtc); + Assert.Equal("cy@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(90), provenance.LastChangedAtUtc); + } + + // Two events can share an instant, and their relative order is then + // UNKNOWABLE: AuditWriter mints a random v4 Guid, so an Id tiebreaker sorts + // arbitrarily rather than chronologically (codex review of PR #503 — an + // earlier revision of this test picked its expected actor by that ordering + // and passed only ~half the time). + // + // So creation is identified by its ACTION, not by its position in the + // trail. That is what makes this deterministic: whichever event sorts + // first, the ".Create" one is the creation and the other is the change. + [Fact] + public async Task Provenance_WhenTwoEventsShareAnInstant_NamesTheCreatorByAction() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "Flock.Create", "ana@farm.test", 0), + Event(accountId, entityId, "Flock.Update", "bo@farm.test", 0)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + // Distinct events, so this is a real change and must be reported. + Assert.NotNull(provenance.LastChangedByEmail); + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + } + + // --- submitting your own draft is not a change ------------------------- + // + // Saving a draft and submitting it are two events, but one act: the person + // wrote the day's numbers down and made them official, changing nothing in + // between. Reporting "last changed by ana" there is noise that reads like + // somebody corrected ana's work. + // + // So the rule is NOT "the latest event after the creation" — it is "the + // latest event that is neither the creation NOR a Submit by the creator". + // Stated as an exclusion rather than as a check on the last event, which is + // what keeps the three tests below from contradicting each other. + + [Fact] + public async Task Provenance_WhenTheCreatorSubmitsTheirOwnDraft_ReportsNoChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal(Base, provenance.CreatedAtUtc); + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // Suppressing the self-promotion from "last changed BY" must not also lose + // WHEN it happened. The promotion is the instant stock is minted, and it is + // recorded nowhere else on the record's own page: DailyEntry has no + // SubmittedAt field, only LockedAtUtc. Draft Monday, submit Friday — before + // this, the row read "created Monday" and Friday existed only in the admin + // audit log (adversarial review of PR #503). + [Fact] + public async Task Provenance_WhenTheCreatorSubmitsTheirOwnDraft_StillReportsWhen() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5760, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Null(provenance.LastChangedByEmail); + // Four days later — the moment the eggs entered stock. + Assert.Equal(Base.AddMinutes(5760), provenance.MadeOfficialAtUtc); + } + + // Reported whoever promoted it, so the two pages never disagree about when + // the record became official. + [Fact] + public async Task Provenance_WhenSomeoneElseSubmits_StillReportsWhenItBecameOfficial() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Submit", "bo@farm.test", 5, "DailyEntry")); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(5), provenance.MadeOfficialAtUtc); + } + + // The promotion lookup is a THIRD query with its own tenant predicate, so it + // needs its own guard — the lesson from the last-change query, where two + // predicates sat unguarded because every test used distinct ids per account. + [Fact] + public async Task Provenance_MadeOfficial_IsScopedToTheTenant() + { + var mine = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var theirs = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + + // Mine is still a draft. Theirs, sharing the id, has been submitted. + await SeedEventsAsync(mine, + Event(mine, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry")); + await SeedEventsAsync(theirs, + Event(theirs, entityId, "DailyEntry.Create", "rival@other.test", 1, "DailyEntry"), + Event(theirs, entityId, "DailyEntry.Submit", "rival@other.test", 2, "DailyEntry")); + + var result = await WithRepositoryAsync(mine, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + // Drop the promotion query's AccountId and my draft would claim to have + // been made official — by an event belonging to another farm. + Assert.Null(result[entityId].MadeOfficialAtUtc); + } + + // A record with no promotion step at all — flocks, grades, expenses — and a + // draft still awaiting one. Nothing to report, and no zero-date invented. + [Fact] + public async Task Provenance_WithNoPromotion_ReportsNoOfficialInstant() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "Flock.Create", "ana@farm.test", 0), + Event(accountId, entityId, "Flock.Update", "bo@farm.test", 30)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + Assert.Null(result[entityId].MadeOfficialAtUtc); + } + + // The masked-co-author case. bo rewrites ana's draft and ana submits it: the + // numbers that became stock are bo's, and before draft edits were recorded + // this read "created by ana, never changed" — crediting ana with bo's work + // (adversarial review of PR #503). + // + // ana's own submit is still hidden, because it is her own drafting. bo's + // edit is not, because bo is not the creator. + [Fact] + public async Task Provenance_WhenSomeoneElseEditsTheDraft_NamesThemEvenIfTheCreatorSubmits() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Update", "bo@farm.test", 2, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(2), provenance.LastChangedAtUtc); + // The submit still happened, and when it happened is still reported. + Assert.Equal(Base.AddMinutes(5), provenance.MadeOfficialAtUtc); + } + + // The other half of the same rule, and the reason it is keyed on the actor: + // editing your OWN draft before submitting must stay quiet, or every entry + // reads as having been corrected by the person who wrote it. + [Fact] + public async Task Provenance_WhenTheCreatorEditsTheirOwnDraft_ReportsNoChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Update", "ana@farm.test", 2, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // ...but only while the creator is the ONLY person who touched the draft. + // Once somebody else has edited it, hiding the creator's own later edit + // misattributes the record: the column named bo, whose numbers ana then + // overwrote, while ana's numbers are the ones that went into stock. Wrong + // person, wrong timestamp — found independently by two reviewers of PR #503 + // and decided by the owner on 2026-08-10. + // + // The submit is still hidden even here: it is reported on its own line, and + // repeating it as "last changed" would say the act of making a record + // official changed it. + [Fact] + public async Task Provenance_WhenTheCreatorRevertsSomeoneElsesEdit_NamesTheCreatorsOwnEdit() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Update", "bo@farm.test", 2, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Update", "ana@farm.test", 6, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 8, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal("ana@farm.test", provenance.LastChangedByEmail); + // +6, not +8: her edit, not her submit. + Assert.Equal(Base.AddMinutes(6), provenance.LastChangedAtUtc); + Assert.Equal(Base.AddMinutes(8), provenance.MadeOfficialAtUtc); + } + + // The same rule on the sales-order shape, and the reason it is stated over + // the whole drafting set rather than over DailyEntry.Update alone. + [Fact] + public async Task Provenance_WhenTheCreatorReworksLinesAfterSomeoneElse_NamesTheCreatorsOwnEdit() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Create", "ana@farm.test", 0, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.UpdateItem", "bo@farm.test", 2, "SalesOrder"), + Event(accountId, entityId, "SalesOrder.RemoveItem", "ana@farm.test", 6, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.Confirm", "ana@farm.test", 8, "SalesOrder", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("SalesOrder", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(6), provenance.LastChangedAtUtc); + Assert.Equal(Base.AddMinutes(8), provenance.MadeOfficialAtUtc); + } + + // "Somebody else touched this draft" is decided over THIS tenant's trail. + // A foreign account's edit of a colliding id must not unhide ana's own + // edit — which would report a change to a record nobody but ana ever + // touched, sourced from a row she is not allowed to see. + [Fact] + public async Task Provenance_WhetherADraftWasSharedIsScopedToTheTenant() + { + var mine = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var theirs = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + + await SeedEventsAsync(theirs, + Event(theirs, entityId, "DailyEntry.Create", "rival@other.test", 0, "DailyEntry"), + Event(theirs, entityId, "DailyEntry.Update", "other@other.test", 1, "DailyEntry")); + await SeedEventsAsync(mine, + Event(mine, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(mine, entityId, "DailyEntry.Update", "ana@farm.test", 6, "DailyEntry", ana), + Event(mine, entityId, "DailyEntry.Submit", "ana@farm.test", 8, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(mine, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // ...and over THIS entity's trail. Two records in one batch: a shared one + // and a solitary one. The solitary record must stay quiet, or the batch + // size silently changes what a record says about itself. + [Fact] + public async Task Provenance_WhetherADraftWasSharedIsScopedToTheRecord() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var solitary = Guid.NewGuid(); + var shared = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, solitary, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, solitary, "DailyEntry.Update", "ana@farm.test", 6, "DailyEntry", ana), + Event(accountId, shared, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, shared, "DailyEntry.Update", "bo@farm.test", 2, "DailyEntry"), + Event(accountId, shared, "DailyEntry.Update", "ana@farm.test", 6, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [solitary, shared])); + + Assert.Null(result[solitary].LastChangedByEmail); + Assert.Equal("ana@farm.test", result[shared].LastChangedByEmail); + Assert.Equal(Base.AddMinutes(6), result[shared].LastChangedAtUtc); + } + + // The case the whole option exists for: a worker writes the numbers, someone + // else makes them official. Both people must be visible — that is the + // accountability the submit step is for. + [Fact] + public async Task Provenance_WhenSomeoneElseSubmitsTheDraft_ReportsTheSubmitter() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Submit", "bo@farm.test", 5, "DailyEntry")); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(5), provenance.LastChangedAtUtc); + } + + // The suppression is keyed on the ACTION, not on the actor alone. Correcting + // a locked entry is a stock-altering change and must always show, including + // when the person correcting it is the one who created it — a blanket + // "same actor" rule would silently swallow exactly that. + [Fact] + public async Task Provenance_WhenTheCreatorAdjustsTheirOwnEntry_StillReportsTheChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Adjust", "ana@farm.test", 600, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(600), provenance.LastChangedAtUtc); + } + + // Excluding the self-submit must not take anything else with it. Had the + // rule been "look at the last event, and if it is a self-submit report + // nothing", bo's edit would vanish — a real change, hidden. Not reachable + // through today's handlers (draft re-saves write no event), so this pins the + // SHAPE of the rule rather than a live path. + [Fact] + public async Task Provenance_WhenAnEarlierChangeByAnotherPersonPrecedesASelfSubmit_StillReportsIt() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Adjust", "bo@farm.test", 2, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(2), provenance.LastChangedAtUtc); + } + + // Sales orders take the identical shape: a draft order is created, then + // CONFIRMED, and confirming is what allocates stock. So the same exclusion + // covers it — the pattern is "an action that promotes a draft to official", + // of which Submit and Confirm are the two instances. + [Fact] + public async Task Provenance_WhenTheCreatorConfirmsTheirOwnOrder_ReportsNoChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Create", "ana@farm.test", 0, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.Confirm", "ana@farm.test", 5, "SalesOrder", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("SalesOrder", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + [Fact] + public async Task Provenance_WhenSomeoneElseConfirmsTheOrder_ReportsTheConfirmer() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Create", "ana@farm.test", 0, "SalesOrder"), + Event(accountId, entityId, "SalesOrder.Confirm", "bo@farm.test", 5, "SalesOrder")); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("SalesOrder", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(5), provenance.LastChangedAtUtc); + } + + // Cancelling is not stock-altering — Cancel is Draft-only, so nothing has + // been allocated yet — but it is TERMINAL, and a record that somebody killed + // must say who. Not covered by the promotion exclusion: cancelling your own + // draft is a real change, unlike confirming it. + [Fact] + public async Task Provenance_WhenTheCreatorCancelsTheirOwnOrder_StillReportsTheChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Create", "ana@farm.test", 0, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.Cancel", "ana@farm.test", 5, "SalesOrder", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("SalesOrder", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(5), provenance.LastChangedAtUtc); + } + + // The LAST-CHANGE query has its own tenant predicate, in two places — the + // outer SELECT and the `creator` CTE — and Provenance_IsScopedToTheTenant + // does NOT hold either one: it gives each account a distinct entityId, so + // the foreign row is already gone before those predicates are consulted. + // Both survived deletion against the whole suite (adversarial review of PR + // #503), which made the comment claiming they were guarded a false + // assurance rather than a guard. + // + // So this shares ONE entityId across two accounts. Not reachable in + // production — ids are unique — but it is the only shape in which those + // predicates do any work, and a guard that never exercises the line it + // claims to protect is worse than none. + // + // Kills both: drop the outer predicate and the rival's later Adjust becomes + // the last change; drop the CTE's predicate and the CTE picks the rival's + // EARLIER Create as the creator, so ana's self-submit stops being + // suppressed and surfaces as a change. + [Fact] + public async Task Provenance_LastChange_IsScopedToTheTenant() + { + var mine = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var theirs = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + var rival = Guid.NewGuid(); + + await SeedEventsAsync(theirs, + // Earlier than my creation, so an unscoped CTE would prefer it. + Event(theirs, entityId, "DailyEntry.Create", "rival@other.test", -10, "DailyEntry", rival), + // Later than my submit, so an unscoped outer query would prefer it. + Event(theirs, entityId, "DailyEntry.Adjust", "rival@other.test", 9, "DailyEntry", rival)); + await SeedEventsAsync(mine, + Event(mine, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(mine, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(mine, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // The CTE that resolves the creator selects by ACTION, exactly as the outer + // creation query does. Position cannot be substituted for it, so an event + // that merely sorts earlier must not be mistaken for the creation. + // + // The fixture is deliberately unnatural — a correction dated before the + // record exists — because that is the only arrangement in which position + // and action disagree. It pins the mechanism, not a reachable sequence. + [Fact] + public async Task Provenance_TheCreatorForTheExclusion_IsFoundByActionNotByPosition() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "DailyEntry.Adjust", "bo@farm.test", -5, "DailyEntry"), + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + // ana's submit is suppressed as a self-promotion, so bo's correction is + // the last change. Were the CTE to take the earliest event instead of + // the ".Create" one, it would call bo the creator, ana's submit would + // stop matching, and ana would surface here instead. + Assert.Equal("bo@farm.test", provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(-5), provenance.LastChangedAtUtc); + } + + // Every member of the drafting set, not just the two a page-level test + // happens to exercise. UpdateItem and RemoveItem could both be deleted from + // that set with the FULL 1104-test suite still green, while their two + // siblings were held — the "two of four covered" shape that reads as + // complete (adversarial review of PR #503). + [Fact] + public async Task Provenance_WhenTheCreatorReworksTheirOwnOrderLines_ReportsNoChange() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Create", "ana@farm.test", 0, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.AddItem", "ana@farm.test", 1, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.UpdateItem", "ana@farm.test", 2, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.RemoveItem", "ana@farm.test", 3, "SalesOrder", ana), + Event(accountId, entityId, "SalesOrder.Confirm", "ana@farm.test", 4, "SalesOrder", ana)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("SalesOrder", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + // Assembling your own order is one act. Drop any member of the drafting + // set and that member resurfaces here as "somebody changed your work". + Assert.Null(provenance.LastChangedByEmail); + Assert.Null(provenance.LastChangedAtUtc); + } + + // The EntityType predicate appears FOUR times, and three of them survived + // deletion against the whole suite — the same miss as the tenant predicate, + // in the same file, found the same way. Provenance_IgnoresEventsOfAnother- + // EntityType holds only the `created` query, because its single foreign + // event is a ".Create" that the other queries exclude by action anyway. + // + // One id, four entity types, arranged so each unguarded predicate changes a + // different answer: + // outer last-change — a foreign Archive would become the last change + // creator CTE — a foreign, EARLIER Create would win the creator slot, + // un-suppressing ana's own submit + // promoted — a foreign, EARLIER Confirm would become the instant + // this entry supposedly became official + [Fact] + public async Task Provenance_EveryQueryIsScopedToTheEntityType() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var entityId = Guid.NewGuid(); + var ana = Guid.NewGuid(); + var rival = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "SalesOrder.Confirm", "rival@farm.test", -20, "SalesOrder", rival), + Event(accountId, entityId, "Flock.Create", "rival@farm.test", -10, "Flock", rival), + Event(accountId, entityId, "DailyEntry.Create", "ana@farm.test", 0, "DailyEntry", ana), + Event(accountId, entityId, "DailyEntry.Submit", "ana@farm.test", 5, "DailyEntry", ana), + Event(accountId, entityId, "Flock.Archive", "rival@farm.test", 90, "Flock", rival)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("DailyEntry", [entityId])); + + var provenance = result[entityId]; + Assert.Equal("ana@farm.test", provenance.CreatedByEmail); + Assert.Null(provenance.LastChangedByEmail); + Assert.Equal(Base.AddMinutes(5), provenance.MadeOfficialAtUtc); + } + + [Fact] + public async Task Provenance_IsScopedToTheTenant() + { + var mine = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var theirs = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var myEntity = Guid.NewGuid(); + var theirEntity = Guid.NewGuid(); + await SeedEventsAsync(mine, Event(mine, myEntity, "Flock.Create", "ana@farm.test", 0)); + await SeedEventsAsync(theirs, Event(theirs, theirEntity, "Flock.Create", "rival@other.test", 0)); + + var result = await WithRepositoryAsync(mine, repo => + repo.GetProvenanceAsync("Flock", [myEntity, theirEntity])); + + Assert.True(result.ContainsKey(myEntity)); + Assert.False(result.ContainsKey(theirEntity)); + } + + [Fact] + public async Task Provenance_IgnoresEventsOfAnotherEntityType() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + // The same id under two entity types: only the asked-for type counts. + var entityId = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, entityId, "Expense.Create", "bo@farm.test", 0, entityType: "Expense")); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [entityId])); + + Assert.Empty(result); + } + + [Fact] + public async Task Provenance_ForAnIdWithNoEvents_IsAbsentRatherThanBlank() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var known = Guid.NewGuid(); + var untouched = Guid.NewGuid(); + await SeedEventsAsync(accountId, Event(accountId, known, "Flock.Create", "ana@farm.test", 0)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [known, untouched])); + + Assert.True(result.ContainsKey(known)); + Assert.False(result.ContainsKey(untouched)); + } + + // The id list is a predicate on the read, not a filter applied afterwards. + // Dropping it left every requested record's answer CORRECT — the creator and + // the shared-draft set are both resolved per EntityId — so nothing else in + // this file noticed, and the dictionary quietly grew a row for every record + // of that type in the account. A caller enumerating it would then hand out + // provenance for records the page never asked about. + [Fact] + public async Task Provenance_ReturnsOnlyTheRecordsItWasAskedFor() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var asked = Guid.NewGuid(); + var notAsked = Guid.NewGuid(); + await SeedEventsAsync(accountId, + Event(accountId, asked, "Flock.Create", "ana@farm.test", 0), + Event(accountId, notAsked, "Flock.Create", "bo@farm.test", 0), + Event(accountId, notAsked, "Flock.Update", "bo@farm.test", 5)); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [asked])); + + Assert.Equal([asked], result.Keys.ToArray()); + } + + [Fact] + public async Task Provenance_WithNoIds_ReturnsEmptyWithoutQuerying() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", [])); + + Assert.Empty(result); + } + + // --- over the wire ----------------------------------------------------- + // + // One per extended list endpoint: the point of #494 is that provenance + // reaches the record's OWN page without a second call, so each of these + // asserts the creating user's email came back on the list row itself. + + private sealed record ProvenanceRowDto( + Guid Id, string? CreatedByEmail, DateTimeOffset? CreatedAtUtc, + string? LastChangedByEmail, DateTimeOffset? LastChangedAtUtc, + DateTimeOffset? MadeOfficialAtUtc); + private sealed record ExpenseListDto(List Items); + private sealed record CreatedDto(Guid Id); + + private async Task<(HttpClient Client, string Email)> AuthedAsync() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + await factory.SeedAccountWithUserAsync(email); + return (factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)), email); + } + + private static async Task CreatedIdAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!.Id; + } + + private static void AssertCreatedBy(ProvenanceRowDto row, string email) + { + Assert.Equal(email, row.CreatedByEmail); + Assert.NotNull(row.CreatedAtUtc); + // Never changed since creation: the trail's earliest and latest event + // are the same one, so the server reports no change at all. + Assert.Null(row.LastChangedByEmail); + Assert.Null(row.LastChangedAtUtc); + } + + [Fact] + public async Task FlockList_CarriesProvenance() + { + var (client, email) = await AuthedAsync(); + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/flocks", Guid.NewGuid().ToString(), + new { name = "Barn A", breed = "ISA Brown", placementDate = "2026-01-01", initialCount = 200 })); + + var rows = await client.GetFromJsonAsync>("/api/v1/flocks"); + + AssertCreatedBy(rows!.Single(r => r.Id == id), email); + } + + [Fact] + public async Task EggGradeList_CarriesProvenance() + { + var (client, email) = await AuthedAsync(); + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/egg-grades", Guid.NewGuid().ToString(), + new { name = "Jumbo", gradeType = "Custom", sortOrder = 7, isSaleable = true })); + + var rows = await client.GetFromJsonAsync>("/api/v1/egg-grades"); + + AssertCreatedBy(rows!.Single(r => r.Id == id), email); + } + + [Fact] + public async Task ExpenseList_CarriesProvenance() + { + var (client, email) = await AuthedAsync(); + var categoryId = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/expense-categories", Guid.NewGuid().ToString(), new { name = "Feed" })); + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/expenses", Guid.NewGuid().ToString(), new + { + expenseCategoryId = categoryId, + date = DateOnly.FromDateTime(DateTime.UtcNow.Date), + description = "Feed delivery", + amountMinorUnits = 25_00, + })); + + var list = await client.GetFromJsonAsync("/api/v1/expenses"); + + AssertCreatedBy(list!.Items.Single(r => r.Id == id), email); + } + + [Fact] + public async Task SalesOrderList_CarriesProvenance() + { + var (client, email) = await AuthedAsync(); + var customerId = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/customers", Guid.NewGuid().ToString(), + new { name = "Mercado Central", phone = "555-0100" })); + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/sales", Guid.NewGuid().ToString(), + new { customerId, orderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date) })); + + var rows = await client.GetFromJsonAsync>("/api/v1/sales"); + + AssertCreatedBy(rows!.Single(r => r.Id == id), email); + } + + private async Task<(HttpClient Client, string Email, Guid AccountId, Guid EntryId)> DraftDailyEntryAsync() + { + 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)); + + // #394: submit requires exact reconciliation, so the draft carries a + // grade line summing to the total — otherwise every submit here 422s + // and the tests below pass for the wrong reason. + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, + houseId = Guid.NewGuid(), + flockId, + date = DateOnly.FromDateTime(DateTime.UtcNow.Date), + totalEggs = 100, + crackedEggs = 0, + dirtyEggs = 0, + discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = 100 } }, + })); + return (client, email, accountId, id); + } + + private async Task<(HttpClient Client, string Email, Guid AccountId, Guid OrderId)> DraftSalesOrderAsync() + { + 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 productId = await factory.SeedProductAsync(accountId, farmId, grades["Large"], "Large Eggs"); + // Confirm allocates stock FIFO, so there has to be stock to draw from — + // otherwise every confirm below 422s and the tests pass for no reason. + await factory.SeedEggLotAsync(accountId, grades["Large"], 500); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + var customerId = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/customers", Guid.NewGuid().ToString(), + new { name = "Mercado Central", phone = "555-0100" })); + var orderId = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/sales", Guid.NewGuid().ToString(), + new { customerId, orderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date) })); + var addItem = await client.PostWithKeyAsync( + $"/api/v1/sales/{orderId}/items", Guid.NewGuid().ToString(), + new { productId, quantity = 10, unitPriceMinorUnits = 100 }); + // A silently-failing add-item leaves an empty order that cannot confirm, + // which would make every test built on this fixture vacuous. + Assert.Equal(HttpStatusCode.Created, addItem.StatusCode); + return (client, email, accountId, orderId); + } + + private async Task> ActionsForAsync(Guid accountId, Guid entityId, string entityType) => + await factory.WithTenantScopeAsync(accountId, async db => + await db.AuditEvents + .Where(e => e.EntityId == entityId && e.EntityType == entityType) + .Select(e => e.Action) + .ToListAsync()); + + // Confirming allocates stock FIFO, so it belongs on the trail for the same + // reason submitting a daily entry does. + [Fact] + public async Task SalesOrderConfirm_WritesAnAuditEvent() + { + var (client, _, accountId, orderId) = await DraftSalesOrderAsync(); + + var response = await client.PostWithKeyAsync( + $"/api/v1/sales/{orderId}/confirm", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Contains("SalesOrder.Confirm", await ActionsForAsync(accountId, orderId, "SalesOrder")); + } + + [Fact] + public async Task SalesOrderCancel_WritesAnAuditEvent() + { + var (client, _, accountId, orderId) = await DraftSalesOrderAsync(); + + var response = await client.PostWithKeyAsync( + $"/api/v1/sales/{orderId}/cancel", Guid.NewGuid().ToString()); + // 204, unlike confirm's 200 — cancel returns no body. + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.Contains("SalesOrder.Cancel", await ActionsForAsync(accountId, orderId, "SalesOrder")); + } + + // End to end: one person drafts an order and confirms it. Two events, one + // act — the row still reports a creator and no change. + [Fact] + public async Task SalesOrderList_WhenTheCreatorConfirmsTheirOwnOrder_ReportsNoChange() + { + var (client, email, _, orderId) = await DraftSalesOrderAsync(); + // Asserted, not discarded — see the daily-entry twin above. + var confirm = await client.PostWithKeyAsync( + $"/api/v1/sales/{orderId}/confirm", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.OK, confirm.StatusCode); + + var rows = await client.GetFromJsonAsync>("/api/v1/sales"); + + var row = rows!.Single(r => r.Id == orderId); + AssertCreatedBy(row, email); + Assert.NotNull(row.MadeOfficialAtUtc); + } + + [Fact] + public async Task DailyEntryList_CarriesProvenance() + { + var (client, email, _, id) = await DraftDailyEntryAsync(); + + var rows = await client.GetFromJsonAsync>("/api/v1/daily-entries"); + + AssertCreatedBy(rows!.Single(r => r.Id == id), email); + } + + // Submitting is a stock-altering transition (it mints the egg lots), so it + // belongs in the trail on its own merits. It is also the ONLY source for the + // "who made this official" half of a daily entry's record history — without + // this event, a manager submitting a worker's draft leaves no trace at all. + // Re-recording against the same natural key rewrites an existing draft. It + // moves no stock, but it is the only thing binding a person to the numbers, + // so it is on the trail — see Provenance_WhenSomeoneElseEditsTheDraft_... + [Fact] + public async Task DailyEntryDraftEdit_WritesAnAuditEvent() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var farmId = Guid.NewGuid(); + var houseId = 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)); + + // Same natural key both times, so the second call appends to the first + // entry rather than creating a second one. + object Body(int total) => new + { + farmId, houseId, flockId, + date = DateOnly.FromDateTime(DateTime.UtcNow.Date), + totalEggs = total, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = total } }, + }; + + var id = await CreatedIdAsync(await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), Body(100))); + var again = await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), Body(500)); + Assert.Equal(HttpStatusCode.Created, again.StatusCode); + + var actions = await ActionsForAsync(accountId, id, "DailyEntry"); + Assert.Contains("DailyEntry.Update", actions); + // Still exactly one creation: two would give the entry two candidate + // authors and let the later one win. + Assert.Single(actions, a => a == "DailyEntry.Create"); + } + + [Fact] + public async Task DailyEntrySubmit_WritesAnAuditEvent() + { + var (client, _, accountId, id) = await DraftDailyEntryAsync(); + + var response = await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.Contains("DailyEntry.Submit", await ActionsForAsync(accountId, id, "DailyEntry")); + } + + // The end-to-end shape of #494's answer: one person writes the day's numbers + // and makes them official, having changed nothing in between. Two audit + // events, one act — the row still reports a creator and NO change. + // + // Load-bearing only once the submit event above exists; before that it would + // pass for the wrong reason. Mutation-checked by removing the self-submit + // exclusion from AuditEventRepository, which turns this red. + [Fact] + public async Task DailyEntryList_WhenTheCreatorSubmitsTheirOwnDraft_ReportsNoChange() + { + var (client, email, _, id) = await DraftDailyEntryAsync(); + // Asserted, not discarded: a submit that started 422ing would leave the + // trail at ".Create" only, and this test would stay green while pinning + // nothing (adversarial review of PR #503). + var submit = await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.OK, submit.StatusCode); + + var rows = await client.GetFromJsonAsync>("/api/v1/daily-entries"); + + var row = rows!.Single(r => r.Id == id); + AssertCreatedBy(row, email); + // The submit is excluded from "last changed by", but WHEN it happened + // still has to reach the page — it is the instant the eggs entered + // stock, and the entry stores no SubmittedAt of its own. + Assert.NotNull(row.MadeOfficialAtUtc); + } + + // Most callers hand this a clamped page, but the egg-grade list has no + // pagination at all and grades are never deleted — a farm's catalog grows + // past any fixed cap eventually. So the batch size is an internal chunking + // detail, NOT a caller contract: more ids than fit in one round trip must + // still answer correctly rather than fail the whole list endpoint. + [Fact] + public async Task Provenance_AboveTheBatchSize_StillResolvesEveryId() + { + var accountId = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local"); + var ids = Enumerable.Range(0, IAuditEventRepository.MaxBatchIds + 25) + .Select(_ => Guid.NewGuid()).ToArray(); + // Seed an event for every id so a dropped chunk shows up as a missing key. + await SeedEventsAsync(accountId, + ids.Select(id => Event(accountId, id, "Flock.Create", "ana@farm.test", 0)).ToArray()); + + var result = await WithRepositoryAsync(accountId, repo => + repo.GetProvenanceAsync("Flock", ids)); + + Assert.Equal(ids.Length, result.Count); + Assert.All(ids, id => Assert.Equal("ana@farm.test", result[id].CreatedByEmail)); + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs b/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs index e2185dab4..8becca3f2 100644 --- a/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/AuditTests.cs @@ -56,8 +56,11 @@ public async Task Adjust_WritesEvent_WithActorAndReason_FailedAdjustWritesNothin }); Assert.Equal(HttpStatusCode.Conflict, failed.StatusCode); + // Scoped to the adjust action: recording the entry legitimately left a + // DailyEntry.Create event (#494), and this test is about what the + // FAILED ADJUST wrote, which is nothing. var afterFailure = await client.GetFromJsonAsync>( - $"/api/v1/audit?entityId={entryId}"); + $"/api/v1/audit?entityId={entryId}&action=DailyEntry.Adjust"); Assert.Empty(afterFailure!); // The successful adjust writes exactly one, with actor + reason. @@ -69,7 +72,8 @@ public async Task Adjust_WritesEvent_WithActorAndReason_FailedAdjustWritesNothin }); Assert.Equal(HttpStatusCode.OK, ok.StatusCode); - var rows = await client.GetFromJsonAsync>($"/api/v1/audit?entityId={entryId}"); + var rows = await client.GetFromJsonAsync>( + $"/api/v1/audit?entityId={entryId}&action=DailyEntry.Adjust"); var row = Assert.Single(rows!); Assert.Equal("DailyEntry.Adjust", row.Action); Assert.Equal("DailyEntry", row.EntityType); @@ -136,7 +140,9 @@ public async Task Viewer_NeverCrossesTenants() version, totalEggs = 40, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0, reason = "tenant A only" }); - Assert.Single((await clientA.GetFromJsonAsync>($"/api/v1/audit?entityId={entryId}"))!); + // A's own trail is non-empty (create + adjust), which is what makes B's + // emptiness below meaningful rather than vacuous. + Assert.NotEmpty((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(); diff --git a/tests/Cluckwork.Api.IntegrationTests/CustomerAndOrderTests.cs b/tests/Cluckwork.Api.IntegrationTests/CustomerAndOrderTests.cs index c259f8720..66c02b3f6 100644 --- a/tests/Cluckwork.Api.IntegrationTests/CustomerAndOrderTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/CustomerAndOrderTests.cs @@ -2,6 +2,7 @@ namespace Cluckwork.Api.IntegrationTests; using System.Net; using Cluckwork.Api.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; // #10 + #11 — customers and the order create/add-item surface. The final test // drives the whole MVP loop through the public API alone (no harness seeding @@ -443,4 +444,25 @@ await client.PostWithKeyAsync( Assert.Equal(350, stock.Single(s => s.EggGradeId == grades["Large"]).Available); Assert.Equal(200, stock.Single(s => s.EggGradeId == grades["Medium"]).Available); } + + // #494 — creation wasn't on the audit trail at all; only corrections were. + [Fact] + public async Task SalesOrder_Create_WritesAuditEvent() + { + var (client, accountId, _, _, _) = await SetupAsync("Large"); + + var customerId = await CreatedId(await client.PostWithKeyAsync( + "/api/v1/customers", Guid.NewGuid().ToString(), + new { name = "Mercado Central", phone = "555-0100" })); + var orderId = await CreatedId(await client.PostWithKeyAsync( + "/api/v1/sales", Guid.NewGuid().ToString(), + new { customerId, orderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date) })); + + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "SalesOrder" && e.EntityId == orderId) + .ToListAsync()); + + var created = Assert.Single(events); + Assert.Equal("SalesOrder.Create", created.Action); + } } diff --git a/tests/Cluckwork.Api.IntegrationTests/EggGradeManagementTests.cs b/tests/Cluckwork.Api.IntegrationTests/EggGradeManagementTests.cs index a52e74ec0..875902da5 100644 --- a/tests/Cluckwork.Api.IntegrationTests/EggGradeManagementTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/EggGradeManagementTests.cs @@ -221,5 +221,25 @@ public async Task Grade_ParallelCreates_SameName_ExactlyOneWins() Assert.Single(all!, g => g.Name.Equals("duplo", StringComparison.OrdinalIgnoreCase)); } + // #494 — creation wasn't on the audit trail at all; only corrections were. + [Fact] + public async Task Grade_Create_WritesAuditEvent() + { + var (client, accountId) = await SetupAsync(); + + var create = await client.PostWithKeyAsync( + "/api/v1/egg-grades", Guid.NewGuid().ToString(), + new { name = "Jumbo", gradeType = "Custom", sortOrder = 7, isSaleable = true }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var id = (await create.Content.ReadFromJsonAsync())!.Id; + + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "EggGrade" && e.EntityId == id) + .ToListAsync()); + + var created = Assert.Single(events); + Assert.Equal("EggGrade.Create", created.Action); + } + private sealed record FlockDto(Guid Id, Guid FarmId, Guid HouseId, string Name); } diff --git a/tests/Cluckwork.Api.IntegrationTests/ExpensesTests.cs b/tests/Cluckwork.Api.IntegrationTests/ExpensesTests.cs index 8496d7896..a5dcf1e2a 100644 --- a/tests/Cluckwork.Api.IntegrationTests/ExpensesTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/ExpensesTests.cs @@ -2,6 +2,7 @@ namespace Cluckwork.Api.IntegrationTests; using System.Net; using Cluckwork.Api.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; // #87 — basic expenses: farm-scoped category catalog (grade pattern) and // money-out records with a snapshotted currency, server-side period totals, @@ -240,4 +241,23 @@ public async Task ParallelAdjusts_SameBaseVersion_ExactlyOneWins() Assert.True(after.AmountMinorUnits is 60_00 or 40_00); Assert.Equal(after.AmountMinorUnits == 60_00 ? "first" : "second", after.Description); } + + // #494 — creation wasn't on the audit trail at all; only corrections were. + [Fact] + public async Task Expense_Create_WritesAuditEvent() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + var categoryId = await CreateCategoryAsync(client, "Feed"); + var id = await CreateExpenseAsync(client, categoryId, 25_00); + + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "Expense" && e.EntityId == id) + .ToListAsync()); + + var created = Assert.Single(events); + Assert.Equal("Expense.Create", created.Action); + } } diff --git a/tests/Cluckwork.Api.IntegrationTests/FlockManagementTests.cs b/tests/Cluckwork.Api.IntegrationTests/FlockManagementTests.cs index 9d12e8aac..29e949b1f 100644 --- a/tests/Cluckwork.Api.IntegrationTests/FlockManagementTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/FlockManagementTests.cs @@ -97,6 +97,21 @@ public async Task Flock_ArchiveFromActive_Allowed() Assert.Equal(HttpStatusCode.NoContent, archive.StatusCode); } + // #494 — creation wasn't on the audit trail at all; only corrections were. + [Fact] + public async Task Flock_Create_WritesAuditEvent() + { + var (client, accountId) = await SetupAsync(); + var id = await CreateFlockAsync(client); + + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "Flock" && e.EntityId == id) + .ToListAsync()); + + var created = Assert.Single(events); + Assert.Equal("Flock.Create", created.Action); + } + // #35 review: the 422 lifecycle branch above can no longer be reached with a // future date, so reach it the way real farms do — a flock depleted days ago // refuses an entry dated after that depletion but still in the past. Deplete diff --git a/tests/Cluckwork.Api.IntegrationTests/SubmitDailyEntryTests.cs b/tests/Cluckwork.Api.IntegrationTests/SubmitDailyEntryTests.cs index d6e4437a5..b01c1dc05 100644 --- a/tests/Cluckwork.Api.IntegrationTests/SubmitDailyEntryTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/SubmitDailyEntryTests.cs @@ -279,4 +279,62 @@ public async Task FullLoop_RecordSubmitSellConfirm_StockDecrements() Assert.Equal(600, lot.QuantityProduced); Assert.Equal(350, lot.QuantityAvailable); } + + // #494 — creation wasn't on the audit trail at all; only corrections were. + [Fact] + public async Task DailyEntry_Record_WritesCreateAuditEvent() + { + var (client, accountId, farmId, flockId, grades) = await SetupAsync("Large"); + var entryId = await RecordAsync(client, Body(farmId, flockId, + [new { eggGradeId = grades["Large"], quantity = 600 }])); + + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "DailyEntry" && e.EntityId == entryId) + .ToListAsync()); + + var created = Assert.Single(events); + Assert.Equal("DailyEntry.Create", created.Action); + } + + // The gate: re-recording against the same natural key APPENDS to the + // existing entry — that is not a creation and must not emit a second event. + [Fact] + public async Task DailyEntry_ReRecordedOnTheSameKey_WritesOnlyOneCreateEvent() + { + var (client, accountId, farmId, flockId, grades) = await SetupAsync("Large"); + var houseId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow.Date); + object Body2(int total, int quantity) => new + { + farmId, + houseId, + flockId, + date, + totalEggs = total, + crackedEggs = 10, + dirtyEggs = 5, + discardedEggs = 3, + mortalityCount = 0, + grades = new object[] { new { eggGradeId = grades["Large"], quantity } }, + }; + + var entryId = await RecordAsync(client, Body2(618, 600)); + var again = await RecordAsync(client, Body2(718, 700)); + Assert.Equal(entryId, again); + + // Ordered explicitly: the assertion below compares a sequence, and an + // unordered query gives Postgres no obligation to return one. + var events = await factory.WithTenantScopeAsync(accountId, db => db.AuditEvents + .Where(e => e.EntityType == "DailyEntry" && e.EntityId == entryId) + .OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.Action) + .Select(e => e.Action) + .ToListAsync()); + + // The re-record DOES leave a trace now — a draft edit is attributable, + // so that rewriting a colleague's numbers before submission is visible + // (#494). What must never happen is a second CREATION, which would give + // the entry two candidate authors and let the later one win. + Assert.Equal(["DailyEntry.Create", "DailyEntry.Update"], events); + Assert.Single(events, a => a == "DailyEntry.Create"); + } } diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index ff7360240..3714d8dd2 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -1,7 +1,30 @@ // Typed wrappers over the Cluckwork JSON API (mirrors the endpoint DTOs). import { apiDelete, apiGet, apiGetBlob, apiPost, apiPut, apiPutBytes, STEP_UP_HEADER } from "./client"; -export interface EggGrade { +/** + * #494 — who created a record and who last changed it, derived server-side + * from the audit trail rather than stored on the record. + * + * `createdBy*` are null for a record created before #494 shipped: there is no + * backfill, so its trail has no creation event to report. + * + * `lastChanged*` are null when nothing has happened since creation. That is the + * SERVER's judgement, made from the audit event ids — do NOT re-derive it by + * comparing the two timestamps, because two distinct events can share an + * instant and that comparison would hide a real edit. + */ +export interface RecordHistory { + createdByEmail: string | null; + createdAtUtc: string | null; + lastChangedByEmail: string | null; + lastChangedAtUtc: string | null; + // #494 — when the record became official. Only resources with a promotion + // step send it (daily entries submit, sales orders confirm), hence optional: + // flocks, egg grades and expenses have no such moment. + madeOfficialAtUtc?: string | null; +} + +export interface EggGrade extends RecordHistory { id: string; farmId: string; name: string; @@ -20,7 +43,7 @@ export interface EggGrade { active: boolean; } -export interface Flock { +export interface Flock extends RecordHistory { id: string; farmId: string; houseId: string; @@ -48,7 +71,7 @@ export interface GradeLine { // version = the base an admin correction must send back (stale → 409); // adjustedFrom = audit snapshot of the values the last adjust replaced. -export interface DailyEntry { +export interface DailyEntry extends RecordHistory { id: string; farmId: string; houseId: string; @@ -284,7 +307,7 @@ export interface OrderItem { currencyMinorUnit: number; } -export interface SalesOrder { +export interface SalesOrder extends RecordHistory { id: string; customerId: string; referenceNumber: string; @@ -1016,7 +1039,7 @@ export interface ExpenseCategory { active: boolean; } -export interface Expense { +export interface Expense extends RecordHistory { id: string; farmId: string; expenseCategoryId: string; diff --git a/web/src/components/ProvenanceCell.test.tsx b/web/src/components/ProvenanceCell.test.tsx new file mode 100644 index 000000000..948385acc --- /dev/null +++ b/web/src/components/ProvenanceCell.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProvenanceCell } from "./ProvenanceCell"; + +// #494 — the cell reports the two ends of a record's audit trail. Whether a +// change happened is the SERVER's call: it sends lastChanged* only when the +// latest event differs from the creation event, and null otherwise. So the +// unchanged case arrives as nulls, and the component must never re-derive it by +// comparing timestamps — distinct events can share an instant. +const CREATED = "2026-05-01T08:00:00+00:00"; +const CHANGED = "2026-05-03T14:30:00+00:00"; + +const OFFICIAL = "2026-05-05T09:15:00+00:00"; + +function renderCell( + history: Parameters[0]["history"], + official?: Parameters[0]["official"], +) { + return render( + + + + + + +
, + ); +} + +describe("ProvenanceCell", () => { + it("names the creator and when", () => { + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + }); + expect(screen.getByText(/ana@farm\.test/)).toBeInTheDocument(); + }); + + it("says nothing about a change when the record has never been changed", () => { + // The server sends nulls for an unchanged record; repeating the creator + // would read as an edit that never happened. + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + }); + expect(screen.queryByText(/Last changed/i)).not.toBeInTheDocument(); + }); + + it("names the last changer separately once the record has been changed", () => { + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: "bo@farm.test", + lastChangedAtUtc: CHANGED, + }); + expect(screen.getByText(/ana@farm\.test/)).toBeInTheDocument(); + expect(screen.getByText(/bo@farm\.test/)).toBeInTheDocument(); + }); + + it("shows a change by the SAME actor at a later time", () => { + // Same person, second edit: equality of the EMAIL alone must not suppress + // the line — only the instant tells the two events apart. + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: "ana@farm.test", + lastChangedAtUtc: CHANGED, + }); + expect(screen.getByText(/Last changed/i)).toBeInTheDocument(); + }); + + it("shows a change made in the SAME instant as the creation", () => { + // Two distinct audit events can share a timestamp — the server's queries + // carry an id tiebreaker precisely because of it, and a seeder running off + // a fixed clock produces it readily. Telling the two apart by instant alone + // would call this record untouched and hide a real edit by someone else. + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: "bo@farm.test", + lastChangedAtUtc: CREATED, + }); + expect(screen.getByText(/bo@farm\.test/)).toBeInTheDocument(); + }); + + it("renders a placeholder only when there is nothing at all to say", () => { + renderCell({ + createdByEmail: null, + createdAtUtc: null, + lastChangedByEmail: null, + lastChangedAtUtc: null, + }); + expect(screen.queryByText(/Created by/i)).not.toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("still reports the change on a record whose creation predates the feature", () => { + // No backfill, so no creator line — but the change has real attribution and + // must not go down with it. An earlier revision rendered this whole cell as + // the placeholder, discarding cy entirely. + renderCell({ + createdByEmail: null, + createdAtUtc: null, + lastChangedByEmail: "cy@farm.test", + lastChangedAtUtc: CHANGED, + }); + expect(screen.getByText(/cy@farm\.test/)).toBeInTheDocument(); + expect(screen.queryByText(/Created by/i)).not.toBeInTheDocument(); + expect(screen.queryByText("—")).not.toBeInTheDocument(); + }); + + // #494 — the promotion instant. A self-submit is excluded from "last changed + // by", so without this line the moment stock was minted would appear nowhere + // on the record's own page. + it("reports when a daily entry was submitted, even with no change to report", () => { + renderCell( + { + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + madeOfficialAtUtc: OFFICIAL, + }, + "submitted", + ); + expect(screen.getByText(/Submitted 2026-05-05 09:15:00/)).toBeInTheDocument(); + expect(screen.queryByText(/Last changed/i)).not.toBeInTheDocument(); + }); + + it("calls it confirmed on a sales order, not submitted", () => { + renderCell( + { + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + madeOfficialAtUtc: OFFICIAL, + }, + "confirmed", + ); + expect(screen.getByText(/Confirmed 2026-05-05 09:15:00/)).toBeInTheDocument(); + expect(screen.queryByText(/Submitted/i)).not.toBeInTheDocument(); + }); + + it("stays silent on a resource with no promotion step", () => { + // Flocks, egg grades and expenses pass no `official`, so the line cannot + // render even if the field somehow arrived — the caller declares whether + // the concept applies at all. + renderCell({ + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + madeOfficialAtUtc: OFFICIAL, + }); + expect(screen.queryByText(/Submitted|Confirmed/i)).not.toBeInTheDocument(); + }); + + it("stays silent on a draft that has not been submitted yet", () => { + renderCell( + { + createdByEmail: "ana@farm.test", + createdAtUtc: CREATED, + lastChangedByEmail: null, + lastChangedAtUtc: null, + madeOfficialAtUtc: null, + }, + "submitted", + ); + expect(screen.queryByText(/Submitted/i)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ProvenanceCell.tsx b/web/src/components/ProvenanceCell.tsx new file mode 100644 index 000000000..06678c867 --- /dev/null +++ b/web/src/components/ProvenanceCell.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from "react-i18next"; +import type { RecordHistory } from "../api/cluckwork"; + +// #494 — one table cell reporting who created a record and who last changed it. +// +// Whether a change happened at all is the SERVER's call: it sends +// lastChanged* only when the trail's latest event is a different event from the +// creation, and null otherwise. This component must not re-derive that by +// comparing timestamps — two distinct events can share an instant, and that +// comparison would hide a real edit (codex review of #494). +// +// Timestamp rendering follows AuditPage's existing convention (UTC, trimmed to +// seconds) rather than the farm clock: these are audit instants, and the trail +// is displayed in UTC everywhere else it appears. +function formatInstant(iso: string): string { + return iso.replace("T", " ").slice(0, 19); +} + +// `official` names the promotion step this record HAS — submitting a daily +// entry, confirming a sales order. Records with no such step (flocks, egg +// grades, expenses) pass nothing and can never render the line, even if a +// madeOfficialAtUtc somehow arrived. +// +// A full phrase per step rather than a shared "{{label}} {{at}}" template: +// composing a verb with a date puts word order in the caller's hands, which is +// exactly what a translator needs to control. +export function ProvenanceCell({ + history, + official, +}: { + history: RecordHistory; + official?: "submitted" | "confirmed"; +}) { + const { t } = useTranslation("common"); + const { createdByEmail, createdAtUtc, lastChangedByEmail, lastChangedAtUtc } = history; + + // The three lines are INDEPENDENT. A record predating #494 has no creation + // event and never gets one, but it can still carry a change with real + // attribution — so a missing creator hides that one line, not the whole cell. + // The placeholder is for a record with nothing at all to say. + const created = createdByEmail && createdAtUtc; + const changed = lastChangedByEmail && lastChangedAtUtc; + const officialAt = official && history.madeOfficialAtUtc; + + if (!created && !changed && !officialAt) { + return —; + } + + return ( + + {created && ( +
{t("recordHistory.createdBy", { + email: createdByEmail, + at: formatInstant(createdAtUtc), + })}
+ )} + {officialAt && ( +
{t( + official === "submitted" + ? "recordHistory.submittedAt" + : "recordHistory.confirmedAt", + { at: formatInstant(officialAt) }, + )}
+ )} + {changed && ( +
{t("recordHistory.lastChangedBy", { + email: lastChangedByEmail, + at: formatInstant(lastChangedAtUtc), + })}
+ )} + + ); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 12e6864e9..b31c48c4d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -4,6 +4,16 @@ // is a build error. export const en = { common: { + // #494 — the record-history cell shown on the list screens. "Last changed" + // appears only when something happened after creation. + "recordHistory.createdBy": "Created by {{email}} on {{at}}", + "recordHistory.lastChangedBy": "Last changed by {{email}} on {{at}}", + // #494 — when the record became official. Shown even when the person who + // submitted or confirmed it is the same person who created it, because that + // case is excluded from "last changed by" and the instant would be lost. + "recordHistory.submittedAt": "Submitted {{at}}", + "recordHistory.confirmedAt": "Confirmed {{at}}", + recordHistoryHeader: "History", cancel: "Cancel", save: "Save", close: "Close", @@ -2039,6 +2049,18 @@ export const en = { "auditAction.Product.Deactivate": "Product deactivated", "auditAction.EggUnitConversion.Update": "Egg unit conversion updated", "auditAction.EggLot.Movement": "Stock written off / recounted", + "auditAction.Flock.Create": "Flock created", + "auditAction.DailyEntry.Create": "Daily entry created", + "auditAction.DailyEntry.Update": "Daily entry draft edited", + "auditAction.DailyEntry.Submit": "Daily entry submitted", + "auditAction.SalesOrder.AddItem": "Order line added", + "auditAction.SalesOrder.UpdateItem": "Order line changed", + "auditAction.SalesOrder.RemoveItem": "Order line removed", + "auditAction.SalesOrder.Create": "Sales order created", + "auditAction.SalesOrder.Confirm": "Sales order confirmed", + "auditAction.SalesOrder.Cancel": "Sales order cancelled", + "auditAction.Expense.Create": "Expense created", + "auditAction.EggGrade.Create": "Egg grade created", // entity type (AuditPage table entity cell) — AuditEvent.entityType. "entityType.Account": "Account", @@ -2522,6 +2544,26 @@ export const en = { + "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.", + auditRecordHistory: + "Flocks, Egg grades, Daily entry history, Sales and Expenses each carry a History column showing who " + + "created the record and when, plus who last changed it if anyone has. It is read from the same audit " + + "log, so it needs no extra permission: if you can see the record, you can see its history.", + auditRecordHistorySubmit: + "Saving a daily entry and submitting it is one act, not a change: do both yourself and the History " + + "column names you as the creator with no change against you. If somebody else submits your draft, " + + "their submit shows as the last change, so you can always see who made a day's numbers official. " + + "Either way the History column tells you WHEN it was submitted, since that is when the eggs " + + "entered stock. Confirming a sales order works the same way, and shows when stock was allocated. " + + "Correcting an entry after it is locked always shows, " + + "even if you created it, and so does cancelling your own draft order. Editing your own draft before " + + "submitting it stays hidden — that is just writing it. But if somebody else edits your draft, their " + + "change is shown, so the person whose numbers went into stock is never invisible. And once they " + + "have, your own later edits are shown too: if you put the numbers back the way you wanted them, " + + "the column names you and the time you did it, not the edit you overwrote.", + auditRecordHistoryOlder: + "Records created before this was added show no \"created by\" line — there is simply no creation " + + "entry in the log for them, and none is invented after the fact. If such a record has been changed " + + "since, that change still shows; the History column is empty only when there is nothing at all.", // Export & backup exportHeading: "Export & backup (admin)", diff --git a/web/src/i18n/enums.ts b/web/src/i18n/enums.ts index 780822e18..8f87e1796 100644 --- a/web/src/i18n/enums.ts +++ b/web/src/i18n/enums.ts @@ -290,6 +290,10 @@ export const AUDIT_ACTION_VALUES = [ "Account.SetBanner", "Account.RemoveBanner", "Account.UpdateSettings", "Product.Create", "Product.Update", "Product.Activate", "Product.Deactivate", "EggUnitConversion.Update", "EggLot.Movement", + "Flock.Create", "DailyEntry.Create", "DailyEntry.Update", "DailyEntry.Submit", + "SalesOrder.Create", "SalesOrder.Confirm", "SalesOrder.Cancel", + "SalesOrder.AddItem", "SalesOrder.UpdateItem", "SalesOrder.RemoveItem", + "Expense.Create", "EggGrade.Create", ] as const; export type AuditActionValue = (typeof AUDIT_ACTION_VALUES)[number]; const AUDIT_ACTION_KEYS = { @@ -331,6 +335,18 @@ const AUDIT_ACTION_KEYS = { "Product.Deactivate": "enums:auditAction.Product.Deactivate", "EggUnitConversion.Update": "enums:auditAction.EggUnitConversion.Update", "EggLot.Movement": "enums:auditAction.EggLot.Movement", + "Flock.Create": "enums:auditAction.Flock.Create", + "DailyEntry.Create": "enums:auditAction.DailyEntry.Create", + "DailyEntry.Update": "enums:auditAction.DailyEntry.Update", + "DailyEntry.Submit": "enums:auditAction.DailyEntry.Submit", + "SalesOrder.AddItem": "enums:auditAction.SalesOrder.AddItem", + "SalesOrder.UpdateItem": "enums:auditAction.SalesOrder.UpdateItem", + "SalesOrder.RemoveItem": "enums:auditAction.SalesOrder.RemoveItem", + "SalesOrder.Create": "enums:auditAction.SalesOrder.Create", + "SalesOrder.Confirm": "enums:auditAction.SalesOrder.Confirm", + "SalesOrder.Cancel": "enums:auditAction.SalesOrder.Cancel", + "Expense.Create": "enums:auditAction.Expense.Create", + "EggGrade.Create": "enums:auditAction.EggGrade.Create", } as const satisfies Record; export function auditActionLabel(value: AuditActionValue | (string & {})): string { const key = AUDIT_ACTION_KEYS[value as AuditActionValue]; diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 637c0d250..d2f98e146 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -5,6 +5,11 @@ // on-screen label, until that screen itself is externalized to the catalog (#182). export const es = { common: { + "recordHistory.createdBy": "Creado por {{email}} el {{at}}", + "recordHistory.lastChangedBy": "Última modificación por {{email}} el {{at}}", + "recordHistory.submittedAt": "Enviado el {{at}}", + "recordHistory.confirmedAt": "Confirmado el {{at}}", + recordHistoryHeader: "Historial", cancel: "Cancelar", save: "Guardar", close: "Cerrar", @@ -1622,6 +1627,18 @@ export const es = { "auditAction.Product.Deactivate": "Producto desactivado", "auditAction.EggUnitConversion.Update": "Conversión de unidad de huevo actualizada", "auditAction.EggLot.Movement": "Existencias dadas de baja / recontadas", + "auditAction.Flock.Create": "Lote creado", + "auditAction.DailyEntry.Create": "Registro diario creado", + "auditAction.DailyEntry.Update": "Borrador de registro diario editado", + "auditAction.DailyEntry.Submit": "Registro diario enviado", + "auditAction.SalesOrder.AddItem": "Línea de pedido agregada", + "auditAction.SalesOrder.UpdateItem": "Línea de pedido modificada", + "auditAction.SalesOrder.RemoveItem": "Línea de pedido eliminada", + "auditAction.SalesOrder.Create": "Pedido de venta creado", + "auditAction.SalesOrder.Confirm": "Pedido de venta confirmado", + "auditAction.SalesOrder.Cancel": "Pedido de venta cancelado", + "auditAction.Expense.Create": "Gasto creado", + "auditAction.EggGrade.Create": "Grado de huevo creado", "entityType.Account": "Cuenta", "entityType.DailyEntry": "Entrada diaria", @@ -2118,6 +2135,29 @@ export const es = { + "automáticamente: quién lo hizo, cuándo (UTC), qué afectó, y el motivo cuando se dio uno. Se " + "escribe junto con el cambio mismo — una acción fallida no deja rastro, una exitosa siempre lo hace " + "— y nunca es editable, por nadie.", + auditRecordHistory: + "Lotes, Grados de huevo, Historial de registro diario, Ventas y Gastos tienen una columna Historial " + + "que muestra quién creó el registro y cuándo, más quién lo modificó por última vez si alguien lo " + + "hizo. Se lee del mismo registro de auditoría, así que no requiere permisos adicionales: si puede " + + "ver el registro, puede ver su historial.", + auditRecordHistorySubmit: + "Guardar un registro diario y enviarlo es un solo acto, no un cambio: si haces ambas cosas tú, la " + + "columna Historial te muestra como creador y no señala ningún cambio. Si otra persona envía tu " + + "borrador, ese envío aparece como el último cambio, así siempre se sabe quién oficializó los " + + "números del día. En ambos casos la columna Historial indica CUÁNDO se envió, porque ese es el " + + "momento en que los huevos entraron en existencias. Confirmar un pedido de venta funciona igual y " + + "muestra cuándo se asignaron las existencias. Corregir un registro ya bloqueado " + + "siempre se muestra, aunque tú lo hayas creado, y cancelar tu propio pedido en borrador también. " + + "Editar tu propio borrador antes de enviarlo queda oculto: eso es simplemente escribirlo. Pero si " + + "otra persona edita tu borrador, su cambio sí se muestra, para que nunca sea invisible quien puso " + + "los números que entraron en existencias. Y a partir de ese momento también se muestran tus " + + "propias ediciones posteriores: si dejas los números como querías, la columna te nombra a ti y " + + "marca la hora en que lo hiciste, no la edición que reemplazaste.", + auditRecordHistoryOlder: + "Los registros creados antes de que esto se agregara no muestran la línea de \"creado por\": " + + "simplemente no hay una entrada de creación en el registro para ellos, y no se inventa ninguna " + + "después. Si un registro así se modificó luego, ese cambio sí se muestra; la columna Historial " + + "queda vacía solo cuando no hay absolutamente nada.", exportHeading: "Exportar y respaldo (admin)", exportCsv: diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 03403a185..eb69410ca 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -10,6 +10,11 @@ // label, until that screen itself is externalized to the catalog (#182). export const tl = { common: { + "recordHistory.createdBy": "Ginawa ni {{email}} noong {{at}}", + "recordHistory.lastChangedBy": "Huling binago ni {{email}} noong {{at}}", + "recordHistory.submittedAt": "Naisumite noong {{at}}", + "recordHistory.confirmedAt": "Nakumpirma noong {{at}}", + recordHistoryHeader: "Kasaysayan", cancel: "Kanselahin", save: "I-save", close: "Isara", @@ -1685,6 +1690,18 @@ export const tl = { "auditAction.Product.Deactivate": "Na-deactivate ang produkto", "auditAction.EggUnitConversion.Update": "Na-update ang conversion ng yunit ng itlog", "auditAction.EggLot.Movement": "Na-write off / na-recount ang stock", + "auditAction.Flock.Create": "Nagawa ang kawan", + "auditAction.DailyEntry.Create": "Nagawa ang pang-araw-araw na entry", + "auditAction.DailyEntry.Update": "Na-edit ang draft ng pang-araw-araw na entry", + "auditAction.DailyEntry.Submit": "Naisumite ang pang-araw-araw na entry", + "auditAction.SalesOrder.AddItem": "Nagdagdag ng linya sa order", + "auditAction.SalesOrder.UpdateItem": "Binago ang linya ng order", + "auditAction.SalesOrder.RemoveItem": "Inalis ang linya ng order", + "auditAction.SalesOrder.Create": "Nagawa ang order ng benta", + "auditAction.SalesOrder.Confirm": "Nakumpirma ang order ng benta", + "auditAction.SalesOrder.Cancel": "Kinansela ang order ng benta", + "auditAction.Expense.Create": "Nagawa ang gastos", + "auditAction.EggGrade.Create": "Nagawa ang grado ng itlog", "entityType.Account": "Account", "entityType.DailyEntry": "Araw-araw na Entry", @@ -2199,6 +2216,30 @@ export const tl = { + "awtomatiko: sino ang gumawa nito, kailan (UTC), ano ang naapektuhan, at ang dahilan kung mayroon. " + "Isinusulat ito kasabay ng pagbabago mismo — walang naiiwang bakas ang isang nabigong aksyon, " + "palaging may naiiwan ang isang matagumpay — at hindi kailanman ine-edit, ng kahit sino.", + auditRecordHistory: + "Ang Mga kawan, Grado ng itlog, Kasaysayan ng pang-araw-araw na entry, Benta at Gastos ay may " + + "kolum na Kasaysayan na nagpapakita kung sino ang gumawa ng record at kailan, at kung sino ang " + + "huling nagbago kung mayroon man. Galing ito sa parehong audit log, kaya walang kailangang dagdag " + + "na pahintulot: kung nakikita mo ang record, nakikita mo ang kasaysayan nito.", + auditRecordHistorySubmit: + "Ang pag-save at pagsumite ng pang-araw-araw na entry ay iisang gawa, hindi pagbabago: kung ikaw " + + "mismo ang gumawa ng dalawa, ipinapakita ka ng column na History bilang tagalikha at walang " + + "nakalistang pagbabago. Kung iba ang nagsumite ng iyong draft, lalabas ang pagsumite nila bilang " + + "huling pagbabago, kaya laging makikita kung sino ang nag-opisyal sa bilang ng araw na iyon. Ganito " + + "rin ang pagkumpirma ng order ng benta. Sa alinmang paraan, ipinapakita ng column na History kung " + + "KAILAN ito naisumite, dahil iyon ang sandaling pumasok sa stock ang mga itlog, at kung kailan " + + "inilaan ang stock para sa order. Ang pagwawasto sa naka-lock na entry ay laging ipinapakita, " + + "kahit ikaw ang gumawa nito, gayundin ang pagkansela ng sarili mong draft na order. Nakatago ang " + + "pag-edit mo sa sarili mong draft bago isumite — bahagi lang iyon ng pagsulat nito. Ngunit kung iba " + + "ang nag-edit ng draft mo, ipinapakita ang pagbabago nila, kaya hindi kailanman nawawala ang taong " + + "naglagay ng mga bilang na pumasok sa stock. At mula noon, ipinapakita na rin ang sarili mong mga " + + "susunod na pag-edit: kung ibinalik mo ang mga bilang sa gusto mo, ikaw ang pinapangalanan ng " + + "column at ang oras na ginawa mo iyon, hindi ang pag-edit na pinalitan mo.", + auditRecordHistoryOlder: + "Ang mga record na ginawa bago ito idagdag ay walang linyang \"ginawa ni\" — wala lang talagang " + + "entry ng paggawa sa log para sa kanila, at walang iniimbentong bago pagkatapos. Kung nabago ang " + + "ganoong record pagkatapos, ipinapakita pa rin ang pagbabagong iyon; blangko lang ang kolum na " + + "Kasaysayan kapag wala talagang anuman.", exportHeading: "Export at Backup (admin)", exportCsv: diff --git a/web/src/routes/DailyEntryPage.test.tsx b/web/src/routes/DailyEntryPage.test.tsx index 933461eb3..e467ef20a 100644 --- a/web/src/routes/DailyEntryPage.test.tsx +++ b/web/src/routes/DailyEntryPage.test.tsx @@ -11,7 +11,7 @@ import { import type { Flock, EggGrade, DailyEntry } from "../api/cluckwork"; import { todayIso } from "../lib/dates"; import { FarmContext } from "../farm/FarmContext"; -import { account, farmState } from "../test/fixtures"; +import { account, farmState, NO_RECORD_HISTORY } from "../test/fixtures"; import i18n from "../i18n"; // DailyEntry has no auth deps — mock only the API seam it loads from. The @@ -43,16 +43,18 @@ const mockListWaterUsage = vi.mocked(listWaterUsage); const mockCreateFlock = vi.mocked(createFlock); const FLOCK: Flock = { + ...NO_RECORD_HISTORY, id: "f1", farmId: "farm1", houseId: "h1", name: "Hen House 1", breed: "ISA", placementDate: "2026-01-01", initialCount: 100, currentBirds: 98, status: "Active", }; const GRADES: EggGrade[] = [ - { id: "gr1", farmId: "farm1", name: "Grade A", gradeType: "Size", sortOrder: 1, isSaleable: true, dailyEntryKind: "Manual", active: true }, - { id: "gr2", farmId: "farm1", name: "Grade B", gradeType: "Size", sortOrder: 2, isSaleable: true, dailyEntryKind: "Manual", active: true }, + { ...NO_RECORD_HISTORY, id: "gr1", farmId: "farm1", name: "Grade A", gradeType: "Size", sortOrder: 1, isSaleable: true, dailyEntryKind: "Manual", active: true }, + { ...NO_RECORD_HISTORY, id: "gr2", farmId: "farm1", name: "Grade B", gradeType: "Size", sortOrder: 2, isSaleable: true, dailyEntryKind: "Manual", active: true }, ]; // #396 — saleable AND active, so it passes every pre-existing filter. The only // thing that keeps it out of the Grading pane is its kind. const CRACKED: EggGrade = { + ...NO_RECORD_HISTORY, id: "gr-cracked", farmId: "farm1", name: "Cracked", gradeType: "Quality", sortOrder: 3, isSaleable: true, dailyEntryKind: "Cracked", active: true, }; @@ -259,6 +261,7 @@ describe("DailyEntryPage prefill gating", () => { it("locks the form when the day already has a submitted entry", async () => { const existing: DailyEntry = { + ...NO_RECORD_HISTORY, id: "de1", farmId: "farm1", houseId: "h1", flockId: "f1", date: todayIso(), status: "Submitted", totalEggs: 100, crackedEggs: 2, dirtyEggs: 3, discardedEggs: 5, mortalityCount: 1, crackedGradeId: null, dirtyGradeId: null, grades: [{ eggGradeId: "gr1", quantity: 60 }, { eggGradeId: "gr2", quantity: 25 }], @@ -282,6 +285,7 @@ describe("DailyEntryPage prefill gating", () => { // en.ts's `enums` header comment). it("shows the harmonized 'adjusted' label, not the raw status, for a manager-adjusted day", async () => { const existing: DailyEntry = { + ...NO_RECORD_HISTORY, id: "de1", farmId: "farm1", houseId: "h1", flockId: "f1", date: todayIso(), status: "ManagerAdjusted", totalEggs: 100, crackedEggs: 2, dirtyEggs: 3, discardedEggs: 5, mortalityCount: 1, crackedGradeId: null, dirtyGradeId: null, grades: [{ eggGradeId: "gr1", quantity: 60 }, { eggGradeId: "gr2", quantity: 25 }], @@ -463,6 +467,7 @@ describe("DailyEntryPage structure", () => { describe("DailyEntryPage draft badge", () => { const draftFor = (date: string) => ({ + ...NO_RECORD_HISTORY, id: "de1", farmId: "farm1", houseId: "h1", flockId: "f1", date, status: "Draft", totalEggs: 40, crackedEggs: 1, dirtyEggs: 0, discardedEggs: 0, mortalityCount: 0, crackedGradeId: null, dirtyGradeId: null, grades: [], version: 1, adjustReason: null, voidReason: null, @@ -1081,6 +1086,7 @@ describe("DailyEntryPage i18n wiring (#182, Task 11)", () => { // "submitted" here. it("interpolates the enum-labelled status into the entry-locked banner from the catalog", async () => { const existing: DailyEntry = { + ...NO_RECORD_HISTORY, id: "de1", farmId: "farm1", houseId: "h1", flockId: "f1", date: todayIso(), status: "Submitted", totalEggs: 100, crackedEggs: 2, dirtyEggs: 3, discardedEggs: 5, mortalityCount: 1, crackedGradeId: null, dirtyGradeId: null, grades: [], version: 1, adjustReason: null, voidReason: null, lockedAtUtc: "2026-07-20T10:00:00Z", adjustedFrom: null, diff --git a/web/src/routes/Dashboard.test.tsx b/web/src/routes/Dashboard.test.tsx index ab87a5df9..435314950 100644 --- a/web/src/routes/Dashboard.test.tsx +++ b/web/src/routes/Dashboard.test.tsx @@ -7,6 +7,7 @@ import { } from "../api/cluckwork"; import type { DailyEntry, Flock, SalesOrder, StockRow } from "../api/cluckwork"; import i18n from "../i18n"; +import { NO_RECORD_HISTORY } from "../test/fixtures"; // Keep the real formatMoney; stub the five read endpoints the dashboard fans out. vi.mock("../api/cluckwork", async (importOriginal) => { @@ -28,10 +29,12 @@ const mockOrders = vi.mocked(listOrders); const mockCustomers = vi.mocked(listCustomers); const flock = (id: string, status: string): Flock => ({ + ...NO_RECORD_HISTORY, id, farmId: "f", houseId: "h", name: `Flock ${id}`, breed: "ISA", placementDate: "2026-01-01", initialCount: 100, currentBirds: 98, status, }); const entry = (flockId: string, status: string, totalEggs: number): DailyEntry => ({ + ...NO_RECORD_HISTORY, id: `de-${flockId}`, farmId: "f", houseId: "h", flockId, date: "2026-07-21", status, totalEggs, crackedEggs: 0, dirtyEggs: 0, discardedEggs: 0, mortalityCount: 0, crackedGradeId: null, dirtyGradeId: null, grades: [], diff --git a/web/src/routes/ExpensesPage.test.tsx b/web/src/routes/ExpensesPage.test.tsx index b7c48e869..9ac4a3831 100644 --- a/web/src/routes/ExpensesPage.test.tsx +++ b/web/src/routes/ExpensesPage.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, within, fireEvent, act, waitFor } from "@testing-library/react"; import { ExpensesPage } from "./ExpensesPage"; import { renderWithProviders } from "../test/renderWithProviders"; -import { account } from "../test/fixtures"; +import { account, NO_RECORD_HISTORY, RECORD_HISTORY } from "../test/fixtures"; import { adjustExpense, createExpense, createExpenseCategory, getExpense, listExpenseCategories, listExpenses, listFlocks, updateExpenseCategory, @@ -56,6 +56,7 @@ const CAT_FEED: ExpenseCategory = { id: "cat-feed", farmId: "farm1", name: "Feed const CAT_UTIL: ExpenseCategory = { id: "cat-util", farmId: "farm1", name: "Utilities", active: true }; const CAT_OLD: ExpenseCategory = { id: "cat-old", farmId: "farm1", name: "Legacy", active: false }; const FLOCK: Flock = { + ...NO_RECORD_HISTORY, id: "f1", farmId: "farm1", houseId: "h1", name: "Hen House 1", breed: "ISA", placementDate: "2026-01-01", initialCount: 100, currentBirds: 98, status: "Active", }; @@ -64,6 +65,7 @@ const FLOCK: Flock = { // account/list currency — the exact case the code comments call out. 1500 minor // units @ 3dp renders "1.500 BHD" (a 2dp formatter could not produce it). const EXP_BHD: Expense = { + ...NO_RECORD_HISTORY, id: "e1", farmId: "farm1", expenseCategoryId: "cat-feed", date: "2026-07-05", description: "Layer feed", amountMinorUnits: 1500, currencyCode: "BHD", currencyMinorUnit: 3, flockId: null, note: null, version: 1, @@ -126,6 +128,29 @@ describe("ExpensesPage list + totals", () => { }); }); +// #494 — the record-history column is a shared component, well tested on its +// own; what is NOT tested by that unit suite is the per-page WIRING that hands +// it the CORRECT row's history object. A page passing the wrong variable (a +// different row, or a stray constant) would go uncaught otherwise. +describe("ExpensesPage record history column (#494)", () => { + it("shows the record history column for the row that has one", async () => { + const EXP_HISTORY: Expense = { ...EXP_BHD, ...RECORD_HISTORY, id: "e-hist", description: "Provenance expense" }; + mockListExpenses.mockResolvedValue({ + items: [EXP_BHD, EXP_HISTORY], totalMinorUnits: 3000, currencyCode: "BHD", currencyMinorUnit: 3, + }); + renderWithProviders(, { token: ADMIN }); + + const historyRow = await screen.findByRole("row", { name: /Provenance expense/ }); + expect(within(historyRow).getByText(/ana@farm\.test/)).toBeInTheDocument(); + expect(within(historyRow).getByText(/bo@farm\.test/)).toBeInTheDocument(); + + // The OTHER row must not carry the history row's data — this is what + // catches every row being wired to the same object. + const otherRow = screen.getByRole("row", { name: /Layer feed/ }); + expect(within(otherRow).queryByText(/ana@farm\.test/)).not.toBeInTheDocument(); + }); +}); + describe("ExpensesPage record expense", () => { // Different scales prove the parse honours the loaded currency's minor unit: // "5" is 5 in JPY (0dp) but would be 500 at 2dp; "1.5" is 1500 in BHD (3dp). diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx index 2251730f5..e462a601b 100644 --- a/web/src/routes/ExpensesPage.tsx +++ b/web/src/routes/ExpensesPage.tsx @@ -10,6 +10,7 @@ import { ApiError } from "../api/client"; import { BusyButton } from "../components/BusyButton"; import { Dialog } from "../components/Dialog"; import { DialogError } from "../components/DialogError"; +import { ProvenanceCell } from "../components/ProvenanceCell"; import { useDialogErrors } from "../components/useDialogErrors"; import { usePagedList } from "../components/usePagedList"; import { usePendingAction } from "../components/usePendingAction"; @@ -553,7 +554,8 @@ export function ExpensesPage() { {t("dateHeader")}{t("categoryHeader")}{t("descriptionHeader")}{t("amountHeader")} - {t("flockHeader")}{t("noteHeader")} + {t("flockHeader")}{t("noteHeader")} + {tc("recordHistoryHeader")} @@ -565,6 +567,7 @@ export function ExpensesPage() { {formatMoney(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)} {flockName(x.flockId)} {x.note ?? "—"} + {/* Opens the correction dialog — non-mutating, so the spinner belongs to the dialog's Save, not here (#242). */} diff --git a/web/src/routes/FlocksPage.test.tsx b/web/src/routes/FlocksPage.test.tsx index 9b41ff0a4..b4ab19fa7 100644 --- a/web/src/routes/FlocksPage.test.tsx +++ b/web/src/routes/FlocksPage.test.tsx @@ -9,6 +9,7 @@ import { import type { BirdMovement, Flock } from "../api/cluckwork"; import { ApiError } from "../api/client"; import i18n from "../i18n"; +import { NO_RECORD_HISTORY, RECORD_HISTORY } from "../test/fixtures"; // Network seam only; ApiError stays real (errorMessage branches on it), and the // date helpers (todayIso/ageWeeks) stay real too — they are pure and not mocked. @@ -33,14 +34,17 @@ const mockReactivate = vi.mocked(reactivateFlock); const mockListMovements = vi.mocked(listBirdMovements); const ACTIVE: Flock = { + ...NO_RECORD_HISTORY, id: "f1", farmId: "farm1", houseId: "h1", name: "Hen House 1", breed: "ISA Brown", placementDate: "2026-01-01", initialCount: 100, currentBirds: 98, status: "Active", }; const DEPLETED: Flock = { + ...NO_RECORD_HISTORY, id: "f2", farmId: "farm1", houseId: "h1", name: "Depleted Flock", breed: "Leghorn", placementDate: "2025-06-01", initialCount: 200, currentBirds: 0, status: "Depleted", }; const ARCHIVED: Flock = { + ...NO_RECORD_HISTORY, id: "f3", farmId: "farm1", houseId: "h1", name: "Old Coop", breed: "Sussex", placementDate: "2024-01-01", initialCount: 50, currentBirds: 0, status: "Archived", }; @@ -550,6 +554,30 @@ describe("FlocksPage role gating", () => { }); }); +// #494 — the record-history column is a shared component, well tested on its +// own; what is NOT tested by that unit suite is the per-page WIRING that hands +// it the CORRECT row's history object. A page passing the wrong variable (a +// different row, or a stray constant) would go uncaught otherwise. +describe("FlocksPage record history column (#494)", () => { + it("shows the record history column for the row that has one", async () => { + const HISTORY_FLOCK: Flock = { + ...RECORD_HISTORY, + id: "f9", farmId: "farm1", houseId: "h1", name: "Provenance Coop", breed: "Orpington", + placementDate: "2026-02-01", initialCount: 50, currentBirds: 50, status: "Active", + }; + await renderReady(ADMIN, [ACTIVE, HISTORY_FLOCK]); + + const historyRow = screen.getByRole("row", { name: /Provenance Coop/ }); + expect(within(historyRow).getByText(/ana@farm\.test/)).toBeInTheDocument(); + expect(within(historyRow).getByText(/bo@farm\.test/)).toBeInTheDocument(); + + // The OTHER row must not carry the history row's data — this is what + // catches every row being wired to the same object. + const otherRow = screen.getByRole("row", { name: /Hen House 1/ }); + expect(within(otherRow).queryByText(/ana@farm\.test/)).not.toBeInTheDocument(); + }); +}); + // --------------------------------------------------------------------------- // i18n wiring (#182, Task 19, batch B3 — the last B3 screen) // --------------------------------------------------------------------------- diff --git a/web/src/routes/FlocksPage.tsx b/web/src/routes/FlocksPage.tsx index fe3d651cf..54f913837 100644 --- a/web/src/routes/FlocksPage.tsx +++ b/web/src/routes/FlocksPage.tsx @@ -12,6 +12,7 @@ import { BusyButton } from "../components/BusyButton"; import { Dialog } from "../components/Dialog"; import { DialogError } from "../components/DialogError"; import { NumberField } from "../components/NumberField"; +import { ProvenanceCell } from "../components/ProvenanceCell"; import { StatusBadge } from "../components/StatusBadge"; import { useConfirm } from "../components/useConfirm"; import { useDialogErrors } from "../components/useDialogErrors"; @@ -344,7 +345,8 @@ export function FlocksPage() { {t("nameHeader")}{t("breedHeader")}{t("placedHeader")}{t("ageHeader")} - {t("birdsHeader")}{t("statusHeader")} + {t("birdsHeader")}{t("statusHeader")} + {tc("recordHistoryHeader")} @@ -360,6 +362,7 @@ export function FlocksPage() { / {f.initialCount}} +