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
11 changes: 11 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,17 @@ this link "Audit history" like the other five would promise a full lifecycle
the data can't deliver: a normal, never-adjusted lot shows nothing at all
under that scope. "Adjustment history" says exactly what it shows.

**Record-type filter (#520)** — a second dropdown on the /audit viewer,
ahead of the existing action filter. Picking a record type (Flock, Sales
order, User, …) does not query anything itself — it narrows the action
dropdown's option list to only the actions actually recorded against that
type, so an admin who knows *what kind of record* they're after isn't
scanning one flat list of every action the farm can log to find it. The
mapping from action to type is read off the server's own audit-write call
sites, not the action code's own "Entity.Verb" prefix — the four
`Account.Set/RemoveLogo/Banner` actions are prefixed `Account` but recorded
against `FarmLogo`, a row shared by both the logo and the banner.

**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
7 changes: 7 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1846,6 +1846,8 @@ export const en = {
// loaded, or if the entity has zero audit events — deliberately not
// distinguishing those two cases (Gate 3).
scopedHeadingFallback: "Record history",
entityTypeFilterLabel: "Record type",
allEntityTypesOption: "All types",
actionFilterLabel: "Action",
allActionsOption: "All actions",
whenHeader: "When (UTC)",
Expand Down Expand Up @@ -2572,6 +2574,11 @@ 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.",
auditRecordTypeFilter:
"The Audit log's \"Record type\" dropdown does not filter the rows itself — it narrows the "
+ "\"Action\" dropdown next to it down to only the actions that happen on the type you pick "
+ "(Flock, Sales order, and so on), so you are not scanning one long list of every action the "
+ "farm can log. Pick the action from that narrowed list to actually filter the rows.",
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 "
Expand Down
60 changes: 60 additions & 0 deletions web/src/i18n/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,66 @@ export function auditActionLabel(value: AuditActionValue | (string & {})): strin
return key ? i18n.t(key) : String(value);
}

// Which EntityType each action is actually recorded against server-side —
// read off every audit.WriteAsync(...) call site, not derived from the
// action's own "Entity.Verb" prefix, which is misleading for the four
// Account.Set/RemoveLogo/Banner actions (prefixed "Account", recorded
// against "FarmLogo" — a shared row FarmLogo.cs documents as covering both
// logo and banner). Powers AuditPage's entity-type dropdown, which narrows
// the action dropdown to only actions that can occur on the selected type.
export const AUDIT_ACTION_ENTITY_TYPE = {
"DailyEntry.Adjust": "DailyEntry",
"DailyEntry.Void": "DailyEntry",
"SalesOrder.Void": "SalesOrder",
"Payment.Void": "Payment",
"Expense.Adjust": "Expense",
"ExpenseCategory.Update": "ExpenseCategory",
"InventoryItem.Adjust": "InventoryItem",
"WaterUsage.Correct": "WaterUsage",
"Flock.BirdMovement": "Flock",
"Flock.Update": "Flock",
"Flock.Deplete": "Flock",
"Flock.Archive": "Flock",
"Flock.Reactivate": "Flock",
"EggGrade.Update": "EggGrade",
"EggGrade.Activate": "EggGrade",
"EggGrade.Deactivate": "EggGrade",
"User.Create": "User",
"User.Update": "User",
"User.PasswordSet": "User",
"User.PasswordChanged": "User",
"User.BreakGlassReset": "User",
"User.RoleChanged": "User",
"User.Disabled": "User",
"User.Enabled": "User",
"User.FlockAssign": "User",
"User.FlockUnassign": "User",
"Account.Export": "Account",
"Account.SetLogo": "FarmLogo",
"Account.RemoveLogo": "FarmLogo",
"Account.SetBanner": "FarmLogo",
"Account.RemoveBanner": "FarmLogo",
"Account.UpdateSettings": "Account",
"Product.Create": "Product",
"Product.Update": "Product",
"Product.Activate": "Product",
"Product.Deactivate": "Product",
"EggUnitConversion.Update": "EggUnitConversion",
"EggLot.Movement": "EggLot",
"Flock.Create": "Flock",
"DailyEntry.Create": "DailyEntry",
"DailyEntry.Update": "DailyEntry",
"DailyEntry.Submit": "DailyEntry",
"SalesOrder.Create": "SalesOrder",
"SalesOrder.Confirm": "SalesOrder",
"SalesOrder.Cancel": "SalesOrder",
"SalesOrder.AddItem": "SalesOrder",
"SalesOrder.UpdateItem": "SalesOrder",
"SalesOrder.RemoveItem": "SalesOrder",
"Expense.Create": "Expense",
"EggGrade.Create": "EggGrade",
} as const satisfies Record<AuditActionValue, EntityTypeValue>;

// ---------------------------------------------------------------------------
// entity type (AuditPage table entity cell) — AuditEvent.entityType.
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions web/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,8 @@ export const es = {
+ "mismo y nunca se editan.",
scopedHeading: "Historial de {{entityType}}",
scopedHeadingFallback: "Historial del registro",
entityTypeFilterLabel: "Tipo de registro",
allEntityTypesOption: "Todos los tipos",
actionFilterLabel: "Acción",
allActionsOption: "Todas las acciones",
whenHeader: "Cuándo (UTC)",
Expand Down Expand Up @@ -2141,6 +2143,11 @@ 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.",
auditRecordTypeFilter:
"El menú \"Tipo de registro\" del registro de auditoría no filtra las filas por sí solo — reduce el "
+ "menú \"Acción\" de al lado a solo las acciones que ocurren en el tipo elegido (Lote, Pedido de "
+ "venta, etc.), para que no tengas que revisar una lista larga con cada acción que la granja puede "
+ "registrar. Elige la acción de esa lista reducida para filtrar las filas realmente.",
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 "
Expand Down
8 changes: 8 additions & 0 deletions web/src/i18n/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,8 @@ export const tl = {
+ "pagbabago mismo at hindi na ito ine-edit pa.",
scopedHeading: "Kasaysayan ng {{entityType}}",
scopedHeadingFallback: "Kasaysayan ng record",
entityTypeFilterLabel: "Uri ng record",
allEntityTypesOption: "Lahat ng uri",
actionFilterLabel: "Aksyon",
allActionsOption: "Lahat ng aksyon",
whenHeader: "Kailan (UTC)",
Expand Down Expand Up @@ -2223,6 +2225,12 @@ 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.",
auditRecordTypeFilter:
"Ang dropdown na \"Uri ng record\" sa audit log ay hindi ito ang nagsa-salà sa mga row — pinapaliit "
+ "lamang nito ang dropdown na \"Aksyon\" sa tabi nito para lang sa mga aksyong nangyayari sa uring "
+ "pinili mo (Kawan, Order ng benta, atbp.), para hindi mo na kailangang mag-scan sa isang mahabang "
+ "listahan ng bawat aksyong maaaring itala ng bukid. Piliin ang aksyon sa napaliit na listahang iyon "
+ "para talagang masalà ang mga row.",
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 "
Expand Down
112 changes: 108 additions & 4 deletions web/src/routes/AuditPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,118 @@ describe("AuditPage filter", () => {
await screen.findByText("No audit events yet."); // let the mount load settle first

await act(async () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } });
fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } });
});

