You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Show "created by / when" and "last changed by / when" inline on record pages #494
Admins/managers should be able to see, directly on a record's own page (Flocks, Daily Entries, Sales Orders, Expenses, Egg Grades, ...) — not by navigating away — who created it and when, and who last changed it and when.
Companion to #493, which covers navigating to a record's full history in the audit log. This issue is the inline summary on the record's own page; keep them separate (#493's "View history" link is a natural affordance to add once this summary exists, but each ships independently).
Approach (confirmed): derive from the existing audit log, no schema change
Considered and rejected: adding real CreatedBy/CreatedAt/UpdatedBy/UpdatedAt columns to every aggregate. That would touch nearly every aggregate + a migration + every write handler, for data the audit log (epic #93) already captures.
Instead: AuditEvent (src/Cluckwork.Domain/Auditing/AuditEvent.cs) is append-only and written inside the same transaction as every change. For a given EntityId, its earliest event is intended to be the creation event, and its latest event is the last change.
Gap found during scoping, folded into this issue's work (still zero schema change): none of the 5 in-scope entity types currently write an AuditEvent on creation — checked CreateFlockHandler, RecordDailyEntryHandler, CreateSalesOrderHandler, CreateExpenseHandler, CreateEggGradeHandler directly, zero IAuditWriter calls in any of them, and AuditActions.cs (src/Cluckwork.Application/Common/AuditActions.cs:20-52) has no *.Create action for any of the 5 (only UserCreate/ProductCreate exist, neither in scope). Without this, "earliest event = created" silently resolves to the first correction instead, or to nothing at all for a never-touched record — the majority case. This issue's scope now includes instrumenting all 5 create handlers with audit.WriteAsync(...), same pattern as their existing Update/Adjust calls, plus the 5 new vocabulary entries and their AuditVocabularyCoverageTests coverage. Still no schema change — AuditEvent/IAuditWriter/AuditActions all already exist; this is new call sites, not new tables. A record created before this ships has no synthetic backfill — it falls back to its earliest correction event, or shows nothing if it's never been touched, which is acceptable (matches how the rest of the audit log already behaves for pre-#93 history).
RBAC: inherits the record's own read access, not a blanket admin gate
A Worker can already read their own Daily Entries (that's core to their job — recording and reviewing them); if they can already see the entry, they should be able to see who created/last changed it too (e.g. "corrected by a manager on ..."), with nothing new exposed beyond what that screen already shows. Sales Orders / Expenses stay effectively admin-only because those screens already are — provenance visibility should never be a stricter or looser gate than the underlying record's own read access, so it needs no separate authorization decision of its own. (Where #388's open question about flock-scoped read gaps applies to an entity type, this issue inherits that gap/fix rather than deciding it here.)
Actor display: shown as the plain actorEmail, same as the existing admin Audit page, for any viewer who can read the record — including a Worker seeing a manager's email on their own Daily Entry. A separate name field was considered (join ActorUserId → User.Name, or snapshot a name onto AuditEvent the way ActorEmail already is) and explicitly rejected for now: a live join was ruled out outright, since it would let a later rename/email-change (#357) or disable (#356) retroactively rewrite what old audit entries display, which defeats the point of an audit trail. Snapshotting a name too was the reasonable safe version of that idea, but decided against for this issue — email-only is enough to start.
Approach (revised): extend the existing list endpoints directly — no new endpoint
Originally scoped as a new batched GET /api/v1/audit/summary endpoint, to avoid leaking admin-only data through worker-readable list endpoints. That risk is now moot: since visibility inherits the record's own read access (above), there's nothing left to protect by keeping this behind a separate call. A second endpoint is strictly worse once that's true — it costs an extra HTTP round-trip and forces the SPA to correlate two responses by id, for no remaining benefit.
Instead: extend each in-scope list endpoint's own query and response DTO directly (e.g. GET /api/v1/daily-entries, GET /api/v1/flocks, ...) to include createdByEmail, createdAtUtc, lastChangedByEmail, lastChangedAtUtc per row, joined/subqueried against AuditEvent within that endpoint's own existing query — bounded by whatever pagination that endpoint already has. To avoid five copies of the same join logic, factor it into one small reusable helper (e.g. a query extension or a lookup service taking a batch of entity ids) that each of the five handlers calls, rather than reimplementing per handler.
Note on where the join goes: all 5 build their response DTO inline in the endpoint file (ToResponse/list-handler method) — there is no separate Application-layer read-handler class for any of them, so the helper is called from the endpoint file itself, fed the page's already-fetched entity ids.
Note on pagination: Flocks/DailyEntries/Sales/Expenses all have limit/offset (default 100, max 500, each endpoint re-declaring its own constants). Egg Grades has no pagination at all — it returns its whole active/all set. The batch lookup for Egg Grades keys off however many rows that returns, not a bounded page.
Scope
API — instrument the 5 create handlers with audit.WriteAsync(AuditActions.<X>Create, ...), matching their existing correction-path calls; add the 5 vocabulary entries + coverage.
API — extend existing list endpoints. For each in-scope entity type's list endpoint, add the four provenance fields to its response DTO, sourced via the shared earliest/latest-AuditEvent-per-id helper, scoped to the ids already being returned on that page.
SPA — inline display. On the list/table pages for entities already in the audit vocabulary (Flocks, Daily Entries, Sales Orders, Expenses, Egg Grades), render "Created by X on Y" / "Last changed by Z on W" directly from the now-extended row data (omit the second when it equals the first — never modified since creation, or when creation predates this feature and there's no event to show).
i18n + Help/glossary sync per AGENTS.md's user-visible-change rule.
Explicitly out of scope
Any schema/migration change to the aggregates themselves, or to AuditEvent.
A separate name field for the actor (live-joined or snapshotted) — decided against above; email only.
A new /api/v1/audit/summary-style endpoint — superseded by extending the existing list endpoints directly.
Backfilling creation provenance for records that predate this change.
Acceptance
Each of the 5 create handlers writes a Create AuditEvent, covered by AuditVocabularyCoverageTests.
Each in-scope record's own list page shows who created it, when, and (if changed since) who last changed it and when — no navigation required, no second API call — for every viewer who can already read that record.
Sourced entirely from the existing AuditEvent log via a shared lookup helper reused across the extended endpoints — no new domain fields, no new endpoint.
Each extended endpoint's provenance fields are visible to exactly whoever could already read that endpoint's rows — never broader, never narrower.
Ask
Admins/managers should be able to see, directly on a record's own page (Flocks, Daily Entries, Sales Orders, Expenses, Egg Grades, ...) — not by navigating away — who created it and when, and who last changed it and when.
Companion to #493, which covers navigating to a record's full history in the audit log. This issue is the inline summary on the record's own page; keep them separate (#493's "View history" link is a natural affordance to add once this summary exists, but each ships independently).
Approach (confirmed): derive from the existing audit log, no schema change
Considered and rejected: adding real
CreatedBy/CreatedAt/UpdatedBy/UpdatedAtcolumns to every aggregate. That would touch nearly every aggregate + a migration + every write handler, for data the audit log (epic #93) already captures.Instead:
AuditEvent(src/Cluckwork.Domain/Auditing/AuditEvent.cs) is append-only and written inside the same transaction as every change. For a givenEntityId, its earliest event is intended to be the creation event, and its latest event is the last change.Gap found during scoping, folded into this issue's work (still zero schema change): none of the 5 in-scope entity types currently write an
AuditEventon creation — checkedCreateFlockHandler,RecordDailyEntryHandler,CreateSalesOrderHandler,CreateExpenseHandler,CreateEggGradeHandlerdirectly, zeroIAuditWritercalls in any of them, andAuditActions.cs(src/Cluckwork.Application/Common/AuditActions.cs:20-52) has no*.Createaction for any of the 5 (onlyUserCreate/ProductCreateexist, neither in scope). Without this, "earliest event = created" silently resolves to the first correction instead, or to nothing at all for a never-touched record — the majority case. This issue's scope now includes instrumenting all 5 create handlers withaudit.WriteAsync(...), same pattern as their existing Update/Adjust calls, plus the 5 new vocabulary entries and theirAuditVocabularyCoverageTestscoverage. Still no schema change —AuditEvent/IAuditWriter/AuditActionsall already exist; this is new call sites, not new tables. A record created before this ships has no synthetic backfill — it falls back to its earliest correction event, or shows nothing if it's never been touched, which is acceptable (matches how the rest of the audit log already behaves for pre-#93 history).RBAC: inherits the record's own read access, not a blanket admin gate
A Worker can already read their own Daily Entries (that's core to their job — recording and reviewing them); if they can already see the entry, they should be able to see who created/last changed it too (e.g. "corrected by a manager on ..."), with nothing new exposed beyond what that screen already shows. Sales Orders / Expenses stay effectively admin-only because those screens already are — provenance visibility should never be a stricter or looser gate than the underlying record's own read access, so it needs no separate authorization decision of its own. (Where #388's open question about flock-scoped read gaps applies to an entity type, this issue inherits that gap/fix rather than deciding it here.)
Actor display: shown as the plain
actorEmail, same as the existing admin Audit page, for any viewer who can read the record — including a Worker seeing a manager's email on their own Daily Entry. A separate name field was considered (joinActorUserId→User.Name, or snapshot a name ontoAuditEventthe wayActorEmailalready is) and explicitly rejected for now: a live join was ruled out outright, since it would let a later rename/email-change (#357) or disable (#356) retroactively rewrite what old audit entries display, which defeats the point of an audit trail. Snapshotting a name too was the reasonable safe version of that idea, but decided against for this issue — email-only is enough to start.Approach (revised): extend the existing list endpoints directly — no new endpoint
Originally scoped as a new batched
GET /api/v1/audit/summaryendpoint, to avoid leaking admin-only data through worker-readable list endpoints. That risk is now moot: since visibility inherits the record's own read access (above), there's nothing left to protect by keeping this behind a separate call. A second endpoint is strictly worse once that's true — it costs an extra HTTP round-trip and forces the SPA to correlate two responses by id, for no remaining benefit.Instead: extend each in-scope list endpoint's own query and response DTO directly (e.g.
GET /api/v1/daily-entries,GET /api/v1/flocks, ...) to includecreatedByEmail,createdAtUtc,lastChangedByEmail,lastChangedAtUtcper row, joined/subqueried againstAuditEventwithin that endpoint's own existing query — bounded by whatever pagination that endpoint already has. To avoid five copies of the same join logic, factor it into one small reusable helper (e.g. a query extension or a lookup service taking a batch of entity ids) that each of the five handlers calls, rather than reimplementing per handler.Note on where the join goes: all 5 build their response DTO inline in the endpoint file (
ToResponse/list-handler method) — there is no separate Application-layer read-handler class for any of them, so the helper is called from the endpoint file itself, fed the page's already-fetched entity ids.Note on pagination: Flocks/DailyEntries/Sales/Expenses all have
limit/offset(default 100, max 500, each endpoint re-declaring its own constants). Egg Grades has no pagination at all — it returns its whole active/all set. The batch lookup for Egg Grades keys off however many rows that returns, not a bounded page.Scope
audit.WriteAsync(AuditActions.<X>Create, ...), matching their existing correction-path calls; add the 5 vocabulary entries + coverage.AuditEvent-per-id helper, scoped to the ids already being returned on that page.Explicitly out of scope
AuditEvent./api/v1/audit/summary-style endpoint — superseded by extending the existing list endpoints directly.Acceptance
AuditEvent, covered byAuditVocabularyCoverageTests.AuditEventlog via a shared lookup helper reused across the extended endpoints — no new domain fields, no new endpoint.