From ad3c7f76c465fdad373f566760396e8989f6ff0e Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 14:36:55 +0200 Subject: [PATCH 1/7] feat: added pagination for the admin zone, default value 25 rows, the limit value is clamped to a maximum of 200 records when the number is bigger (e.g. 999) --- apps/client/src/lib/api/endpoints.ts | 6 +++-- .../src/pages/Admin/AdminEventsPage.tsx | 24 +++++++++++++++++-- .../client/src/pages/Admin/AdminUsersPage.tsx | 23 ++++++++++++++++-- apps/client/src/pages/Admin/admin.hooks.ts | 12 +++++----- .../src/modules/admin/admin.handlers.ts | 14 +++++++++-- .../server/src/modules/admin/admin.service.ts | 14 +++++++---- 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/apps/client/src/lib/api/endpoints.ts b/apps/client/src/lib/api/endpoints.ts index 3fb7089..975de23 100644 --- a/apps/client/src/lib/api/endpoints.ts +++ b/apps/client/src/lib/api/endpoints.ts @@ -44,10 +44,12 @@ export const ENDPOINTS = { // Admin endpoints adminDashboard: (): string => `${apiPrefix}/admin/dashboard`, - adminUsers: (): string => `${apiPrefix}/admin/users`, + adminUsers: (params?: PaginationParams): string => + `${apiPrefix}/admin/users${qs(params)}`, adminUser: (userId: string | number): string => `${apiPrefix}/admin/users/${userId}`, - adminEvents: (): string => `${apiPrefix}/admin/events`, + adminEvents: (params?: PaginationParams): string => + `${apiPrefix}/admin/events${qs(params)}`, adminSystemMessages: (): string => `${apiPrefix}/admin/system-messages`, adminSystemMessage: (messageId: string | number): string => `${apiPrefix}/admin/system-messages/${messageId}`, diff --git a/apps/client/src/pages/Admin/AdminEventsPage.tsx b/apps/client/src/pages/Admin/AdminEventsPage.tsx index ac7af60..b94d809 100644 --- a/apps/client/src/pages/Admin/AdminEventsPage.tsx +++ b/apps/client/src/pages/Admin/AdminEventsPage.tsx @@ -1,9 +1,10 @@ import { Link } from '@tanstack/react-router'; import { format } from 'date-fns'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/atoms'; -import { AppDataTable } from '@/components/organisms'; +import { AppDataTable, AppPagination, AppRowsPerPage } from '@/components/organisms'; import { TableCell, TableHead, @@ -21,7 +22,9 @@ function formatDate(value: string | Date) { export function AdminEventsPage() { const { t } = useTranslation(); - const { data, isLoading, error } = useAdminEventsQuery(); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const { data, isLoading, error } = useAdminEventsQuery({ page, limit: pageSize }); return ( { + setPageSize(size); + setPage(1); + }} + /> + } + renderPagination={ + + } renderHeader={ diff --git a/apps/client/src/pages/Admin/AdminUsersPage.tsx b/apps/client/src/pages/Admin/AdminUsersPage.tsx index aa2fd08..f481533 100644 --- a/apps/client/src/pages/Admin/AdminUsersPage.tsx +++ b/apps/client/src/pages/Admin/AdminUsersPage.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Badge, Button } from '@/components/atoms'; import { ConfirmDialog } from '@/components/molecules'; import { useAuth } from '@/hooks/useAuth'; -import { AppDataTable } from '@/components/organisms'; +import { AppDataTable, AppPagination, AppRowsPerPage } from '@/components/organisms'; import { TableCell, TableHead, @@ -33,7 +33,9 @@ export function AdminUsersPage() { const { t } = useTranslation(); const { user: currentUser } = useAuth(); const queryClient = useQueryClient(); - const { data, isLoading, error } = useAdminUsersQuery(); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const { data, isLoading, error } = useAdminUsersQuery({ page, limit: pageSize }); const updateUserActiveMutation = useAdminUserActiveMutation(); const deleteUserMutation = useAdminUserDeleteMutation(); const [activeToggleTarget, setActiveToggleTarget] = useState<{ @@ -145,6 +147,23 @@ export function AdminUsersPage() { error={error} columnCount={7} emptyStateText={t('Pages.Admin.Table.Empty')} + renderToolbar={ + { + setPageSize(size); + setPage(1); + }} + /> + } + renderPagination={ + + } renderHeader={ diff --git a/apps/client/src/pages/Admin/admin.hooks.ts b/apps/client/src/pages/Admin/admin.hooks.ts index 3393d98..8af3c16 100644 --- a/apps/client/src/pages/Admin/admin.hooks.ts +++ b/apps/client/src/pages/Admin/admin.hooks.ts @@ -94,13 +94,13 @@ export function useAdminDashboardQuery() { }); } -export function useAdminUsersQuery() { +export function useAdminUsersQuery({ page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { const api = useApi(); return useQuery({ - queryKey: ['admin', 'users'], + queryKey: ['admin', 'users', page, limit], queryFn: async () => - adminUserListSchema.parse(await api.get(ENDPOINTS.adminUsers())), + adminUserListSchema.parse(await api.get(ENDPOINTS.adminUsers({ page, limit }))), }); } @@ -128,13 +128,13 @@ export function useAdminUserDeleteMutation() { }); } -export function useAdminEventsQuery() { +export function useAdminEventsQuery({ page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { const api = useApi(); return useQuery({ - queryKey: ['admin', 'events'], + queryKey: ['admin', 'events', page, limit], queryFn: async () => - adminEventListSchema.parse(await api.get(ENDPOINTS.adminEvents())), + adminEventListSchema.parse(await api.get(ENDPOINTS.adminEvents({ page, limit }))), }); } diff --git a/apps/server/src/modules/admin/admin.handlers.ts b/apps/server/src/modules/admin/admin.handlers.ts index 7ae9f5a..0882d2f 100644 --- a/apps/server/src/modules/admin/admin.handlers.ts +++ b/apps/server/src/modules/admin/admin.handlers.ts @@ -68,7 +68,12 @@ export async function getAdminUsersHandler(c) { const logContext = buildAdminLogContext(c, adminUserId); try { - const users = await getAdminUsers(prisma); + const rawPage = c.req.query('page'); + const rawLimit = c.req.query('limit'); + const page = rawPage ? Math.max(1, parseInt(rawPage, 10)) : 1; + const limit = rawLimit ? Math.min(200, Math.max(1, parseInt(rawLimit, 10))) : 25; + + const users = await getAdminUsers(prisma, { page, limit }); logger.info('Admin users list loaded', { ...logContext, @@ -97,7 +102,12 @@ export async function getAdminEventsHandler(c) { const logContext = buildAdminLogContext(c, adminUserId); try { - const events = await getAdminEvents(prisma); + const rawPage = c.req.query('page'); + const rawLimit = c.req.query('limit'); + const page = rawPage ? Math.max(1, parseInt(rawPage, 10)) : 1; + const limit = rawLimit ? Math.min(200, Math.max(1, parseInt(rawLimit, 10))) : 25; + + const events = await getAdminEvents(prisma, { page, limit }); logger.info('Admin events list loaded', { ...logContext, diff --git a/apps/server/src/modules/admin/admin.service.ts b/apps/server/src/modules/admin/admin.service.ts index 06f9949..ad2b620 100644 --- a/apps/server/src/modules/admin/admin.service.ts +++ b/apps/server/src/modules/admin/admin.service.ts @@ -292,12 +292,15 @@ export async function getAdminDashboard(prisma, referenceDate = new Date()) { }); } -export async function getAdminUsers(prisma) { +export async function getAdminUsers(prisma, { page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { + const skip = (page - 1) * limit; + const [total, users] = await Promise.all([ prisma.user.count(), prisma.user.findMany({ orderBy: { createdAt: 'desc' }, - take: LIST_LIMIT, + skip, + take: limit, select: { id: true, email: true, @@ -460,12 +463,15 @@ export async function deleteAdminUser( }); } -export async function getAdminEvents(prisma) { +export async function getAdminEvents(prisma, { page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { + const skip = (page - 1) * limit; + const [total, events] = await Promise.all([ prisma.event.count(), prisma.event.findMany({ orderBy: [{ createdAt: 'desc' }, { date: 'desc' }], - take: LIST_LIMIT, + skip, + take: limit, select: { id: true, name: true, From 85ea561594b7ffe6c7770522cfacb3ef34ebbb6a Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 14:45:43 +0200 Subject: [PATCH 2/7] feat: improve pagination implementation --- .../src/pages/Admin/AdminEventsPage.tsx | 8 ++- .../client/src/pages/Admin/AdminUsersPage.tsx | 9 ++- .../admin/__tests__/admin.service.test.ts | 65 ++++++++++++++++++- .../src/modules/admin/admin.handlers.ts | 30 +++++++-- .../server/src/modules/admin/admin.openapi.ts | 34 ++++++++++ 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/apps/client/src/pages/Admin/AdminEventsPage.tsx b/apps/client/src/pages/Admin/AdminEventsPage.tsx index b94d809..72f594f 100644 --- a/apps/client/src/pages/Admin/AdminEventsPage.tsx +++ b/apps/client/src/pages/Admin/AdminEventsPage.tsx @@ -1,6 +1,6 @@ import { Link } from '@tanstack/react-router'; import { format } from 'date-fns'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/atoms'; @@ -26,6 +26,12 @@ export function AdminEventsPage() { const [pageSize, setPageSize] = useState(25); const { data, isLoading, error } = useAdminEventsQuery({ page, limit: pageSize }); + useEffect(() => { + if (!data) return; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + if (page > totalPages) setPage(totalPages); + }, [data, page, pageSize]); + return ( { + if (!data) return; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + if (page > totalPages) setPage(totalPages); + }, [data, page, pageSize]); + const updateUserActiveMutation = useAdminUserActiveMutation(); const deleteUserMutation = useAdminUserDeleteMutation(); const [activeToggleTarget, setActiveToggleTarget] = useState<{ diff --git a/apps/server/src/modules/admin/__tests__/admin.service.test.ts b/apps/server/src/modules/admin/__tests__/admin.service.test.ts index 9d80e7c..40b6598 100644 --- a/apps/server/src/modules/admin/__tests__/admin.service.test.ts +++ b/apps/server/src/modules/admin/__tests__/admin.service.test.ts @@ -19,9 +19,11 @@ function createPrismaMock() { }, findMany: async ({ where, + skip, take, }: { where?: { createdAt?: { gte: Date } }; + skip?: number; take?: number; }) => { const users = [ @@ -63,7 +65,8 @@ function createPrismaMock() { .map((user) => ({ createdAt: user.createdAt })); } - return users.slice(0, take ?? users.length); + const offset = skip ?? 0; + return users.slice(offset, take !== undefined ? offset + take : undefined); }, }, event: { @@ -77,9 +80,11 @@ function createPrismaMock() { }, findMany: async ({ where, + skip, take, }: { where?: { createdAt?: { gte: Date } }; + skip?: number; take?: number; }) => { const events = [ @@ -124,7 +129,8 @@ function createPrismaMock() { .map((event) => ({ createdAt: event.createdAt })); } - return events.slice(0, take ?? events.length); + const offset = skip ?? 0; + return events.slice(offset, take !== undefined ? offset + take : undefined); }, }, }; @@ -322,6 +328,61 @@ describe('admin service', () => { expect(events.items[0]?.authorName).toBe('Ada Admin'); }); + it('paginates users: page 1 returns first items', async () => { + const prisma = createPrismaMock(); + const result = await getAdminUsers(prisma, { page: 1, limit: 2 }); + + expect(result.total).toBe(3); + expect(result.items).toHaveLength(2); + expect(result.items[0]?.email).toBe('admin@example.com'); + expect(result.items[1]?.email).toBe('user@example.com'); + }); + + it('paginates users: page 2 returns remaining items', async () => { + const prisma = createPrismaMock(); + const result = await getAdminUsers(prisma, { page: 2, limit: 2 }); + + expect(result.total).toBe(3); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.email).toBe('inactive@example.com'); + }); + + it('paginates users: page beyond total returns empty items', async () => { + const prisma = createPrismaMock(); + const result = await getAdminUsers(prisma, { page: 99, limit: 25 }); + + expect(result.total).toBe(3); + expect(result.items).toHaveLength(0); + }); + + it('paginates events: page 1 returns first items', async () => { + const prisma = createPrismaMock(); + const result = await getAdminEvents(prisma, { page: 1, limit: 2 }); + + expect(result.total).toBe(4); + expect(result.items).toHaveLength(2); + expect(result.items[0]?.id).toBe('evt-1'); + expect(result.items[1]?.id).toBe('evt-2'); + }); + + it('paginates events: page 2 returns remaining items', async () => { + const prisma = createPrismaMock(); + const result = await getAdminEvents(prisma, { page: 2, limit: 2 }); + + expect(result.total).toBe(4); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.id).toBe('evt-3'); + }); + + it('uses default page=1 and limit=25 when no params provided', async () => { + const prisma = createPrismaMock(); + const users = await getAdminUsers(prisma); + const events = await getAdminEvents(prisma); + + expect(users.items).toHaveLength(3); + expect(events.items).toHaveLength(3); + }); + it('updates admin user active state', async () => { const { prisma, state } = createUserMutationPrismaMock([ { diff --git a/apps/server/src/modules/admin/admin.handlers.ts b/apps/server/src/modules/admin/admin.handlers.ts index 0882d2f..7560edf 100644 --- a/apps/server/src/modules/admin/admin.handlers.ts +++ b/apps/server/src/modules/admin/admin.handlers.ts @@ -4,7 +4,7 @@ import { adminUserActiveUpdateInputSchema } from '@repo/shared'; import { HTTP_STATUS } from '../../constants/index.js'; import { logger } from '../../lib/logging.js'; import { getAdminUserId } from '../../middlewares/require-admin.js'; -import { error as errorResponse, success as successResponse } from '../../utils/responseApi.js'; +import { error as errorResponse, success as successResponse, validation as validationResponse } from '../../utils/responseApi.js'; import prisma from '../../utils/context.js'; import { @@ -70,8 +70,18 @@ export async function getAdminUsersHandler(c) { try { const rawPage = c.req.query('page'); const rawLimit = c.req.query('limit'); - const page = rawPage ? Math.max(1, parseInt(rawPage, 10)) : 1; - const limit = rawLimit ? Math.min(200, Math.max(1, parseInt(rawLimit, 10))) : 25; + const parsedPage = rawPage !== undefined ? parseInt(rawPage, 10) : 1; + const parsedLimit = rawLimit !== undefined ? parseInt(rawLimit, 10) : 25; + + if (!Number.isFinite(parsedPage) || parsedPage < 1) { + return c.json(validationResponse('Invalid page parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + } + if (!Number.isFinite(parsedLimit) || parsedLimit < 1) { + return c.json(validationResponse('Invalid limit parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + } + + const page = parsedPage; + const limit = Math.min(200, parsedLimit); const users = await getAdminUsers(prisma, { page, limit }); @@ -104,8 +114,18 @@ export async function getAdminEventsHandler(c) { try { const rawPage = c.req.query('page'); const rawLimit = c.req.query('limit'); - const page = rawPage ? Math.max(1, parseInt(rawPage, 10)) : 1; - const limit = rawLimit ? Math.min(200, Math.max(1, parseInt(rawLimit, 10))) : 25; + const parsedPage = rawPage !== undefined ? parseInt(rawPage, 10) : 1; + const parsedLimit = rawLimit !== undefined ? parseInt(rawLimit, 10) : 25; + + if (!Number.isFinite(parsedPage) || parsedPage < 1) { + return c.json(validationResponse('Invalid page parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + } + if (!Number.isFinite(parsedLimit) || parsedLimit < 1) { + return c.json(validationResponse('Invalid limit parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + } + + const page = parsedPage; + const limit = Math.min(200, parsedLimit); const events = await getAdminEvents(prisma, { page, limit }); diff --git a/apps/server/src/modules/admin/admin.openapi.ts b/apps/server/src/modules/admin/admin.openapi.ts index 869c266..548c504 100644 --- a/apps/server/src/modules/admin/admin.openapi.ts +++ b/apps/server/src/modules/admin/admin.openapi.ts @@ -207,10 +207,27 @@ export const ADMIN_OPENAPI_PATHS: Record = { operationId: 'adminUsers', summary: 'Get admin users list', security: bearerSecurity, + parameters: [ + { + name: 'page', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, default: 1 }, + description: 'Page number (1-indexed)', + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 200, default: 25 }, + description: 'Items per page', + }, + ], responses: { 200: usersOkResponse, 401: okJson('Unauthorized'), 403: okJson('Forbidden'), + 422: okJson('Invalid pagination parameters'), }, }, }, @@ -267,10 +284,27 @@ export const ADMIN_OPENAPI_PATHS: Record = { operationId: 'adminEvents', summary: 'Get admin events list', security: bearerSecurity, + parameters: [ + { + name: 'page', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, default: 1 }, + description: 'Page number (1-indexed)', + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 200, default: 25 }, + description: 'Items per page', + }, + ], responses: { 200: eventsOkResponse, 401: okJson('Unauthorized'), 403: okJson('Forbidden'), + 422: okJson('Invalid pagination parameters'), }, }, }, From 0e1a7e7cd8de64fbe74237a4711966f34613d14f Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 14:50:13 +0200 Subject: [PATCH 3/7] chore: update ai agent instructions to update openapi spec and add test when changed --- CLAUDE.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9ca1f24..4a064f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,36 @@ See `src/graphql/__tests__/ws-security.test.ts` for reference. merge, rebase, reset, checkout, etc.) and GitHub API access. Only read-only operations (status, log, diff, show, blame, grep) are allowed. +## Conventions + +### REST API changes + +When adding or modifying a REST endpoint (query params, request body, response +shape, new status codes), **always update the OpenAPI spec in the same PR**. +Each module keeps its spec in `src/modules//.openapi.ts`. The +spec drives generated docs (`/reference`) and is the contract for API consumers. + +Checklist for every endpoint change: + +- New query param → add to `parameters[]` with type, `minimum`/`maximum`, + `default`, and description. +- New error response (e.g. 422 for invalid input) → add to `responses`. +- Changed response shape → update the envelope/results schema reference. + +### Tests + +When changing server-side logic, update or add tests in the same commit: + +- **Unit tests** live next to source in `__tests__/` (e.g. + `src/modules/admin/__tests__/admin.service.test.ts`). Update Prisma mocks to + cover new parameters (`skip`, `take`, filters) and add cases for: happy path, + boundary values, and invalid input that should produce an error. +- **Do not leave existing tests calling the old signature without params** when + the behaviour under default params has changed — verify the defaults are still + correct or add a dedicated default-params test. +- Run `pnpm --filter server test` after every server change and fix failures + before considering the task done. + ## Docs Deep technical docs in `docs/`. `docs/WIKI.md` is a ~58KB technical reference — From 4786022066fa6aa9483511d21e3112312a249caa Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 14:54:13 +0200 Subject: [PATCH 4/7] chore: update postman testing --- CLAUDE.md | 20 ++++++++++++++++ apps/server/postman/collection.json | 36 +++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a064f7..79627c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,26 @@ Checklist for every endpoint change: - New error response (e.g. 422 for invalid input) → add to `responses`. - Changed response shape → update the envelope/results schema reference. +### Postman collection + +The Postman collection lives in `apps/server/postman/collection.json` and is the +scenario-driven integration test suite run via Newman in CI. **Keep it in sync +with every API change** — out-of-sync collections silently pass stale scenarios. + +Checklist for every endpoint change: + +- New query param → add an entry to `url.query[]` with `key`, `value` (the + default), and `description`. Update `url.raw` to include the param. +- New required field in response → add a `pm.test` assertion in the request's + `test` script that validates the field exists and has the expected type. +- New error response (e.g. 422) → add a separate request that sends invalid + input and asserts the expected status code. +- Renamed or removed field → update all `pm.test` assertions that reference it. + +Structure reference: each item has `request.url.query[]` for query params and +`event[listen=test].script.exec[]` for test assertions (array of strings, one +per line). + ### Tests When changing server-side logic, update or add tests in the same commit: diff --git a/apps/server/postman/collection.json b/apps/server/postman/collection.json index b227450..107d2ae 100644 --- a/apps/server/postman/collection.json +++ b/apps/server/postman/collection.json @@ -10036,9 +10036,21 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/rest/v1/admin/users", + "raw": "{{baseUrl}}/rest/v1/admin/users?page=1&limit=25", "host": ["{{baseUrl}}"], - "path": ["rest", "v1", "admin", "users"] + "path": ["rest", "v1", "admin", "users"], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (1-indexed)" + }, + { + "key": "limit", + "value": "25", + "description": "Items per page (max 200)" + } + ] }, "auth": { "type": "bearer", @@ -10079,6 +10091,8 @@ "pm.test('Response is JSON', function () { pm.expect(pm.response.headers.get('Content-Type') || '').to.include('application/json'); });", "const body = pm.response.json();", "pm.test('Admin users list exists', function () { pm.expect(body.results && body.results.items).to.be.an('array'); });", + "pm.test('Admin users total is a non-negative integer', function () { pm.expect(body.results.total).to.be.a('number').and.to.be.at.least(0); });", + "pm.test('Admin users items respects page limit', function () { pm.expect(body.results.items.length).to.be.at.most(25); });", "pm.test('Admin users include the target user', function () { pm.expect(body.results.items.some(item => item.email === pm.collectionVariables.get('adminTargetUserEmail'))).to.eql(true); });", "" ] @@ -10220,9 +10234,21 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/rest/v1/admin/events", + "raw": "{{baseUrl}}/rest/v1/admin/events?page=1&limit=25", "host": ["{{baseUrl}}"], - "path": ["rest", "v1", "admin", "events"] + "path": ["rest", "v1", "admin", "events"], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (1-indexed)" + }, + { + "key": "limit", + "value": "25", + "description": "Items per page (max 200)" + } + ] }, "auth": { "type": "bearer", @@ -10263,6 +10289,8 @@ "pm.test('Response is JSON', function () { pm.expect(pm.response.headers.get('Content-Type') || '').to.include('application/json'); });", "const body = pm.response.json();", "pm.test('Admin events list exists', function () { pm.expect(body.results && body.results.items).to.be.an('array'); });", + "pm.test('Admin events total is a non-negative integer', function () { pm.expect(body.results.total).to.be.a('number').and.to.be.at.least(0); });", + "pm.test('Admin events items respects page limit', function () { pm.expect(body.results.items.length).to.be.at.most(25); });", "" ] } From 15a702a64977ae9f62b1ec315066a09fd17bcead Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 15:04:21 +0200 Subject: [PATCH 5/7] fix: do not update the original author of the event when updating details as user with ADMIN role --- .../modules/event/event.secure.handlers.ts | 4 ++-- apps/server/src/utils/__tests__/authz.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts index 5105500..a9604a3 100644 --- a/apps/server/src/modules/event/event.secure.handlers.ts +++ b/apps/server/src/modules/event/event.secure.handlers.ts @@ -1104,7 +1104,7 @@ export function registerSecureEventRoutes(router) { return ownership.response; } - const { userId } = ownership; + const { userId, event: existingEventOwnership } = ownership; try { const normalizedZeroTime = normalizeUtcTimeString(zeroTime); @@ -1176,7 +1176,7 @@ export function registerSecureEventRoutes(router) { resultsOfficialAt: null, } : {}), - authorId: userId, + authorId: existingEventOwnership.authorId, }, }); diff --git a/apps/server/src/utils/__tests__/authz.test.ts b/apps/server/src/utils/__tests__/authz.test.ts index 3e6a298..309d966 100644 --- a/apps/server/src/utils/__tests__/authz.test.ts +++ b/apps/server/src/utils/__tests__/authz.test.ts @@ -132,6 +132,30 @@ describe('authz helpers', () => { }); }); + it('preserves original event authorId when admin accesses an event they do not own', async () => { + const prisma = createPrismaMock({ + users: { + 4: { role: 'ADMIN' }, + }, + events: { + 'evt-2': { authorId: 99 }, + }, + }); + + const result = await ensureEventOwnerOrAdmin( + prisma, + { + isAuthenticated: true, + type: 'jwt', + userId: 4, + }, + 'evt-2', + ); + + expect(result.event.authorId).toBe(99); + expect(result.userId).toBe(4); + }); + it('rejects regular users on events they do not own', async () => { const prisma = createPrismaMock({ users: { From e026f07fb444a96f2815a07154ce363a82374596 Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sun, 26 Apr 2026 15:07:21 +0200 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20update=20czech=20translation:=20Z?= =?UTF-8?q?=C3=A1vod=20->=20Akce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/i18n/locales/cs/translation.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/client/src/i18n/locales/cs/translation.json b/apps/client/src/i18n/locales/cs/translation.json index ee496ab..a1ca7a1 100644 --- a/apps/client/src/i18n/locales/cs/translation.json +++ b/apps/client/src/i18n/locales/cs/translation.json @@ -18,20 +18,20 @@ "Description": "Centrální pracovní prostor pro administrátory s rychlým přehledem o uživatelích, závodech a publikační aktivitě.", "ErrorTitle": "Nepodařilo se načíst admin data", "ChartTitle": "Měsíční aktivita", - "ChartDescription": "Noví uživatelé a nově vytvořené závody za posledních šest měsíců.", + "ChartDescription": "Noví uživatelé a nově vytvořené akce za posledních šest měsíců.", "ChartUsersSeries": "Vytvoření uživatelé", - "ChartEventsSeries": "Vytvořené závody", + "ChartEventsSeries": "Vytvořené akce", "RecentUsersTitle": "Poslední uživatelé", "RecentUsersDescription": "Naposledy registrované účty v aplikaci.", - "RecentEventsTitle": "Poslední závody", - "RecentEventsDescription": "Nejnovější záznamy závodů aktuálně uložené v OFeed.", + "RecentEventsTitle": "Poslední akce", + "RecentEventsDescription": "Nejnovější záznamy akcí aktuálně uložené v OFeed.", "Cards": { "TotalUsers": "Uživatelé celkem", "ActiveUsersDetail": "{{count}} účtů je aktivních", - "TotalEvents": "Závody celkem", - "PublishedEventsDetail": "{{count}} závodů je publikovaných", - "RankingEvents": "Rankingové závody", - "UpcomingEventsDetail": "{{count}} nadcházejících závodů je naplánováno", + "TotalEvents": "Akce celkem", + "PublishedEventsDetail": "{{count}} akcí je publikovaných", + "RankingEvents": "Rankingové akce", + "UpcomingEventsDetail": "{{count}} nadcházejících akcí je naplánováno", "AdminUsers": "Admin uživatelé", "AdminUsersHint": "Uživatelé s přístupem do admin zóny" } @@ -65,8 +65,8 @@ } }, "Events": { - "Title": "Závody", - "Description": "Posledních {{count}} závodů včetně vlastníka, disciplíny a stavu publikace." + "Title": "Akce", + "Description": "Posledních {{count}} akcí včetně vlastníka, disciplíny a stavu publikace." }, "SystemMessages": { "Title": "Systémové zprávy", @@ -141,7 +141,7 @@ "SnapshotDatasetsHint": "Nahrané měsíční CSV snapshoty seskupené podle typu rankingu a kategorie.", "SnapshotEntries": "Řádky snapshotů", "SnapshotEntriesHint": "Celkový počet rankingových řádků aktuálně uložených z CSV uploadů.", - "EventDatasets": "Synchronizované sady závodů", + "EventDatasets": "Synchronizované sady akcí", "EventDatasetsHint": "ORIS rankingové výsledky seskupené podle závodu, typu rankingu a kategorie.", "EventResults": "Synchronizované řádky výsledků", "EventResultsHint": "Celkový počet individuálních rankingových výsledků synchronizovaných z ORIS." @@ -230,7 +230,7 @@ "UploadSuccessDescription": "Úspěšně bylo importováno {{count}} řádků.", "UploadErrorTitle": "Upload snapshotu selhal", "SyncSuccessTitle": "Synchronizace s ORIS dokončena", - "SyncSuccessDescription": "Synchronizovalo se {{count}} sad závodů.", + "SyncSuccessDescription": "Synchronizovalo se {{count}} sad akcí.", "SyncErrorTitle": "Synchronizace s ORIS selhala", "ClearSnapshotsSuccessTitle": "Snapshoty vyčištěny", "ClearSnapshotsErrorTitle": "Čištění snapshotů selhalo", From e2cfe70ea3a0ab39082463feca93604c35e79033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20K=C5=99ivda?= Date: Tue, 28 Apr 2026 08:52:49 +0200 Subject: [PATCH 7/7] fix: preserve event author on admin updates --- .../src/pages/Admin/AdminEventsPage.tsx | 11 +++- .../client/src/pages/Admin/AdminUsersPage.tsx | 11 +++- apps/client/src/pages/Admin/admin.hooks.ts | 18 ++++-- .../admin/__tests__/admin.handlers.test.ts | 58 +++++++++++++++++++ .../src/modules/admin/admin.handlers.ts | 57 +++++++++++++----- .../server/src/modules/admin/admin.service.ts | 10 +++- .../modules/event/event.secure.handlers.ts | 3 - 7 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/modules/admin/__tests__/admin.handlers.test.ts diff --git a/apps/client/src/pages/Admin/AdminEventsPage.tsx b/apps/client/src/pages/Admin/AdminEventsPage.tsx index 72f594f..a2203c0 100644 --- a/apps/client/src/pages/Admin/AdminEventsPage.tsx +++ b/apps/client/src/pages/Admin/AdminEventsPage.tsx @@ -4,7 +4,11 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/atoms'; -import { AppDataTable, AppPagination, AppRowsPerPage } from '@/components/organisms'; +import { + AppDataTable, + AppPagination, + AppRowsPerPage, +} from '@/components/organisms'; import { TableCell, TableHead, @@ -24,7 +28,10 @@ export function AdminEventsPage() { const { t } = useTranslation(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const { data, isLoading, error } = useAdminEventsQuery({ page, limit: pageSize }); + const { data, isLoading, error } = useAdminEventsQuery({ + page, + limit: pageSize, + }); useEffect(() => { if (!data) return; diff --git a/apps/client/src/pages/Admin/AdminUsersPage.tsx b/apps/client/src/pages/Admin/AdminUsersPage.tsx index 7714ea2..683da77 100644 --- a/apps/client/src/pages/Admin/AdminUsersPage.tsx +++ b/apps/client/src/pages/Admin/AdminUsersPage.tsx @@ -8,7 +8,11 @@ import { useTranslation } from 'react-i18next'; import { Badge, Button } from '@/components/atoms'; import { ConfirmDialog } from '@/components/molecules'; import { useAuth } from '@/hooks/useAuth'; -import { AppDataTable, AppPagination, AppRowsPerPage } from '@/components/organisms'; +import { + AppDataTable, + AppPagination, + AppRowsPerPage, +} from '@/components/organisms'; import { TableCell, TableHead, @@ -35,7 +39,10 @@ export function AdminUsersPage() { const queryClient = useQueryClient(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const { data, isLoading, error } = useAdminUsersQuery({ page, limit: pageSize }); + const { data, isLoading, error } = useAdminUsersQuery({ + page, + limit: pageSize, + }); useEffect(() => { if (!data) return; diff --git a/apps/client/src/pages/Admin/admin.hooks.ts b/apps/client/src/pages/Admin/admin.hooks.ts index 8af3c16..53235ea 100644 --- a/apps/client/src/pages/Admin/admin.hooks.ts +++ b/apps/client/src/pages/Admin/admin.hooks.ts @@ -94,13 +94,18 @@ export function useAdminDashboardQuery() { }); } -export function useAdminUsersQuery({ page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { +export function useAdminUsersQuery({ + page = 1, + limit = 25, +}: { page?: number; limit?: number } = {}) { const api = useApi(); return useQuery({ queryKey: ['admin', 'users', page, limit], queryFn: async () => - adminUserListSchema.parse(await api.get(ENDPOINTS.adminUsers({ page, limit }))), + adminUserListSchema.parse( + await api.get(ENDPOINTS.adminUsers({ page, limit })) + ), }); } @@ -128,13 +133,18 @@ export function useAdminUserDeleteMutation() { }); } -export function useAdminEventsQuery({ page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { +export function useAdminEventsQuery({ + page = 1, + limit = 25, +}: { page?: number; limit?: number } = {}) { const api = useApi(); return useQuery({ queryKey: ['admin', 'events', page, limit], queryFn: async () => - adminEventListSchema.parse(await api.get(ENDPOINTS.adminEvents({ page, limit }))), + adminEventListSchema.parse( + await api.get(ENDPOINTS.adminEvents({ page, limit })) + ), }); } diff --git a/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts b/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts new file mode 100644 index 0000000..351197b --- /dev/null +++ b/apps/server/src/modules/admin/__tests__/admin.handlers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { HTTP_STATUS } from '../../../constants/index.js'; +import { getAdminEventsHandler, getAdminUsersHandler } from '../admin.handlers.js'; + +function createContext(query: Record) { + return { + get: (key: string) => (key === 'requestId' ? 'test-request-id' : undefined), + req: { + method: 'GET', + path: '/rest/v1/admin/users', + query: (key: string) => query[key], + }, + json: (body: unknown, status: number) => ({ body, status }), + }; +} + +describe('admin handlers pagination validation', () => { + it('rejects partially numeric users page query parameter', async () => { + const response = await getAdminUsersHandler(createContext({ page: '1abc' })); + + expect(response.status).toBe(HTTP_STATUS.UNPROCESSABLE_CONTENT); + expect(response.body).toMatchObject({ + error: true, + code: HTTP_STATUS.UNPROCESSABLE_CONTENT, + }); + }); + + it('rejects partially numeric users limit query parameter', async () => { + const response = await getAdminUsersHandler(createContext({ limit: '25foo' })); + + expect(response.status).toBe(HTTP_STATUS.UNPROCESSABLE_CONTENT); + expect(response.body).toMatchObject({ + error: true, + code: HTTP_STATUS.UNPROCESSABLE_CONTENT, + }); + }); + + it('rejects partially numeric events page query parameter', async () => { + const response = await getAdminEventsHandler(createContext({ page: '1.9' })); + + expect(response.status).toBe(HTTP_STATUS.UNPROCESSABLE_CONTENT); + expect(response.body).toMatchObject({ + error: true, + code: HTTP_STATUS.UNPROCESSABLE_CONTENT, + }); + }); + + it('rejects partially numeric events limit query parameter', async () => { + const response = await getAdminEventsHandler(createContext({ limit: '25foo' })); + + expect(response.status).toBe(HTTP_STATUS.UNPROCESSABLE_CONTENT); + expect(response.body).toMatchObject({ + error: true, + code: HTTP_STATUS.UNPROCESSABLE_CONTENT, + }); + }); +}); diff --git a/apps/server/src/modules/admin/admin.handlers.ts b/apps/server/src/modules/admin/admin.handlers.ts index 7560edf..664868a 100644 --- a/apps/server/src/modules/admin/admin.handlers.ts +++ b/apps/server/src/modules/admin/admin.handlers.ts @@ -4,7 +4,11 @@ import { adminUserActiveUpdateInputSchema } from '@repo/shared'; import { HTTP_STATUS } from '../../constants/index.js'; import { logger } from '../../lib/logging.js'; import { getAdminUserId } from '../../middlewares/require-admin.js'; -import { error as errorResponse, success as successResponse, validation as validationResponse } from '../../utils/responseApi.js'; +import { + error as errorResponse, + success as successResponse, + validation as validationResponse, +} from '../../utils/responseApi.js'; import prisma from '../../utils/context.js'; import { @@ -31,6 +35,21 @@ function buildAdminLogContext(c, adminUserId: number | null) { }; } +function parsePositiveIntegerQueryParam(value: string | undefined, defaultValue: number) { + if (value === undefined) { + return defaultValue; + } + + const normalized = value.trim(); + + if (!/^\d+$/.test(normalized)) { + return null; + } + + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null; +} + export async function getAdminDashboardHandler(c) { const adminUserId = getAdminUserId(c); const logContext = buildAdminLogContext(c, adminUserId); @@ -70,14 +89,20 @@ export async function getAdminUsersHandler(c) { try { const rawPage = c.req.query('page'); const rawLimit = c.req.query('limit'); - const parsedPage = rawPage !== undefined ? parseInt(rawPage, 10) : 1; - const parsedLimit = rawLimit !== undefined ? parseInt(rawLimit, 10) : 25; + const parsedPage = parsePositiveIntegerQueryParam(rawPage, 1); + const parsedLimit = parsePositiveIntegerQueryParam(rawLimit, 25); - if (!Number.isFinite(parsedPage) || parsedPage < 1) { - return c.json(validationResponse('Invalid page parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + if (parsedPage === null) { + return c.json( + validationResponse('Invalid page parameter'), + HTTP_STATUS.UNPROCESSABLE_CONTENT, + ); } - if (!Number.isFinite(parsedLimit) || parsedLimit < 1) { - return c.json(validationResponse('Invalid limit parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + if (parsedLimit === null) { + return c.json( + validationResponse('Invalid limit parameter'), + HTTP_STATUS.UNPROCESSABLE_CONTENT, + ); } const page = parsedPage; @@ -114,14 +139,20 @@ export async function getAdminEventsHandler(c) { try { const rawPage = c.req.query('page'); const rawLimit = c.req.query('limit'); - const parsedPage = rawPage !== undefined ? parseInt(rawPage, 10) : 1; - const parsedLimit = rawLimit !== undefined ? parseInt(rawLimit, 10) : 25; + const parsedPage = parsePositiveIntegerQueryParam(rawPage, 1); + const parsedLimit = parsePositiveIntegerQueryParam(rawLimit, 25); - if (!Number.isFinite(parsedPage) || parsedPage < 1) { - return c.json(validationResponse('Invalid page parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + if (parsedPage === null) { + return c.json( + validationResponse('Invalid page parameter'), + HTTP_STATUS.UNPROCESSABLE_CONTENT, + ); } - if (!Number.isFinite(parsedLimit) || parsedLimit < 1) { - return c.json(validationResponse('Invalid limit parameter'), HTTP_STATUS.UNPROCESSABLE_CONTENT); + if (parsedLimit === null) { + return c.json( + validationResponse('Invalid limit parameter'), + HTTP_STATUS.UNPROCESSABLE_CONTENT, + ); } const page = parsedPage; diff --git a/apps/server/src/modules/admin/admin.service.ts b/apps/server/src/modules/admin/admin.service.ts index db6c569..fe76c48 100644 --- a/apps/server/src/modules/admin/admin.service.ts +++ b/apps/server/src/modules/admin/admin.service.ts @@ -294,7 +294,10 @@ export async function getAdminDashboard(prisma, referenceDate = new Date()) { }); } -export async function getAdminUsers(prisma, { page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { +export async function getAdminUsers( + prisma, + { page = 1, limit = 25 }: { page?: number; limit?: number } = {}, +) { const skip = (page - 1) * limit; const [total, users] = await Promise.all([ @@ -465,7 +468,10 @@ export async function deleteAdminUser( }); } -export async function getAdminEvents(prisma, { page = 1, limit = 25 }: { page?: number; limit?: number } = {}) { +export async function getAdminEvents( + prisma, + { page = 1, limit = 25 }: { page?: number; limit?: number } = {}, +) { const skip = (page - 1) * limit; const [total, events] = await Promise.all([ diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts index 233ac69..1b6efa1 100644 --- a/apps/server/src/modules/event/event.secure.handlers.ts +++ b/apps/server/src/modules/event/event.secure.handlers.ts @@ -1110,8 +1110,6 @@ export function registerSecureEventRoutes(router) { return ownership.response; } - const { userId, event: existingEventOwnership } = ownership; - try { const parsedEntriesOpenAt = parseOptionalIsoDateTime(entriesOpenAt); const parsedEntriesCloseAt = parseOptionalIsoDateTime(entriesCloseAt); @@ -1181,7 +1179,6 @@ export function registerSecureEventRoutes(router) { resultsOfficialAt: null, } : {}), - authorId: existingEventOwnership.authorId, }, });