// The ARGUMENT is the behavior: the chosen action must reach the seam.
expect(mockListAuditEvents).toHaveBeenLastCalledWith(
expect.objectContaining({ action: "Flock.Deplete", offset: 0 }),
);
});

it("narrows the action dropdown to only actions recorded against the chosen record type", async () => {
renderAudit();
await screen.findByText("No audit events yet.");

const actionSelect = screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement;
const optionValues = () => Array.from(actionSelect.options).map((o) => o.value);
expect(optionValues()).toContain("User.Create"); // unfiltered: every action listed
expect(optionValues()).toContain("Flock.Deplete");

fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), {
target: { value: "Flock" },
});

// Only Flock-recorded actions remain, plus the "All actions" blank option.
expect(optionValues()).toEqual(
expect.arrayContaining(["", "Flock.BirdMovement", "Flock.Update", "Flock.Deplete", "Flock.Archive", "Flock.Reactivate", "Flock.Create"]),
);
expect(optionValues()).not.toContain("User.Create");
expect(optionValues()).not.toContain("SalesOrder.Void");
});

it("resets a selected action that no longer matches once the record type changes", async () => {
mockListAuditEvents.mockResolvedValue([]);
renderAudit();
await screen.findByText("No audit events yet.");

await act(async () => {
fireEvent.change(screen.getByRole("combobox", { name: "Action" }), {
target: { value: "User.Create" },
});
});
expect(mockListAuditEvents).toHaveBeenLastCalledWith(
expect.objectContaining({ action: "User.Create" }),
);

// "User.Create" isn't a Flock action — picking Flock as the record type
// must not leave a hidden, mismatched action filter still in effect.
await act(async () => {
fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), {
target: { value: "Flock" },
});
});

