Task #146: Filter and paginate role permission history

Summary
- Backend: GET /api/roles/:id/audit now accepts limit (default 10, max 200),
  offset, actorUserId (0 = no filter), and from/to (YYYY-MM-DD UTC). The
  response is now a paginated envelope `{entries, totalCount, limit, offset,
  nextOffset}` instead of a bare array.
- OpenAPI: lib/api-spec/openapi.yaml updated with the new params and a new
  RolePermissionAuditList schema; client hooks regenerated via
  `pnpm --filter @workspace/api-spec run codegen`.
- Frontend: RolePermissionHistory in artifacts/tx-os/src/pages/admin.tsx is
  now self-contained — owns its own filter state, fetches the user list for
  the actor dropdown, applies actor changes immediately, and validates date
  inputs (rejecting invalid / inverted ranges).
- Pagination: switched to TRUE OFFSET PAGINATION. The first page comes from
  React Query (so live cache invalidations after a save still refresh it),
  and subsequent "Load more" clicks fetch `offset = nextOffset` imperatively
  via getRolePermissionAudit() and append the rows to local state. There is
  no client-side ceiling on how far back an admin can page; we simply stop
  showing the button when nextOffset is null. A filtersKey effect resets the
  appended pages whenever any filter (actor / from / to) changes so we never
  serve overlapping or out-of-order rows.
- i18n: added missing keys in artifacts/tx-os/src/locales/{en,ar}.json
  (historyEmptyFiltered, historyShowing, historyLoadMore, historyFilters.*,
  historyErrors.*).
- Tests: artifacts/api-server/tests/role-permission-audit.test.mjs updated
  to read the new envelope and now also covers offset-based pagination,
  actorUserId filtering (including the "0 = no filter" semantic), and
  date-range filtering (in-range / past-range / inverted / garbage). All 9
  audit tests pass; tx-os typecheck clean.
- E2E: ran the testing skill end-to-end against /admin → role edit dialog →
  history panel: created a fresh role through the UI, made 25 permission
  writes, verified 10 → 20 → 25 pagination with no duplicates and correct
  hide-on-end behaviour, filter-by-actor reset to first page, empty date
  range showed empty state, inverted dates surfaced the validation error.

Drift / notes
- Pre-existing executive-meetings tests in the `test` workflow are still
  failing — unchanged by this task.
- The Arabic language toggle isn't exposed in the page header in this build,
  so the e2e Arabic step was skipped; locale strings are in place and the
  test ids do not change with locale.
- Code review (initial pass) flagged a 200-row UI ceiling in the previous
  "growing limit" approach. Replaced with true offset pagination (described
  above) so admins can scroll back through arbitrarily long histories.

Code review follow-ups (round 2)
- Tightened parseRoleAuditUtcDate to reject impossible calendar days
  (2024-02-31, 2025-13-01, etc.) instead of silently rolling forward.
- Aligned OpenAPI: actorUserId schema is now `minimum: 0` so the spec
  matches the runtime "0 = no filter" contract; client regenerated.
- Added a targeted backend test that asserts impossible dates → 400.

Code review follow-ups (round 3)
- UX: when applied filters become invalid, the History list now hides the
  stale entries and shows the "fix filters first" hint instead, and the
  Load more button is hidden until filters are valid again. Avoids
  presenting yesterday's results as the current view.

Code review follow-ups (round 4)
- Belt-and-braces: also reset the appended history pages when the first
  page's totalCount + first-row id signature changes, so an external cache
  invalidation (e.g. another save while the dialog is still open) cannot
  leave appended pages out of sync with the refreshed first page.

Replit-Task-Id: cc3c9e83-921f-4f72-b443-79f95f6467b1
This commit is contained in:
riyadhafraa
2026-04-30 10:45:07 +00:00
parent 754a49591e
commit fe736e3ff7
10 changed files with 819 additions and 79 deletions
+51 -21
View File
@@ -1956,8 +1956,12 @@ export const GetRoleUsageResponse = zod.object({
});
/**
* Returns the most recent permission-change audit records for the given
role, newest first. Each record captures the actor (if known), the
* Returns permission-change audit records for the given role, newest
first. Supports offset-based pagination via `limit`/`offset` and
optional filters by acting user (`actorUserId`) and a date range
(`from`/`to`, inclusive UTC days). The response includes the total
matching count and the next offset to load (or `null` when the end
has been reached). Each entry captures the actor (if known), the
previous permission IDs, the new permission IDs, and the timestamp.
* @summary List recent permission-change audit entries for a role (admin)
@@ -1967,7 +1971,12 @@ export const GetRolePermissionAuditParams = zod.object({
});
export const getRolePermissionAuditQueryLimitDefault = 10;
export const getRolePermissionAuditQueryLimitMax = 50;
export const getRolePermissionAuditQueryLimitMax = 200;
export const getRolePermissionAuditQueryOffsetDefault = 0;
export const getRolePermissionAuditQueryOffsetMin = 0;
export const getRolePermissionAuditQueryActorUserIdMin = 0;
export const GetRolePermissionAuditQueryParams = zod.object({
limit: zod.coerce
@@ -1975,30 +1984,51 @@ export const GetRolePermissionAuditQueryParams = zod.object({
.min(1)
.max(getRolePermissionAuditQueryLimitMax)
.default(getRolePermissionAuditQueryLimitDefault),
offset: zod.coerce
.number()
.min(getRolePermissionAuditQueryOffsetMin)
.default(getRolePermissionAuditQueryOffsetDefault),
actorUserId: zod.coerce
.number()
.min(getRolePermissionAuditQueryActorUserIdMin)
.optional()
.describe(
"Restrict results to entries authored by the given user. Use\n`0` or omit to return entries from any actor.\n",
),
from: zod
.date()
.optional()
.describe("Start date (inclusive, YYYY-MM-DD UTC)."),
to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."),
});
export const GetRolePermissionAuditResponseItem = zod.object({
id: zod.number(),
roleId: zod.number(),
previousPermissionIds: zod.array(zod.number()),
newPermissionIds: zod.array(zod.number()),
addedPermissionIds: zod.array(zod.number()),
removedPermissionIds: zod.array(zod.number()),
createdAt: zod.coerce.date(),
actor: zod.union([
export const GetRolePermissionAuditResponse = zod.object({
entries: zod.array(
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
roleId: zod.number(),
previousPermissionIds: zod.array(zod.number()),
newPermissionIds: zod.array(zod.number()),
addedPermissionIds: zod.array(zod.number()),
removedPermissionIds: zod.array(zod.number()),
createdAt: zod.coerce.date(),
actor: zod.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
]),
}),
zod.null(),
]),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
export const GetRolePermissionAuditResponse = zod.array(
GetRolePermissionAuditResponseItem,
);
/**
* Given a candidate set of permission IDs for the role, returns the list of