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
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,56 @@ 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/<module>/<module>.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.

### 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:

- **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 —
Expand Down
24 changes: 12 additions & 12 deletions apps/client/src/i18n/locales/cs/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions apps/client/src/lib/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
37 changes: 35 additions & 2 deletions apps/client/src/pages/Admin/AdminEventsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Link } from '@tanstack/react-router';
import { format } from 'date-fns';
import { useEffect, 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,
Expand All @@ -21,7 +26,18 @@ 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,
});

useEffect(() => {
if (!data) return;
const totalPages = Math.max(1, Math.ceil(data.total / pageSize));
if (page > totalPages) setPage(totalPages);
}, [data, page, pageSize]);

return (
<AdminPageLayout
Expand Down Expand Up @@ -51,6 +67,23 @@ export function AdminEventsPage() {
error={error}
columnCount={7}
emptyStateText={t('Pages.Admin.Table.Empty')}
renderToolbar={
<AppRowsPerPage
pageSize={pageSize}
onPageSizeChange={size => {
setPageSize(size);
setPage(1);
}}
/>
}
renderPagination={
<AppPagination
page={page}
pageSize={pageSize}
totalItems={data?.total ?? 0}
onPageChange={setPage}
/>
}
renderHeader={
<TableHeader>
<TableRow>
Expand Down
39 changes: 36 additions & 3 deletions apps/client/src/pages/Admin/AdminUsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import type { AdminUserListItem } from '@repo/shared';
import { useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Power, PowerOff, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
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,
Expand All @@ -33,7 +37,19 @@ 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,
});

useEffect(() => {
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<{
Expand Down Expand Up @@ -145,6 +161,23 @@ export function AdminUsersPage() {
error={error}
columnCount={7}
emptyStateText={t('Pages.Admin.Table.Empty')}
renderToolbar={
<AppRowsPerPage
pageSize={pageSize}
onPageSizeChange={size => {
setPageSize(size);
setPage(1);
}}
/>
}
renderPagination={
<AppPagination
page={page}
pageSize={pageSize}
totalItems={data?.total ?? 0}
onPageChange={setPage}
/>
}
renderHeader={
<TableHeader>
<TableRow>
Expand Down
22 changes: 16 additions & 6 deletions apps/client/src/pages/Admin/admin.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,18 @@ 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 }))
),
});
}

Expand Down Expand Up @@ -128,13 +133,18 @@ 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 }))
),
});
}

Expand Down
36 changes: 32 additions & 4 deletions apps/server/postman/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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); });",
""
]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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); });",
""
]
}
Expand Down
Loading
Loading