expect(mockListAuditEvents).toHaveBeenLastCalledWith(
expect.objectContaining({ action: undefined }),
);
expect((screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).value).toBe("");
});

it("carries the record-type filter in the URL, independent of entityId scoping", async () => {
renderAudit("/audit?entityType=Flock");
await screen.findByText("No audit events yet.");

expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe(
"Flock",
);
});

it("falls back to the unfiltered action list for a garbage entityType query value", async () => {
renderAudit("/audit?entityType=NotARealType");
await screen.findByText("No audit events yet.");

expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe(
"",
);
expect(
Array.from(
(screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).options,
).map((o) => o.value),
).toContain("User.Create"); // unfiltered — the bogus value is ignored, not applied
});

it("ignores an action from the URL that is incompatible with the entityType also in the URL (codex review of #521)", async () => {
mockListAuditEvents.mockResolvedValue([]);
// Flock + User.Create is a real but incompatible pair — User.Create is
// never in Flock's action list, so a hand-edited or shared URL carrying
// both must not silently keep querying the hidden action.
renderAudit("/audit?entityType=Flock&action=User.Create");
await screen.findByText("No audit events yet.");

expect(mockListAuditEvents).toHaveBeenLastCalledWith(
expect.objectContaining({ action: undefined }),
);
expect((screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).value).toBe("");
});

it("clears the entityType query param when the record type is reset to 'All types'", async () => {
renderAudit("/audit?entityType=Flock");
await screen.findByText("No audit events yet.");

fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), {
target: { value: "" },
});

expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe(
"",
);
expect(
Array.from(
(screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).options,
).map((o) => o.value),
).toContain("SalesOrder.Void"); // back to the full, unfiltered list
});
});

describe("AuditPage paging", () => {
Expand Down Expand Up @@ -486,7 +590,7 @@ describe("AuditPage entity-scoped mode (#493)", () => {
await screen.findByText("No audit events for this record yet.");

await act(async () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } });
fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } });
});

expect(mockListAuditEvents).toHaveBeenLastCalledWith(
Expand All @@ -500,7 +604,7 @@ describe("AuditPage entity-scoped mode (#493)", () => {
await screen.findByText("No audit events yet.");

await act(async () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } });
fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } });
});

expect(mockListAuditEvents).toHaveBeenLastCalledWith(
Expand All @@ -524,7 +628,7 @@ describe("AuditPage entity-scoped mode (#493)", () => {
mockListAuditEvents.mockReturnValueOnce(new Promise<AuditEvent[]>((r) => (resolveReload = r)));

await act(async () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } });
fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } });
});

// The reload is in flight: the heading must NOT still say "Flock
Expand Down
58 changes: 55 additions & 3 deletions web/src/routes/AuditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { listAuditEvents } from "../api/cluckwork";
import { usePagedList } from "../components/usePagedList";
import { AUDIT_ACTION_VALUES, auditActionLabel, entityTypeLabel } from "../i18n/enums";
import {
AUDIT_ACTION_ENTITY_TYPE,
AUDIT_ACTION_VALUES,
auditActionLabel,
ENTITY_TYPE_VALUES,
entityTypeLabel,
type EntityTypeValue,
} from "../i18n/enums";

const PAGE = 100;

Expand Down Expand Up @@ -56,7 +63,7 @@ export function AuditPage() {
const { t: tc } = useTranslation("common");

const [searchParams, setSearchParams] = useSearchParams();
const actionFilter = searchParams.get("action") ?? "";
const rawActionFilter = searchParams.get("action") ?? "";
const rawEntityId = searchParams.get("entityId");
// Lowercased (codex review of #516): the API returns EntityId as a .NET
// Guid, which System.Text.Json serializes lowercase regardless of the
Expand All @@ -75,6 +82,43 @@ export function AuditPage() {
setSearchParams(next);
}, [searchParams, setSearchParams]);

