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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user