# Prompt-to-PR: Admin-Datentabelle hinzufügen

> SOP zum Hinzufügen einer serverseitig paginierten, sortierbaren, durchsuchbaren Admin-Datentabelle in Next.js mit TanStack Table und einer PostgreSQL-Abfrage — kein ORM-Zauber.

**Type:** Playbook  
**Tools:** Cursor, Claude Code, Codex, Windsurf  
**Stack:** Next.js, TypeScript, PostgreSQL, Tailwind  
**Difficulty:** medium  
**Updated:** 2026-06-08

---

Admin-Tabellen sind eine häufige KI-Codierungsaufgabe, die oft unsichere, rein clientseitige Implementierungen hervorbringt. Dieses Playbook hält Paginierung und Sortierung serverseitig und schützt die Route explizit.

## 1. Anforderung

Füge eine `/admin/users`-Seite hinzu, die nur für Benutzer mit `role = "admin"` zugänglich ist. Die Tabelle ist serverseitig paginiert (25 Zeilen), nach Spalten sortierbar und nach E-Mail durchsuchbar. Die Daten stammen aus einer PostgreSQL-`users`-Tabelle über eine parametrisierte Abfrage — kein unparametrisiertes SQL.

## 2. Erster Prompt

```txt title="First Prompt"
Add an admin users table to this Next.js 15 App Router project.

Requirements:
1. Create `src/app/admin/users/page.tsx` — an async Server Component.
   - Protect it: check the session (using the existing auth helper at
     src/lib/auth.ts). If no session or session.user.role !== "admin",
     call notFound() or redirect to /.
   - Read `page` (default 1), `sort` (default "createdAt"), `order`
     ("asc"|"desc", default "desc"), and `q` (search string) from
     `searchParams`.
   - Fetch data by calling a function `getUsers({ page, sort, order, q })`
     from `src/lib/queries/users.ts`.
   - Pass results to `<UsersTable>` client component.
2. Create `src/lib/queries/users.ts` exporting `getUsers`.
   - Use parameterized SQL via the existing db client (Drizzle or postgres.js,
     whichever is in this project).
   - Allowed sort columns whitelist: ["email","name","createdAt","role"].
     Reject any other sort value by defaulting to "createdAt".
   - Return `{ rows: User[], total: number }`.
   - Page size: 25.
3. Create `src/components/admin/UsersTable.tsx` — a Client Component using
   TanStack Table v8.
   - Columns: email, name, role, createdAt (formatted), actions (Edit link).
   - Column headers are clickable to sort — update URL search params via
     useRouter/useSearchParams (do not manage sort state locally).
   - Render a search input that debounces 300 ms before updating the URL.
   - Render a pagination row: Previous / Next and "Page N of M".
4. Install `@tanstack/react-table` if not already present.
5. Do not implement the Edit action — leave it as a placeholder link.
```

## 3. Erwartete Dateiänderungen

```txt
package.json                                   (@tanstack/react-table if missing)
src/app/admin/users/page.tsx                   (new — protected Server Component)
src/lib/queries/users.ts                       (new — parameterized query)
src/components/admin/UsersTable.tsx            (new — TanStack Table client component)
```

## 4. Review-Checkliste

- Die Seite prüft serverseitig `session.user.role === "admin"` — kein reiner Client-Guard.
- `getUsers` verwendet eine Whitelist zulässiger Sortierspalten, bevor sie in SQL eingefügt werden — keine rohen Benutzereingaben in Spaltennamen.
- Alle WHERE/LIMIT/OFFSET-Werte verwenden parametrisierte Platzhalter, keine String-Verkettung.
- Die Suche (`q`) verwendet `ILIKE '%' || $1 || '%'` mit einem Parameter, keine String-Interpolation.
- TanStack Table wird nur in der `"use client"`-Komponente verwendet — kein `@tanstack/react-table`-Import in Server Components.
- URL-Suchparameter steuern den Sortier-/Seiten-/Suchstatus — der Zurück-Button des Browsers funktioniert korrekt.
- Der `total`-Wert stammt aus einem `SELECT COUNT(*)` in derselben Abfragefunktion — kein vollständiger Tabellen-Scan getrennt von der paginierten Abfrage.

## 5. Test-Befehle

```bash
bun dev

# Hit the page as an unauthenticated user
curl -I http://localhost:3000/admin/users
# Expect: 404 or redirect, not 200

# Log in as an admin user in the browser, visit /admin/users
# Confirm: 25 rows, sortable columns, search, pagination

# SQL audit — confirm no dynamic column names slip through
grep -n "sort\|order\|column" src/lib/queries/users.ts

bun tsc --noEmit
bun test
```

## 6. Häufige Fehler

- **SQL-Injection über Sortierspalte** — der Agent interpoliert `sort` direkt: `` `ORDER BY ${sort}` ``. Es muss eine Whitelist-Lookup auf einen sicheren Spaltennamen-String verwendet werden.
- **`searchParams` ist asynchron in Next.js 15+** — muss mit `await` aufgerufen werden: `const { page } = await searchParams`.
- **TanStack Table rendert in einer Server-Komponente** — Build-Fehler, weil es `useState` verwendet. Verschiebe es in eine `"use client"`-Komponente.
- **`total` ist um eine Seite verschoben** — `Math.ceil(total / 25)` ergibt eine falsche Seitenzahl, wenn `total` 0 ist. Absicherung: `Math.max(1, Math.ceil(total / 25))`.
- **Rollenprüfung fehlt** — der Agent prüft nur auf eine Session, nicht auf die Admin-Rolle. Jeder eingeloggte Benutzer kann auf die Tabelle zugreifen.

## 7. Fix-Prompt

```txt title="Fix Prompt"
The sort column in getUsers is interpolated directly into SQL:
  `ORDER BY ${sort} ${order}`
This is a SQL injection risk.

Fix: create an allowlist object:
  const ALLOWED_SORT: Record<string, string> = {
    email: "users.email",
    name: "users.name",
    createdAt: "users.created_at",
    role: "users.role",
  };
  const safeSort = ALLOWED_SORT[sort] ?? "users.created_at";

Then use `safeSort` in the query. The order direction must also be
validated: only accept "asc" or "desc", default to "desc".
```

## 8. PR-Beschreibung

```md title="PR description"
## Feature: Admin users table at /admin/users

- Server-side pagination (25 rows/page), sorting, and email search
- Role guard: redirects non-admins server-side (no client-only check)
- Parameterized SQL with sort-column whitelist — no injection surface
- TanStack Table v8 client component; sort/page/search state lives in URL
- `getUsers` returns `{ rows, total }` from a single query function
```