// Entity-type filter narrows the action dropdown's OPTION LIST only — it is
// never sent to the server (the /api/v1/audit query still filters on
// `action` alone, matching what the backend supports). Changing it drops
// any selected `action`: AUDIT_ACTION_ENTITY_TYPE is a many-to-one map, so
// a previously chosen action can fall outside the new type's option list,
// and leaving it selected-but-hidden would silently keep querying against
// a type the visible dropdown no longer shows.
const rawEntityTypeFilter = searchParams.get("entityType");
const entityTypeFilter: EntityTypeValue | "" =
rawEntityTypeFilter && (ENTITY_TYPE_VALUES as readonly string[]).includes(rawEntityTypeFilter)
? (rawEntityTypeFilter as EntityTypeValue)
: "";

const updateEntityTypeFilter = useCallback((type: string) => {
const next = new URLSearchParams(searchParams);
if (type) next.set("entityType", type);
else next.delete("entityType");
next.delete("action");
setSearchParams(next);
}, [searchParams, setSearchParams]);

const availableActions = entityTypeFilter
? AUDIT_ACTION_VALUES.filter((a) => AUDIT_ACTION_ENTITY_TYPE[a] === entityTypeFilter)
: AUDIT_ACTION_VALUES;
Comment on lines +106 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize incompatible type/action URLs

When a shared or hand-edited URL contains a valid but incompatible pair such as ?entityType=Flock&action=User.Create, this list omits the selected action, so the Action control visually falls back to “All actions” while fetchPage continues querying User.Create. Validate actionFilter against availableActions and clear or ignore it when the pair is incompatible so a hidden filter cannot produce misleading results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 983af91: actionFilter is now validated against availableActions for the current entityTypeFilter and ignored (not just reset on a dropdown click) when the URL's action isn't in scope for its type — closes it for both a manual click and a direct/shared URL load. Mutation-verified: reverting the validation makes the new test fail on its own action: undefined assertion.


// codex review of #521 — a hand-edited or shared URL can carry a valid but
// INCOMPATIBLE pair, e.g. ?entityType=Flock&action=User.Create: the raw
// action isn't in `availableActions` for that type, so the <select> falls
// back to showing no match while `fetchPage` would silently keep querying
// the hidden action. Ignoring an out-of-scope action here, not just when
// the user changes the type dropdown, keeps the visible filter and the
// query in sync for BOTH entry paths (a click, and a direct URL load).
const actionFilter =
rawActionFilter && (availableActions as readonly string[]).includes(rawActionFilter)
? rawActionFilter
: "";

// #469 — the ticket/dedupe/busy-ownership discipline this screen grew for
// itself (codex review of #94) now lives in usePagedList, shared with every
// other paged screen. The filter is expressed as the fetcher's identity, so
Expand Down Expand Up @@ -169,10 +213,18 @@ export function AuditPage() {
<p className="muted">{t("intro")}</p>

<div className="filters">
<label>{t("entityTypeFilterLabel")}
<select value={entityTypeFilter} onChange={(e) => updateEntityTypeFilter(e.target.value)}>
Comment on lines 215 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new cascading filter

This adds a user-visible control with non-obvious semantics—the record type narrows the Action choices but does not itself filter audit rows—without updating the SPA Help page or its in-app glossary. Add guidance to the Audit help section so users can understand the two-step filtering behavior, as required for every user-visible behavior change.

AGENTS.md reference: AGENTS.md:L251-L251

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 983af91: a new Help bullet (auditRecordTypeFilter, en/es/tl) explaining the two-step narrow-then-filter behavior, plus a GLOSSARY.md entry (#520). Full web suite green (1778/1778), typecheck clean.

<option value="">{t("allEntityTypesOption")}</option>
{ENTITY_TYPE_VALUES.map((et) => (
<option key={et} value={et}>{entityTypeLabel(et)}</option>
))}
</select>
</label>
<label>{t("actionFilterLabel")}
<select value={actionFilter} onChange={(e) => updateActionFilter(e.target.value)}>
<option value="">{t("allActionsOption")}</option>
{AUDIT_ACTION_VALUES.map((a) => (
{availableActions.map((a) => (
<option key={a} value={a}>{auditActionLabel(a)}</option>
))}
</select>
Expand Down
1 change: 1 addition & 0 deletions web/src/routes/HelpPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export function HelpPage() {
<h3 id="audit">{t("auditHeading")}</h3>
<ul>
<li>{t("auditLog")}</li>
<li>{t("auditRecordTypeFilter")}</li>
<li>{t("auditRecordHistory")}</li>
<li>{t("auditRecordHistoryLink")}</li>
<li>{t("auditRecordHistorySubmit")}</li>
Expand Down
Loading