Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions src/Cluckwork.Api/Endpoints/DailyEntries/DailyEntryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,21 @@ public static RouteGroupBuilder MapDailyEntryEndpoints(this RouteGroupBuilder gr
private static async Task<IResult> 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<IResult> ListDailyEntries(
Cluckwork.Application.Features.DailyEntries.IDailyEntryRepository entries,
Cluckwork.Application.Features.Audit.IAuditEventRepository audit,
TenantContext tenant,
CancellationToken ct,
Guid? flockId = null,
Expand All @@ -78,10 +83,14 @@ private static async Task<IResult> 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
Expand All @@ -92,7 +101,9 @@ private static async Task<IResult> 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<JsonElement>(e.AdjustedFromJson));
e.AdjustedFromJson is null ? null : JsonSerializer.Deserialize<JsonElement>(e.AdjustedFromJson),
p?.CreatedByEmail, p?.CreatedAtUtc, p?.LastChangedByEmail, p?.LastChangedAtUtc,
p?.MadeOfficialAtUtc);

private static async Task<IResult> AdjustDailyEntry(
Guid id,
Expand Down Expand Up @@ -230,7 +241,15 @@ public sealed record DailyEntryResponse(
Guid? CrackedGradeId, Guid? DirtyGradeId,
IReadOnlyList<GradeLineResponse> 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);

Expand Down
28 changes: 21 additions & 7 deletions src/Cluckwork.Api/Endpoints/EggGrades/EggGradeEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,22 +48,30 @@ public static RouteGroupBuilder MapEggGradeEndpoints(this RouteGroupBuilder grou
}

private static async Task<IResult> 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<IResult> 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<IResult> CreateEggGrade(
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
32 changes: 24 additions & 8 deletions src/Cluckwork.Api/Endpoints/Expenses/ExpenseEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +115,7 @@ private static async Task<IResult> UpdateCategory(
private static async Task<IResult> ListExpenses(
IExpenseRepository expenses,
IAccountRepository accounts,
IAuditEventRepository audit,
TenantContext tenant,
CancellationToken ct,
DateOnly? from = null,
Expand All @@ -132,20 +134,25 @@ private static async Task<IResult> 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<IResult> 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<IResult> CreateExpense(
Expand Down Expand Up @@ -177,6 +184,7 @@ private static async Task<IResult> AdjustExpense(
AdjustExpenseHandler handler,
IValidator<AdjustExpenseCommand> validator,
IExpenseRepository expenses,
IAuditEventRepository audit,
TenantContext tenant,
CancellationToken ct)
{
Expand All @@ -194,9 +202,12 @@ private static async Task<IResult> 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)
Expand All @@ -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);
Expand All @@ -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<ExpenseResponse> Items, long TotalMinorUnits, string CurrencyCode, int CurrencyMinorUnit);
Expand Down
Loading
Loading