Files
logbuch/docs/superpowers/plans/2026-07-30-logbuch-fundament.md
Matthias Giesselmann b90ff252d1 Add Logbuch: project update blog with admin, media and API
Public archive with sidebar navigation, project pages, month archive,
search and entry pages with image blocks. Admin area for brands,
projects, post types, users, api clients, media and the entry editor
with drag and drop images, preview per audience, scheduling and
publish checks. Read and write API with bearer tokens, audience
scoping, idempotent creation, OpenAPI document and editorial guide.
Magic link login with configurable allowed domains, whole app behind
the session gate. 456 tests including design rule checks.
2026-07-31 21:33:42 +02:00

2141 lines
64 KiB
Markdown

# Logbuch Fundament Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Datenfundament und Lese-API von Logbuch: Projekte und Beiträge liegen in Postgres, die Sichtbarkeitsregel greift serverseitig, und eine fremde Anwendung kann ihre Beiträge samt gelesen-Status über die API abrufen.
**Architecture:** Vier Schichten mit klaren Grenzen. `src/domain` enthält reine Funktionen ohne Framework und Datenbank (Sichtbarkeit, Statusübergänge, Veröffentlichungsprüfungen). `src/data` enthält Drizzle-Schema und Repositories, das einzige SQL im Projekt. `src/app/api/v1` ist die HTTP-Grenze mit zod-Validierung, Token-Prüfung und Problem-JSON. UI kommt erst in Plan 2 und 3.
**Tech Stack:** Next.js 16 (App Router), React 19, TypeScript, PostgreSQL 18, Drizzle ORM 0.45, zod 4, Vitest, Tailwind 4, pnpm.
## Global Constraints
Diese Regeln gelten für jede Aufgabe in diesem Plan, ohne dass sie dort wiederholt werden.
- Paketmanager ist ausschließlich `pnpm`, niemals `npm` oder `yarn`.
- Einrückung mit Tabs, Tabbreite 4. Ausnahme YAML: 2 Leerzeichen.
- Keine Kommentare im Code. Wenn Code erklärt werden muss, ist er falsch benannt.
- Keine Emojis, weder in Code noch in Oberflächen noch in Commit-Messages.
- Keine Gedankenstriche in Texten und Copy, immer normales Minus.
- Echte Umlaute in allen deutschen Texten, nie `ae`, `oe`, `ue`, `ss`.
- Keine Pixelangaben in CSS, Größen und Abstände über `rem` oder die Tailwind-Skala.
- Alle Oberflächentexte über next-intl, keine festverdrahteten Zeichenketten. Betrifft in diesem Plan nur Fehlertexte in API-Antworten: diese bleiben englische Maschinencodes, die Anzeige übersetzt später das Frontend.
- Bei jeder Unsicherheit zu einer Bibliotheks-API zuerst die offizielle Dokumentation prüfen, niemals aus dem Gedächtnis schreiben.
- **Kein git in diesem Plan.** Kein `git init`, kein `git add`, keine Commits, kein Remote. Alles bleibt lokal, bis der Auftraggeber die laufende App auf seinem Rechner gesehen und abgenommen hat. Erst danach wird ein Repository angelegt und der Stand als ein Commit gesichert.
- Statt Commits endet jede Aufgabe mit einem grünen Testlauf. Der Nachweis ist die Ausgabe, nicht die Historie.
- Versionen, am 30.07.2026 gegen die Registry geprüft: Next `16.2.12`, React `19.2.8`, Drizzle ORM `0.45.2`, drizzle-kit `0.31.10`, zod `4.4.3`, Tailwind `4.3.3`, Vitest `4.1.10`, pg `8.22.0`, Postgres-Image `18`. Vor dem Installieren erneut `npm view <paket> version` prüfen, nie eine Version aus dem Gedächtnis setzen.
## Referenzen
- Spec: `docs/superpowers/specs/2026-07-30-logbuch-design.md`
- Design-Mockup: `docs/design/mockup-overview.html`
## Umfang dieses Plans
Enthalten: Projekt-Setup, Datenbank-Schema, Domain-Logik für Sichtbarkeit und Status, Repositories, Lese-API mit Token-Prüfung, gelesen-Status, Seed-Daten.
Nicht enthalten und in Folgeplänen: Admin und Editor (Plan 2), Web-Archiv (Plan 3), Client-Pakete für React und Vue (Plan 4), Schreib-API mit Medien-Upload und Import-Vorschläge (Plan 5).
## Dateistruktur
| Datei | Verantwortung |
|---|---|
| `src/domain/audience.ts` | Zielgruppen-Rangfolge und Filterentscheidung |
| `src/domain/post-status.ts` | Erlaubte Statusübergänge |
| `src/domain/publish-checks.ts` | Prüfungen vor Veröffentlichung, getrennt in Sperre und Hinweis |
| `src/domain/types.ts` | Geteilte Domain-Typen ohne Datenbankbezug |
| `src/data/schema/brands.ts` | Tabellen `brand`, `project` |
| `src/data/schema/posts.ts` | Tabellen `post`, `block`, `issue`, `tag`, `post_tag` |
| `src/data/schema/media.ts` | Tabelle `media` |
| `src/data/schema/clients.ts` | Tabellen `api_client`, `post_read` |
| `src/data/schema/index.ts` | Sammelexport aller Tabellen und Enums |
| `src/data/db.ts` | Datenbankverbindung |
| `src/data/repositories/projects.ts` | Abfragen auf `brand` und `project` |
| `src/data/repositories/posts.ts` | Abfragen auf `post` mit Filtern und Seiten |
| `src/data/repositories/reads.ts` | Lesen und Schreiben von `post_read` |
| `src/data/repositories/clients.ts` | Token-Auflösung für `api_client` |
| `src/lib/problem.ts` | Einheitliche Fehlerantwort als Problem-JSON |
| `src/lib/api-auth.ts` | Bearer-Token aus dem Request auflösen |
| `src/app/api/v1/projects/route.ts` | `GET /api/v1/projects` |
| `src/app/api/v1/posts/route.ts` | `GET /api/v1/posts` |
| `src/app/api/v1/posts/[slug]/route.ts` | `GET /api/v1/posts/:slug` |
| `src/app/api/v1/unread/route.ts` | `GET /api/v1/unread` |
| `src/app/api/v1/read/route.ts` | `POST /api/v1/read` |
| `scripts/seed.ts` | Beispieldaten aus dem Mockup |
| `tests/setup.ts` | Testdatenbank migrieren und leeren |
---
### Task 1: Projekt-Setup
**Files:**
- Create: `package.json`, `tsconfig.json`, `next.config.ts`, `postcss.config.mjs`, `vitest.config.ts`, `drizzle.config.ts`, `docker-compose.yml`, `.env.example`, `.gitignore`, `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/globals.css`, `tests/smoke.test.ts`
**Interfaces:**
- Consumes: nichts
- Produces: laufender Dev-Server auf Port 4700, `pnpm test` startet Vitest, `DATABASE_URL` und `DATABASE_URL_TEST` als Umgebungsvariablen
- [x] **Step 1: Arbeitsverzeichnis festlegen**
```bash
cd /Volumes/M2mini/WORK/NYO/projects/logbuch
ls docs
```
Erwartet: `design`, `superpowers`. Kein `git init`, siehe Global Constraints.
- [x] **Step 2: Abhängigkeiten installieren**
```bash
pnpm init
pnpm add next@16.2.12 react@19.2.8 react-dom@19.2.8 drizzle-orm@0.45.2 pg@8.22.0 zod@4.4.3
pnpm add -D typescript @types/node @types/react @types/react-dom @types/pg drizzle-kit vitest tsx tailwindcss@^4 @tailwindcss/postcss dotenv
```
- [x] **Step 3: `package.json` Skripte setzen**
```json
{
"name": "logbuch",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 4700",
"build": "next build",
"start": "next start --port 4700",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx scripts/seed.ts"
}
}
```
- [x] **Step 4: `tsconfig.json` anlegen**
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"incremental": true,
"resolveJsonModule": true,
"allowJs": false,
"plugins": [{ "name": "next" }],
"paths": { "~/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
- [x] **Step 5: Postgres über Docker bereitstellen**
`docker-compose.yml`, YAML mit 2 Leerzeichen:
```yaml
services:
db:
image: postgres:18
environment:
POSTGRES_USER: logbuch
POSTGRES_PASSWORD: logbuch
POSTGRES_DB: logbuch
ports:
- "5442:5432"
volumes:
- logbuch-db:/var/lib/postgresql/data
volumes:
logbuch-db:
```
`.env.example`:
```
DATABASE_URL=postgres://logbuch:logbuch@localhost:5442/logbuch
DATABASE_URL_TEST=postgres://logbuch:logbuch@localhost:5442/logbuch_test
```
Starten und Testdatenbank anlegen:
```bash
cp .env.example .env
docker compose up -d
docker compose exec db psql -U logbuch -d logbuch -c "CREATE DATABASE logbuch_test"
```
- [x] **Step 6: Drizzle konfigurieren**
`drizzle.config.ts`:
```ts
import { defineConfig } from 'drizzle-kit'
import 'dotenv/config'
export default defineConfig({
dialect: 'postgresql',
schema: './src/data/schema/index.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL! },
})
```
- [x] **Step 7: Tailwind und Tokens anlegen**
`postcss.config.mjs`:
```js
export default {
plugins: { '@tailwindcss/postcss': {} },
}
```
`src/app/globals.css`. Die Farbwerte stammen aus `docs/design/mockup-overview.html`. Hell ist Standard, Dunkel tauscht dieselben Variablen, damit Komponenten keine eigenen Dunkel-Regeln brauchen:
```css
@import "tailwindcss";
@theme {
--color-paper: #eff0ec;
--color-surface: #fbfbf9;
--color-ink: #191c20;
--color-ink-2: #4a4f56;
--color-ink-3: #868b92;
--color-rule: #d8d9d2;
--color-signal: #c43a22;
--color-link: #2b4a9b;
--font-display: "Futura", "Avenir Next", system-ui, sans-serif;
--font-body: "Charter", "Iowan Old Style", Georgia, serif;
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--color-paper: #15171a;
--color-surface: #1c1f23;
--color-ink: #e9e8e3;
--color-ink-2: #a8adb4;
--color-ink-3: #73787f;
--color-rule: #2c3036;
--color-signal: #e46045;
--color-link: #8aa6ee;
}
}
:root[data-theme="dark"] {
--color-paper: #15171a;
--color-surface: #1c1f23;
--color-ink: #e9e8e3;
--color-ink-2: #a8adb4;
--color-ink-3: #73787f;
--color-rule: #2c3036;
--color-signal: #e46045;
--color-link: #8aa6ee;
}
body {
background: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-body);
}
```
Bevor diese Datei geschrieben wird: die Tailwind-4-Dokumentation zu `@theme` und zum Dunkelmodus über ein `data`-Attribut prüfen unter https://tailwindcss.com/docs/theme und https://tailwindcss.com/docs/dark-mode. Der Umschalter über `data-theme` kommt in Plan 3, hier zählt nur, dass die Tokens greifen. Die Datei bleibt dauerhaft unter 150 Zeilen.
- [x] **Step 8: Minimale App-Hülle anlegen**
`src/app/layout.tsx`:
```tsx
import './globals.css'
import type { ReactNode } from 'react'
export const metadata = { title: 'Logbuch' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="de">
<body>{children}</body>
</html>
)
}
```
`src/app/page.tsx`:
```tsx
export default function Page() {
return <main>Logbuch</main>
}
```
- [x] **Step 9: Vitest konfigurieren**
`vitest.config.ts`:
```ts
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./tests/setup.ts'],
fileParallelism: false,
},
resolve: {
alias: { '~': fileURLToPath(new URL('./src', import.meta.url)) },
},
})
```
`tests/setup.ts` vorläufig, wird in Task 2 erweitert:
```ts
import 'dotenv/config'
```
`tests/smoke.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
describe('setup', () => {
it('liest die Testdatenbank-Adresse aus der Umgebung', () => {
expect(process.env.DATABASE_URL_TEST).toContain('logbuch_test')
})
})
```
- [x] **Step 10: Tests und Build prüfen**
```bash
pnpm test
pnpm typecheck
pnpm dev
```
Erwartet: Vitest grün, `typecheck` ohne Fehler, Dev-Server liefert auf http://localhost:4700 die Seite mit dem Wort Logbuch.
---
### Task 2: Schema für Marken und Projekte
**Files:**
- Create: `src/data/schema/brands.ts`, `src/data/schema/index.ts`, `src/data/db.ts`, `src/data/repositories/projects.ts`, `tests/data/projects.test.ts`
- Modify: `tests/setup.ts`
**Interfaces:**
- Consumes: `DATABASE_URL_TEST` aus Task 1
- Produces: `db`, Tabellen `brands`, `projects`, Funktionen `listBrands(): Promise<Brand[]>` und `listProjects(): Promise<Project[]>`, Typen `Brand` und `Project`
- [x] **Step 1: Datenbankverbindung anlegen**
`src/data/db.ts`:
```ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'
const url = process.env.NODE_ENV === 'test' ? process.env.DATABASE_URL_TEST : process.env.DATABASE_URL
if (!url) {
throw new Error('DATABASE_URL is not set')
}
export const pool = new Pool({ connectionString: url })
export const db = drizzle(pool, { schema })
export type Db = typeof db
```
- [x] **Step 2: Tabellen anlegen**
`src/data/schema/brands.ts`:
```ts
import { boolean, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const brands = pgTable('brand', {
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
color: text('color').notNull().default('#191c20'),
sort: integer('sort').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const projects = pgTable('project', {
id: uuid('id').primaryKey().defaultRandom(),
brandId: uuid('brand_id').notNull().references(() => brands.id, { onDelete: 'restrict' }),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
description: text('description'),
color: text('color').notNull().default('#191c20'),
isActive: boolean('is_active').notNull().default(true),
sort: integer('sort').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type Brand = typeof brands.$inferSelect
export type Project = typeof projects.$inferSelect
```
`src/data/schema/index.ts`:
```ts
export * from './brands'
```
- [x] **Step 3: Migration erzeugen und anwenden**
```bash
pnpm db:generate
pnpm db:migrate
DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate
```
Erwartet: eine neue Datei unter `drizzle/`, beide Datenbanken haben die Tabellen `brand` und `project`.
- [x] **Step 4: Testaufräumen einrichten**
`tests/setup.ts`:
```ts
import 'dotenv/config'
import { beforeEach } from 'vitest'
import { sql } from 'drizzle-orm'
import { db } from '~/data/db'
process.env.NODE_ENV = 'test'
beforeEach(async () => {
await db.execute(sql`truncate table project, brand restart identity cascade`)
})
```
- [x] **Step 5: Failing Test schreiben**
`tests/data/projects.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { brands, projects } from '~/data/schema'
import { listProjects } from '~/data/repositories/projects'
describe('listProjects', () => {
it('liefert aktive Projekte nach Sortierung und Namen', async () => {
const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning()
await db.insert(projects).values([
{ brandId: brand!.id, slug: 'trakk', name: 'Trakk', sort: 2 },
{ brandId: brand!.id, slug: 'mta360', name: 'MTA360', sort: 1 },
{ brandId: brand!.id, slug: 'alt', name: 'Altprojekt', isActive: false, sort: 0 },
])
const result = await listProjects()
expect(result.map(project => project.slug)).toEqual(['mta360', 'trakk'])
})
})
```
- [x] **Step 6: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/data/projects.test.ts
```
Erwartet: FAIL, `listProjects` existiert nicht.
- [x] **Step 7: Repository schreiben**
`src/data/repositories/projects.ts`:
```ts
import { asc, eq } from 'drizzle-orm'
import { db } from '../db'
import { brands, projects, type Brand, type Project } from '../schema'
export async function listProjects(): Promise<Project[]> {
return db.select().from(projects).where(eq(projects.isActive, true)).orderBy(asc(projects.sort), asc(projects.name))
}
export async function listBrands(): Promise<Brand[]> {
return db.select().from(brands).orderBy(asc(brands.sort), asc(brands.name))
}
export async function findProjectBySlug(slug: string): Promise<Project | undefined> {
const rows = await db.select().from(projects).where(eq(projects.slug, slug)).limit(1)
return rows[0]
}
```
- [x] **Step 8: Test laufen lassen**
```bash
pnpm vitest run tests/data/projects.test.ts
```
Erwartet: PASS.
---
### Task 3: Schema für Beiträge, Blöcke, Ausgaben, Medien
**Files:**
- Create: `src/data/schema/posts.ts`, `src/data/schema/media.ts`
- Modify: `src/data/schema/index.ts`, `tests/setup.ts`
**Interfaces:**
- Consumes: `brands`, `projects` aus Task 2
- Produces: Tabellen `posts`, `blocks`, `issues`, `tags`, `postTags`, `media`, Enums `postType`, `postAudience`, `postStatus`, Typen `Post`, `Block`, `Issue`, `Media`
- [x] **Step 1: Enums und Tabellen anlegen**
`src/data/schema/media.ts`:
```ts
import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { projects } from './brands'
export const mediaKind = pgEnum('media_kind', ['image', 'video'])
export type MediaVariant = {
width: number
format: string
path: string
}
export const media = pgTable('media', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
kind: mediaKind('kind').notNull().default('image'),
originalFilename: text('original_filename').notNull(),
mime: text('mime').notNull(),
width: integer('width'),
height: integer('height'),
byteSize: integer('byte_size').notNull(),
variants: jsonb('variants').$type<MediaVariant[]>().notNull().default([]),
alt: text('alt'),
caption: text('caption'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type Media = typeof media.$inferSelect
```
`src/data/schema/posts.ts`:
```ts
import { index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core'
import { projects } from './brands'
import { media } from './media'
export const postType = pgEnum('post_type', ['feature', 'improvement', 'fix', 'breaking', 'info'])
export const postAudience = pgEnum('post_audience', ['internal', 'customer', 'public'])
export const postStatus = pgEnum('post_status', ['draft', 'review', 'scheduled', 'published', 'archived'])
export const postLocale = pgEnum('post_locale', ['de', 'en'])
export const issueStatus = pgEnum('issue_status', ['draft', 'scheduled', 'published'])
export const issues = pgTable('issue', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
periodFrom: timestamp('period_from', { withTimezone: true }),
periodTo: timestamp('period_to', { withTimezone: true }),
status: issueStatus('status').notNull().default('draft'),
publishAt: timestamp('publish_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const posts = pgTable('post', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }),
number: integer('number').notNull(),
slug: text('slug').notNull(),
title: text('title').notNull(),
teaser: text('teaser'),
type: postType('type').notNull().default('feature'),
audience: postAudience('audience').notNull().default('internal'),
status: postStatus('status').notNull().default('draft'),
locale: postLocale('locale').notNull().default('de'),
translationGroup: uuid('translation_group'),
publishAt: timestamp('publish_at', { withTimezone: true }),
authorName: text('author_name'),
coverMediaId: uuid('cover_media_id').references(() => media.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, table => [
unique('post_project_slug').on(table.projectId, table.slug),
unique('post_project_number').on(table.projectId, table.number),
index('post_publish_at').on(table.publishAt),
index('post_project_status').on(table.projectId, table.status),
])
export const blocks = pgTable('block', {
id: uuid('id').primaryKey().defaultRandom(),
postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
sort: integer('sort').notNull().default(0),
type: text('type').notNull(),
data: jsonb('data').$type<Record<string, unknown>>().notNull().default({}),
}, table => [index('block_post_sort').on(table.postId, table.sort)])
export const tags = pgTable('tag', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
label: text('label').notNull(),
}, table => [unique('tag_project_slug').on(table.projectId, table.slug)])
export const postTags = pgTable('post_tag', {
postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
}, table => [primaryKey({ columns: [table.postId, table.tagId] })])
export type Issue = typeof issues.$inferSelect
export type Post = typeof posts.$inferSelect
export type Block = typeof blocks.$inferSelect
export type Tag = typeof tags.$inferSelect
```
`src/data/schema/index.ts`:
```ts
export * from './brands'
export * from './media'
export * from './posts'
```
- [x] **Step 2: Migration erzeugen und anwenden**
```bash
pnpm db:generate
pnpm db:migrate
DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate
```
- [x] **Step 3: Aufräumen im Test erweitern**
In `tests/setup.ts` die Truncate-Anweisung ersetzen:
```ts
beforeEach(async () => {
await db.execute(sql`truncate table post_tag, tag, block, post, issue, media, project, brand restart identity cascade`)
})
```
- [x] **Step 4: Prüfen, dass bestehende Tests weiter grün sind**
```bash
pnpm test
pnpm typecheck
```
Erwartet: alles grün.
---
### Task 4: Sichtbarkeitsregel in der Domain
**Files:**
- Create: `src/domain/types.ts`, `src/domain/audience.ts`, `tests/domain/audience.test.ts`
**Interfaces:**
- Consumes: nichts
- Produces: Typen `Audience = 'internal' | 'customer' | 'public'`, `ViewerScope = 'internal' | 'customer' | 'public'`, Funktionen `visibleAudiences(scope: ViewerScope): Audience[]` und `canSee(scope: ViewerScope, audience: Audience): boolean`
- [x] **Step 1: Failing Test mit vollständiger Matrix schreiben**
`tests/domain/audience.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { canSee, visibleAudiences } from '~/domain/audience'
import type { Audience, ViewerScope } from '~/domain/types'
const cases: [ViewerScope, Audience, boolean][] = [
['internal', 'internal', true],
['internal', 'customer', true],
['internal', 'public', true],
['customer', 'internal', false],
['customer', 'customer', true],
['customer', 'public', true],
['public', 'internal', false],
['public', 'customer', false],
['public', 'public', true],
]
describe('canSee', () => {
for (const [scope, audience, expected] of cases) {
it(`${scope} sieht ${audience}: ${expected}`, () => {
expect(canSee(scope, audience)).toBe(expected)
})
}
})
describe('visibleAudiences', () => {
it('gibt für intern alle Zielgruppen zurück', () => {
expect(visibleAudiences('internal').sort()).toEqual(['customer', 'internal', 'public'])
})
it('gibt für Kunden keine internen Beiträge zurück', () => {
expect(visibleAudiences('customer').sort()).toEqual(['customer', 'public'])
})
it('gibt öffentlich nur öffentliche Beiträge zurück', () => {
expect(visibleAudiences('public')).toEqual(['public'])
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/domain/audience.test.ts
```
Erwartet: FAIL, Modul `~/domain/audience` fehlt.
- [x] **Step 3: Domain-Typen und Logik schreiben**
`src/domain/types.ts`:
```ts
export type Audience = 'internal' | 'customer' | 'public'
export type ViewerScope = Audience
export type PostType = 'feature' | 'improvement' | 'fix' | 'breaking' | 'info'
export type PostStatus = 'draft' | 'review' | 'scheduled' | 'published' | 'archived'
```
`src/domain/audience.ts`:
```ts
import type { Audience, ViewerScope } from './types'
const openness: Record<Audience, number> = {
internal: 0,
customer: 1,
public: 2,
}
export function visibleAudiences(scope: ViewerScope): Audience[] {
return (Object.keys(openness) as Audience[]).filter(audience => openness[audience] >= openness[scope])
}
export function canSee(scope: ViewerScope, audience: Audience): boolean {
return openness[audience] >= openness[scope]
}
```
- [x] **Step 4: Test laufen lassen**
```bash
pnpm vitest run tests/domain/audience.test.ts
```
Erwartet: PASS, alle neun Matrixfälle grün.
---
### Task 5: Statusübergänge und Veröffentlichungsprüfungen
**Files:**
- Create: `src/domain/post-status.ts`, `src/domain/publish-checks.ts`, `tests/domain/post-status.test.ts`, `tests/domain/publish-checks.test.ts`
**Interfaces:**
- Consumes: `PostStatus`, `Audience` aus `src/domain/types.ts`
- Produces: `canTransition(from: PostStatus, to: PostStatus): boolean`, `PublishCandidate` und `checkPublish(candidate: PublishCandidate): PublishResult` mit `PublishResult = { blockers: string[]; hints: string[] }`
- [x] **Step 1: Failing Test für Statusübergänge schreiben**
`tests/domain/post-status.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { canTransition } from '~/domain/post-status'
describe('canTransition', () => {
it('erlaubt den Weg vom Entwurf bis zur Veröffentlichung', () => {
expect(canTransition('draft', 'review')).toBe(true)
expect(canTransition('review', 'published')).toBe(true)
expect(canTransition('draft', 'published')).toBe(true)
expect(canTransition('draft', 'scheduled')).toBe(true)
expect(canTransition('scheduled', 'published')).toBe(true)
})
it('erlaubt Zurückziehen und Wiederaufnahme', () => {
expect(canTransition('published', 'archived')).toBe(true)
expect(canTransition('archived', 'draft')).toBe(true)
})
it('verbietet Sprünge zurück aus der Veröffentlichung in den Entwurf', () => {
expect(canTransition('published', 'draft')).toBe(false)
expect(canTransition('published', 'review')).toBe(false)
})
it('verbietet gleichbleibenden Status als Übergang', () => {
expect(canTransition('draft', 'draft')).toBe(false)
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/domain/post-status.test.ts
```
Erwartet: FAIL, Modul fehlt.
- [x] **Step 3: Statusübergänge schreiben**
`src/domain/post-status.ts`:
```ts
import type { PostStatus } from './types'
const allowed: Record<PostStatus, PostStatus[]> = {
draft: ['review', 'scheduled', 'published', 'archived'],
review: ['draft', 'scheduled', 'published', 'archived'],
scheduled: ['draft', 'published', 'archived'],
published: ['archived'],
archived: ['draft'],
}
export function canTransition(from: PostStatus, to: PostStatus): boolean {
return allowed[from].includes(to)
}
```
- [x] **Step 4: Test laufen lassen**
```bash
pnpm vitest run tests/domain/post-status.test.ts
```
Erwartet: PASS.
- [x] **Step 5: Failing Test für Veröffentlichungsprüfungen schreiben**
Die Spec trennt Sperre und Hinweis. Gesperrt wird nur bei fehlender Zielgruppe, fehlendem Titel, leerem Beitrag und öffentlichem Beitrag ohne Alt-Text. Alles andere ist Hinweis.
`tests/domain/publish-checks.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { checkPublish } from '~/domain/publish-checks'
const base = {
title: 'Regel-Engine: Bedingung trifft Aktion',
audience: 'internal' as const,
blockCount: 3,
images: [{ hasAlt: true }],
tagCount: 1,
hasCover: true,
}
describe('checkPublish', () => {
it('lässt einen vollständigen Beitrag ohne Sperre und ohne Hinweis durch', () => {
expect(checkPublish(base)).toEqual({ blockers: [], hints: [] })
})
it('sperrt einen Beitrag ohne Titel', () => {
expect(checkPublish({ ...base, title: ' ' }).blockers).toContain('title_missing')
})
it('sperrt einen leeren Beitrag', () => {
expect(checkPublish({ ...base, blockCount: 0 }).blockers).toContain('content_empty')
})
it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', () => {
const result = checkPublish({ ...base, audience: 'public', images: [{ hasAlt: false }] })
expect(result.blockers).toContain('alt_text_missing')
})
it('meldet fehlenden Alt-Text bei internen Beiträgen nur als Hinweis', () => {
const result = checkPublish({ ...base, images: [{ hasAlt: false }] })
expect(result.blockers).toEqual([])
expect(result.hints).toContain('alt_text_missing')
})
it('meldet fehlende Schlagworte und fehlendes Aufmacherbild als Hinweis', () => {
const result = checkPublish({ ...base, tagCount: 0, hasCover: false })
expect(result.blockers).toEqual([])
expect(result.hints).toEqual(expect.arrayContaining(['tags_missing', 'cover_missing']))
})
})
```
- [x] **Step 6: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/domain/publish-checks.test.ts
```
Erwartet: FAIL, Modul fehlt.
- [x] **Step 7: Prüfungen schreiben**
`src/domain/publish-checks.ts`:
```ts
import type { Audience } from './types'
export type PublishCandidate = {
title: string
audience: Audience
blockCount: number
images: { hasAlt: boolean }[]
tagCount: number
hasCover: boolean
}
export type PublishResult = {
blockers: string[]
hints: string[]
}
export function checkPublish(candidate: PublishCandidate): PublishResult {
const blockers: string[] = []
const hints: string[] = []
const imagesWithoutAlt = candidate.images.filter(image => !image.hasAlt).length
if (candidate.title.trim().length === 0) {
blockers.push('title_missing')
}
if (candidate.blockCount === 0) {
blockers.push('content_empty')
}
if (imagesWithoutAlt > 0) {
if (candidate.audience === 'public') {
blockers.push('alt_text_missing')
} else {
hints.push('alt_text_missing')
}
}
if (candidate.tagCount === 0) {
hints.push('tags_missing')
}
if (!candidate.hasCover) {
hints.push('cover_missing')
}
return { blockers, hints }
}
```
- [x] **Step 8: Test laufen lassen**
```bash
pnpm vitest run tests/domain/publish-checks.test.ts
pnpm typecheck
```
Erwartet: PASS und keine Typfehler.
---
### Task 6: API-Clients und Token-Prüfung
**Files:**
- Create: `src/data/schema/clients.ts`, `src/data/repositories/clients.ts`, `src/lib/api-auth.ts`, `src/lib/problem.ts`, `tests/lib/api-auth.test.ts`
- Modify: `src/data/schema/index.ts`, `tests/setup.ts`
**Interfaces:**
- Consumes: `projects` aus Task 2, `posts` aus Task 3, `ViewerScope` aus Task 4
- Produces: Tabellen `apiClients`, `postReads`, `hashToken(token: string): string`, `resolveClient(request: Request): Promise<ApiClient | undefined>`, `problem(status: number, type: string, detail?: string): Response`, Typ `ApiClient`
- [x] **Step 1: Tabellen anlegen**
`src/data/schema/clients.ts`:
```ts
import { boolean, index, pgEnum, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { projects } from './brands'
import { posts } from './posts'
export const clientMode = pgEnum('client_mode', ['read', 'write'])
export const clientScope = pgEnum('client_scope', ['internal', 'customer', 'public'])
export const apiClients = pgTable('api_client', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
mode: clientMode('mode').notNull().default('read'),
scope: clientScope('scope').notNull().default('customer'),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
canPublish: boolean('can_publish').notNull().default(false),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const postReads = pgTable('post_read', {
postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
externalUserId: text('external_user_id').notNull(),
readAt: timestamp('read_at', { withTimezone: true }).notNull().defaultNow(),
}, table => [
primaryKey({ columns: [table.postId, table.externalUserId] }),
index('post_read_project_user').on(table.projectId, table.externalUserId),
])
export type ApiClient = typeof apiClients.$inferSelect
```
In `src/data/schema/index.ts` ergänzen:
```ts
export * from './clients'
```
- [x] **Step 2: Migration erzeugen und anwenden, Aufräumen erweitern**
```bash
pnpm db:generate
pnpm db:migrate
DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate
```
In `tests/setup.ts` die Truncate-Anweisung ersetzen:
```ts
beforeEach(async () => {
await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand restart identity cascade`)
})
```
- [x] **Step 3: Failing Test schreiben**
`tests/lib/api-auth.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { apiClients } from '~/data/schema'
import { hashToken, resolveClient } from '~/lib/api-auth'
async function seedClient(token: string, revoked = false) {
const [client] = await db.insert(apiClients).values({
name: 'Trakk Leseclient',
tokenHash: hashToken(token),
mode: 'read',
scope: 'internal',
revokedAt: revoked ? new Date() : null,
}).returning()
return client!
}
describe('resolveClient', () => {
it('findet den Client zu einem gültigen Bearer-Token', async () => {
const client = await seedClient('lb_live_abc')
const request = new Request('http://localhost/api/v1/posts', {
headers: { authorization: 'Bearer lb_live_abc' },
})
await expect(resolveClient(request)).resolves.toMatchObject({ id: client.id })
})
it('liefert nichts ohne Kopfzeile', async () => {
await seedClient('lb_live_abc')
const request = new Request('http://localhost/api/v1/posts')
await expect(resolveClient(request)).resolves.toBeUndefined()
})
it('liefert nichts für ein widerrufenes Token', async () => {
await seedClient('lb_live_abc', true)
const request = new Request('http://localhost/api/v1/posts', {
headers: { authorization: 'Bearer lb_live_abc' },
})
await expect(resolveClient(request)).resolves.toBeUndefined()
})
it('speichert das Token niemals im Klartext', async () => {
await seedClient('lb_live_abc')
const rows = await db.select().from(apiClients)
expect(rows[0]!.tokenHash).not.toContain('lb_live_abc')
})
})
```
- [x] **Step 4: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/lib/api-auth.test.ts
```
Erwartet: FAIL, Modul `~/lib/api-auth` fehlt.
- [x] **Step 5: Token-Auflösung und Problem-JSON schreiben**
`src/lib/api-auth.ts`:
```ts
import { createHash } from 'node:crypto'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '~/data/db'
import { apiClients, type ApiClient } from '~/data/schema'
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export async function resolveClient(request: Request): Promise<ApiClient | undefined> {
const header = request.headers.get('authorization')
if (!header?.startsWith('Bearer ')) {
return undefined
}
const token = header.slice('Bearer '.length).trim()
if (token.length === 0) {
return undefined
}
const rows = await db
.select()
.from(apiClients)
.where(and(eq(apiClients.tokenHash, hashToken(token)), isNull(apiClients.revokedAt)))
.limit(1)
return rows[0]
}
```
`src/lib/problem.ts`:
```ts
export function problem(status: number, type: string, detail?: string): Response {
return Response.json(
{ type, title: type.replace(/_/g, ' '), status, detail },
{ status, headers: { 'content-type': 'application/problem+json' } },
)
}
```
- [x] **Step 6: Test laufen lassen**
```bash
pnpm vitest run tests/lib/api-auth.test.ts
```
Erwartet: PASS, alle vier Fälle grün.
---
### Task 7: Beitrags-Repository mit Filtern
**Files:**
- Create: `src/data/repositories/posts.ts`, `tests/data/posts.test.ts`
**Interfaces:**
- Consumes: `posts`, `projects`, `tags`, `postTags` aus Tasks 2 und 3, `visibleAudiences` aus Task 4
- Produces: `listPosts(query: PostQuery): Promise<PostListResult>` mit `PostQuery = { scope: ViewerScope; projectSlug?: string; projectId?: string; since?: Date; type?: PostType; tag?: string; page: number; perPage: number }` und `PostListResult = { items: PostListItem[]; total: number }`, `findPost(slug: string, scope: ViewerScope): Promise<PostDetail | undefined>`
- [x] **Step 1: Failing Test schreiben**
`tests/data/posts.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { brands, posts, projects } from '~/data/schema'
import { findPost, listPosts } from '~/data/repositories/posts'
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning()
const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning()
await db.insert(posts).values([
{
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
type: 'feature', audience: 'customer', status: 'published',
publishAt: new Date('2026-07-30T08:00:00Z'),
},
{
projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau',
type: 'improvement', audience: 'internal', status: 'published',
publishAt: new Date('2026-07-29T08:00:00Z'),
},
{
projectId: trakk!.id, number: 3, slug: 'noch-entwurf', title: 'Noch Entwurf',
type: 'fix', audience: 'public', status: 'draft',
},
{
projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü',
type: 'improvement', audience: 'public', status: 'published',
publishAt: new Date('2026-07-28T08:00:00Z'),
},
])
return { trakk: trakk!, mta: mta! }
}
describe('listPosts', () => {
it('liefert nur veröffentlichte Beiträge, neueste zuerst', async () => {
await seed()
const result = await listPosts({ scope: 'internal', page: 1, perPage: 25 })
expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'interner-umbau', 'spaltenmenue'])
expect(result.total).toBe(3)
})
it('verbirgt interne Beiträge vor Kunden', async () => {
await seed()
const result = await listPosts({ scope: 'customer', page: 1, perPage: 25 })
expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'spaltenmenue'])
})
it('filtert nach Projekt', async () => {
await seed()
const result = await listPosts({ scope: 'internal', projectSlug: 'mta360', page: 1, perPage: 25 })
expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue'])
})
it('filtert nach Zeitpunkt und Typ', async () => {
await seed()
const since = await listPosts({ scope: 'internal', since: new Date('2026-07-29T00:00:00Z'), page: 1, perPage: 25 })
const byType = await listPosts({ scope: 'internal', type: 'feature', page: 1, perPage: 25 })
expect(since.items).toHaveLength(2)
expect(byType.items.map(item => item.slug)).toEqual(['regel-engine'])
})
it('teilt in Seiten und meldet die Gesamtzahl', async () => {
await seed()
const result = await listPosts({ scope: 'internal', page: 2, perPage: 2 })
expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue'])
expect(result.total).toBe(3)
})
})
describe('findPost', () => {
it('liefert einen Beitrag mit Projektangabe', async () => {
await seed()
const post = await findPost('regel-engine', 'customer')
expect(post?.projectSlug).toBe('trakk')
expect(post?.title).toBe('Regel-Engine')
})
it('liefert nichts, wenn die Zielgruppe es nicht erlaubt', async () => {
await seed()
await expect(findPost('interner-umbau', 'customer')).resolves.toBeUndefined()
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/data/posts.test.ts
```
Erwartet: FAIL, Modul `~/data/repositories/posts` fehlt.
- [x] **Step 3: Repository schreiben**
`src/data/repositories/posts.ts`:
```ts
import { and, count, desc, eq, gte, inArray, type SQL } from 'drizzle-orm'
import { db } from '../db'
import { posts, projects, postTags, tags } from '../schema'
import { visibleAudiences } from '~/domain/audience'
import type { PostType, ViewerScope } from '~/domain/types'
export type PostQuery = {
scope: ViewerScope
projectSlug?: string
projectId?: string
since?: Date
type?: PostType
tag?: string
page: number
perPage: number
}
export type PostListItem = {
id: string
slug: string
title: string
teaser: string | null
type: string
audience: string
publishAt: Date | null
number: number
projectSlug: string
projectName: string
projectColor: string
}
export type PostListResult = {
items: PostListItem[]
total: number
}
export type PostDetail = PostListItem
const selection = {
id: posts.id,
slug: posts.slug,
title: posts.title,
teaser: posts.teaser,
type: posts.type,
audience: posts.audience,
publishAt: posts.publishAt,
number: posts.number,
projectSlug: projects.slug,
projectName: projects.name,
projectColor: projects.color,
}
function conditions(query: PostQuery): SQL[] {
const parts: SQL[] = [
eq(posts.status, 'published'),
inArray(posts.audience, visibleAudiences(query.scope)),
]
if (query.projectSlug) {
parts.push(eq(projects.slug, query.projectSlug))
}
if (query.projectId) {
parts.push(eq(posts.projectId, query.projectId))
}
if (query.since) {
parts.push(gte(posts.publishAt, query.since))
}
if (query.type) {
parts.push(eq(posts.type, query.type))
}
return parts
}
export async function listPosts(query: PostQuery): Promise<PostListResult> {
const parts = conditions(query)
const offset = (query.page - 1) * query.perPage
if (query.tag) {
const rows = await db
.select(selection)
.from(posts)
.innerJoin(projects, eq(projects.id, posts.projectId))
.innerJoin(postTags, eq(postTags.postId, posts.id))
.innerJoin(tags, and(eq(tags.id, postTags.tagId), eq(tags.slug, query.tag)))
.where(and(...parts))
.orderBy(desc(posts.publishAt))
.limit(query.perPage)
.offset(offset)
return { items: rows, total: rows.length }
}
const items = await db
.select(selection)
.from(posts)
.innerJoin(projects, eq(projects.id, posts.projectId))
.where(and(...parts))
.orderBy(desc(posts.publishAt))
.limit(query.perPage)
.offset(offset)
const [totals] = await db
.select({ value: count() })
.from(posts)
.innerJoin(projects, eq(projects.id, posts.projectId))
.where(and(...parts))
return { items, total: Number(totals?.value ?? 0) }
}
export async function findPost(slug: string, scope: ViewerScope): Promise<PostDetail | undefined> {
const rows = await db
.select(selection)
.from(posts)
.innerJoin(projects, eq(projects.id, posts.projectId))
.where(and(
eq(posts.slug, slug),
eq(posts.status, 'published'),
inArray(posts.audience, visibleAudiences(scope)),
))
.limit(1)
return rows[0]
}
```
- [x] **Step 4: Test laufen lassen**
```bash
pnpm vitest run tests/data/posts.test.ts
pnpm typecheck
```
Erwartet: PASS. Falls der Seiten-Test wegen der Gesamtzahl beim Tag-Filter scheitert, den Tag-Zweig so ändern, dass er dieselbe Zählabfrage mit den Tag-Joins ausführt, statt `rows.length` zu melden.
---
### Task 8: Lese-Endpunkte
**Files:**
- Create: `src/app/api/v1/projects/route.ts`, `src/app/api/v1/posts/route.ts`, `src/app/api/v1/posts/[slug]/route.ts`, `tests/api/posts.test.ts`
**Interfaces:**
- Consumes: `resolveClient` und `problem` aus Task 6, `listPosts` und `findPost` aus Task 7, `listProjects` aus Task 2
- Produces: HTTP-Endpunkte `GET /api/v1/projects`, `GET /api/v1/posts`, `GET /api/v1/posts/:slug`
- [x] **Step 1: Failing Test schreiben**
Die Route-Handler werden direkt als Funktionen aufgerufen, ohne laufenden Server.
`tests/api/posts.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { apiClients, brands, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { GET as getPosts } from '~/app/api/v1/posts/route'
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning()
await db.insert(posts).values([
{
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'),
},
{
projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau',
audience: 'internal', status: 'published', publishAt: new Date('2026-07-29T08:00:00Z'),
},
])
await db.insert(apiClients).values([
{ name: 'Kundenclient', tokenHash: hashToken('lb_customer'), mode: 'read', scope: 'customer', projectId: trakk!.id },
{ name: 'Interner Client', tokenHash: hashToken('lb_internal'), mode: 'read', scope: 'internal', projectId: trakk!.id },
])
}
function request(url: string, token?: string) {
return new Request(url, { headers: token ? { authorization: `Bearer ${token}` } : {} })
}
describe('GET /api/v1/posts', () => {
it('antwortet 401 ohne Token', async () => {
await seed()
const response = await getPosts(request('http://localhost/api/v1/posts'))
expect(response.status).toBe(401)
})
it('gibt einem Kundenclient keine internen Beiträge, auch nicht mit Parameter', async () => {
await seed()
const response = await getPosts(request('http://localhost/api/v1/posts?audience=internal', 'lb_customer'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['regel-engine'])
})
it('gibt einem internen Client alle Beiträge', async () => {
await seed()
const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_internal'))
const body = await response.json()
expect(body.items).toHaveLength(2)
expect(body.meta.total).toBe(2)
})
it('lehnt ungültige Parameter mit 400 ab', async () => {
await seed()
const response = await getPosts(request('http://localhost/api/v1/posts?per_page=999', 'lb_internal'))
expect(response.status).toBe(400)
})
it('begrenzt einen projektgebundenen Client auf sein Projekt', async () => {
await seed()
const response = await getPosts(request('http://localhost/api/v1/posts?project=mta360', 'lb_customer'))
const body = await response.json()
expect(body.items).toHaveLength(0)
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/api/posts.test.ts
```
Erwartet: FAIL, Route fehlt.
- [x] **Step 3: Endpunkte schreiben**
`src/app/api/v1/posts/route.ts`:
```ts
import { z } from 'zod'
import { listPosts } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const querySchema = z.object({
project: z.string().min(1).optional(),
since: z.coerce.date().optional(),
type: z.enum(['feature', 'improvement', 'fix', 'breaking', 'info']).optional(),
tag: z.string().min(1).optional(),
page: z.coerce.number().int().min(1).default(1),
per_page: z.coerce.number().int().min(1).max(100).default(25),
})
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const url = new URL(request.url)
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return problem(400, 'invalid_query', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const query = parsed.data
const result = await listPosts({
scope: client.scope,
projectSlug: query.project,
projectId: client.projectId ?? undefined,
since: query.since,
type: query.type,
tag: query.tag,
page: query.page,
perPage: query.per_page,
})
return Response.json({
items: result.items,
meta: { total: result.total, page: query.page, per_page: query.per_page },
})
}
```
`src/app/api/v1/posts/[slug]/route.ts`:
```ts
import { findPost } from '~/data/repositories/posts'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const { slug } = await context.params
const post = await findPost(slug, client.scope)
if (!post) {
return problem(404, 'post_not_found')
}
return Response.json(post)
}
```
`src/app/api/v1/projects/route.ts`:
```ts
import { listProjects } from '~/data/repositories/projects'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
const all = await listProjects()
const items = client.projectId ? all.filter(project => project.id === client.projectId) : all
return Response.json({ items })
}
```
Der Parameter `audience` wird bewusst nicht gelesen. Die Zielgruppe kommt ausschließlich aus dem Client.
- [x] **Step 4: Test laufen lassen**
```bash
pnpm vitest run tests/api/posts.test.ts
pnpm typecheck
```
Erwartet: PASS, insbesondere der Fall mit `?audience=internal`, der weiterhin nur den Kundenbeitrag liefert.
---
### Task 9: Gelesen-Status
**Files:**
- Create: `src/data/repositories/reads.ts`, `src/app/api/v1/unread/route.ts`, `src/app/api/v1/read/route.ts`, `tests/api/reads.test.ts`
**Interfaces:**
- Consumes: `postReads` aus Task 6, `listPosts` aus Task 7, `resolveClient` und `problem` aus Task 6
- Produces: `countUnread(args: { projectId: string; externalUserId: string; scope: ViewerScope }): Promise<number>`, `listUnread(args: same): Promise<PostListItem[]>`, `markRead(args: { projectId: string; externalUserId: string; postIds: string[] }): Promise<number>`, Endpunkte `GET /api/v1/unread` und `POST /api/v1/read`
- [x] **Step 1: Failing Test schreiben**
`tests/api/reads.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { apiClients, brands, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
import { GET as getUnread } from '~/app/api/v1/unread/route'
import { POST as postRead } from '~/app/api/v1/read/route'
async function seed() {
const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning()
const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning()
const inserted = await db.insert(posts).values([
{
projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'),
},
{
projectId: trakk!.id, number: 2, slug: 'sla-uhr', title: 'SLA-Uhr',
audience: 'customer', status: 'published', publishAt: new Date('2026-07-25T08:00:00Z'),
},
]).returning()
await db.insert(apiClients).values({
name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id,
})
return { postIds: inserted.map(post => post.id) }
}
function get(url: string) {
return new Request(url, { headers: { authorization: 'Bearer lb_trakk' } })
}
function post(body: unknown) {
return new Request('http://localhost/api/v1/read', {
method: 'POST',
headers: { authorization: 'Bearer lb_trakk', 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('gelesen-Status', () => {
it('zählt zuerst alle Beiträge als ungelesen', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(body.count).toBe(2)
expect(body.items).toHaveLength(2)
})
it('meldet Beiträge als gelesen und zählt danach weniger', async () => {
const { postIds } = await seed()
const marked = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42'))
const body = await response.json()
expect(marked.status).toBe(200)
expect(body.count).toBe(1)
expect(body.items[0].slug).toBe('sla-uhr')
})
it('bleibt bei doppelter Meldung stabil', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
const second = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] }))
expect(second.status).toBe(200)
})
it('trennt Nutzer voneinander', async () => {
const { postIds } = await seed()
await postRead(post({ external_user_id: 'u-42', post_ids: postIds }))
const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-99'))
const body = await response.json()
expect(body.count).toBe(2)
})
it('verlangt eine Nutzerkennung', async () => {
await seed()
const response = await getUnread(get('http://localhost/api/v1/unread'))
expect(response.status).toBe(400)
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/api/reads.test.ts
```
Erwartet: FAIL, Module fehlen.
- [x] **Step 3: Repository schreiben**
`src/data/repositories/reads.ts`:
```ts
import { and, desc, eq, inArray, notExists, sql } from 'drizzle-orm'
import { db } from '../db'
import { postReads, posts, projects } from '../schema'
import { visibleAudiences } from '~/domain/audience'
import type { ViewerScope } from '~/domain/types'
import type { PostListItem } from './posts'
type UnreadArgs = {
projectId: string
externalUserId: string
scope: ViewerScope
}
export async function listUnread(args: UnreadArgs): Promise<PostListItem[]> {
return db
.select({
id: posts.id,
slug: posts.slug,
title: posts.title,
teaser: posts.teaser,
type: posts.type,
audience: posts.audience,
publishAt: posts.publishAt,
number: posts.number,
projectSlug: projects.slug,
projectName: projects.name,
projectColor: projects.color,
})
.from(posts)
.innerJoin(projects, eq(projects.id, posts.projectId))
.where(and(
eq(posts.projectId, args.projectId),
eq(posts.status, 'published'),
inArray(posts.audience, visibleAudiences(args.scope)),
notExists(
db.select({ one: sql`1` }).from(postReads).where(and(
eq(postReads.postId, posts.id),
eq(postReads.externalUserId, args.externalUserId),
)),
),
))
.orderBy(desc(posts.publishAt))
}
export async function markRead(args: { projectId: string; externalUserId: string; postIds: string[] }): Promise<number> {
if (args.postIds.length === 0) {
return 0
}
const rows = args.postIds.map(postId => ({
postId,
projectId: args.projectId,
externalUserId: args.externalUserId,
}))
await db.insert(postReads).values(rows).onConflictDoNothing()
return rows.length
}
```
- [x] **Step 4: Endpunkte schreiben**
`src/app/api/v1/unread/route.ts`:
```ts
import { z } from 'zod'
import { listUnread } from '~/data/repositories/reads'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const querySchema = z.object({ external_user_id: z.string().min(1) })
export async function GET(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (!client.projectId) {
return problem(422, 'client_without_project')
}
const url = new URL(request.url)
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return problem(400, 'invalid_query', 'external_user_id')
}
const items = await listUnread({
projectId: client.projectId,
externalUserId: parsed.data.external_user_id,
scope: client.scope,
})
return Response.json({ count: items.length, items })
}
```
`src/app/api/v1/read/route.ts`:
```ts
import { z } from 'zod'
import { markRead } from '~/data/repositories/reads'
import { resolveClient } from '~/lib/api-auth'
import { problem } from '~/lib/problem'
const bodySchema = z.object({
external_user_id: z.string().min(1),
post_ids: z.array(z.uuid()).min(1).max(200),
})
```
zod 4 hat die Prüfungen für Formate auf oberste Ebene gezogen, `z.uuid()` statt `z.string().uuid()`. Vor dem Schreiben unter https://zod.dev bestätigen, weil beide Formen in Umlauf sind.
```ts
export async function POST(request: Request) {
const client = await resolveClient(request)
if (!client) {
return problem(401, 'unauthorized')
}
if (!client.projectId) {
return problem(422, 'client_without_project')
}
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', '))
}
const marked = await markRead({
projectId: client.projectId,
externalUserId: parsed.data.external_user_id,
postIds: parsed.data.post_ids,
})
return Response.json({ marked })
}
```
- [x] **Step 5: Test laufen lassen**
```bash
pnpm vitest run tests/api/reads.test.ts
pnpm typecheck
```
Erwartet: PASS.
---
### Task 10: Seed-Daten
**Files:**
- Create: `scripts/seed.ts`, `tests/scripts/seed.test.ts`
**Interfaces:**
- Consumes: alle Tabellen aus Tasks 2, 3 und 6
- Produces: `seed(): Promise<void>`, ausführbar über `pnpm db:seed`
- [x] **Step 1: Failing Test schreiben**
`tests/scripts/seed.test.ts`:
```ts
import { describe, expect, it } from 'vitest'
import { db } from '~/data/db'
import { posts, projects } from '~/data/schema'
import { seed } from '../../scripts/seed'
describe('seed', () => {
it('legt Marken, Projekte und Beiträge an', async () => {
await seed()
const projectRows = await db.select().from(projects)
const postRows = await db.select().from(posts)
expect(projectRows.map(project => project.slug)).toEqual(
expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']),
)
expect(postRows.length).toBeGreaterThanOrEqual(6)
expect(postRows.every(post => post.number > 0)).toBe(true)
})
it('ist zweimal hintereinander ausführbar', async () => {
await seed()
await expect(seed()).resolves.toBeUndefined()
})
})
```
- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen**
```bash
pnpm vitest run tests/scripts/seed.test.ts
```
Erwartet: FAIL, `scripts/seed.ts` fehlt.
- [x] **Step 3: Seed schreiben**
Inhalte übernommen aus `docs/design/mockup-overview.html`, damit Archiv und Admin später mit echtem Text arbeiten.
`scripts/seed.ts`:
```ts
import 'dotenv/config'
import { db } from '~/data/db'
import { apiClients, blocks, brands, posts, projects } from '~/data/schema'
import { hashToken } from '~/lib/api-auth'
const brandRows = [
{ slug: 'nyo', name: 'NYO', color: '#2b4a9b', sort: 1 },
{ slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 },
]
const projectRows = [
{ brand: 'pocket-rocket', slug: 'trakk', name: 'Trakk', color: '#2e7d5b', sort: 1 },
{ brand: 'nyo', slug: 'mta360', name: 'MTA360', color: '#2b4a9b', sort: 2 },
{ brand: 'nyo', slug: 'mta-telematik', name: 'MTA Telematik', color: '#b4761a', sort: 3 },
{ brand: 'pocket-rocket', slug: 'orbit', name: 'Orbit', color: '#6b4ba8', sort: 4 },
{ brand: 'nyo', slug: 'pulsar', name: 'Pulsar', color: '#a33b6a', sort: 5 },
]
const postRows = [
{
project: 'trakk', number: 142, slug: 'regel-engine-bedingung-trifft-aktion',
title: 'Regel-Engine: Bedingung trifft Aktion', type: 'feature' as const, audience: 'customer' as const,
teaser: 'Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken.',
publishAt: new Date('2026-07-30T07:14:00Z'), author: 'Paul',
},
{
project: 'mta360', number: 141, slug: 'spalten-per-rechtsklick-verwalten',
title: 'Spalten per Rechtsklick verwalten', type: 'improvement' as const, audience: 'customer' as const,
teaser: 'Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen.',
publishAt: new Date('2026-07-29T09:00:00Z'), author: 'Matthias',
},
{
project: 'mta-telematik', number: 140, slug: 'zehntausend-tracker-auf-einer-karte',
title: '10.000 Tracker auf einer Karte', type: 'feature' as const, audience: 'customer' as const,
teaser: 'Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft.',
publishAt: new Date('2026-07-28T10:30:00Z'), author: 'Paul',
},
{
project: 'trakk', number: 139, slug: 'sla-uhr-zaehlt-feiertage-nicht-mehr-mit',
title: 'SLA-Uhr zählt Feiertage nicht mehr mit', type: 'fix' as const, audience: 'customer' as const,
teaser: 'Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus.',
publishAt: new Date('2026-07-25T08:00:00Z'), author: 'Matthias',
},
{
project: 'mta360', number: 138, slug: 'lokale-tabellen-konfigurationen-entfallen',
title: 'Lokale Tabellen-Konfigurationen werden nicht mehr gelesen', type: 'breaking' as const, audience: 'customer' as const,
teaser: 'Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, legt seine Ansicht einmal neu an.',
publishAt: new Date('2026-07-23T12:00:00Z'), author: 'Matthias',
},
{
project: 'orbit', number: 137, slug: 'ein-eingang-fuer-alle-kommentare',
title: 'Ein Eingang für alle Kommentare', type: 'info' as const, audience: 'internal' as const,
teaser: 'Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.',
publishAt: new Date('2026-07-21T15:45:00Z'), author: 'Paul',
},
]
export async function seed(): Promise<void> {
const brandIds = new Map<string, string>()
const projectIds = new Map<string, string>()
for (const row of brandRows) {
const [brand] = await db.insert(brands).values(row)
.onConflictDoUpdate({ target: brands.slug, set: { name: row.name, color: row.color, sort: row.sort } })
.returning()
brandIds.set(row.slug, brand!.id)
}
for (const row of projectRows) {
const [project] = await db.insert(projects)
.values({ brandId: brandIds.get(row.brand)!, slug: row.slug, name: row.name, color: row.color, sort: row.sort })
.onConflictDoUpdate({ target: projects.slug, set: { name: row.name, color: row.color, sort: row.sort } })
.returning()
projectIds.set(row.slug, project!.id)
}
for (const row of postRows) {
const [post] = await db.insert(posts)
.values({
projectId: projectIds.get(row.project)!,
number: row.number,
slug: row.slug,
title: row.title,
teaser: row.teaser,
type: row.type,
audience: row.audience,
status: 'published',
publishAt: row.publishAt,
authorName: row.author,
})
.onConflictDoUpdate({ target: [posts.projectId, posts.slug], set: { title: row.title, teaser: row.teaser } })
.returning()
await db.delete(blocks).where(eq(blocks.postId, post!.id))
await db.insert(blocks).values({ postId: post!.id, sort: 0, type: 'text', data: { text: row.teaser } })
}
await db.insert(apiClients).values({
name: 'Trakk Leseclient',
tokenHash: hashToken('lb_seed_trakk_read'),
mode: 'read',
scope: 'customer',
projectId: projectIds.get('trakk')!,
}).onConflictDoNothing()
}
function eqPost(postId: string) {
return eq(blocks.postId, postId)
}
```
Am Dateianfang zusätzlich `import { eq } from 'drizzle-orm'` setzen.
Ausführbar machen, am Dateiende:
```ts
if (process.argv[1]?.endsWith('seed.ts')) {
await seed()
await pool.end()
}
```
Dafür `pool` mit importieren: `import { db, pool } from '~/data/db'`.
- [x] **Step 4: Test laufen lassen und Seed ausführen**
```bash
pnpm vitest run tests/scripts/seed.test.ts
pnpm db:seed
```
Erwartet: PASS, und in der Entwicklungsdatenbank stehen fünf Projekte und sechs Beiträge.
- [x] **Step 5: Endpunkt von Hand prüfen**
```bash
pnpm dev
curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/posts" | head -40
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:4700/api/v1/posts"
```
Erwartet: erste Anfrage liefert die Trakk-Beiträge als JSON, zweite Anfrage antwortet 401. Der interne Orbit-Beitrag darf nicht enthalten sein.
- [x] **Step 6: Gesamtlauf**
```bash
pnpm test
pnpm typecheck
pnpm build
```
Erwartet: alles grün.
---
## Selbstprüfung gegen die Spec
| Spec-Abschnitt | Aufgabe in diesem Plan |
|---|---|
| Datenmodell brand, project | Task 2 |
| Datenmodell post, block, issue, tag, post_tag | Task 3 |
| Datenmodell media | Task 3, Upload erst Plan 5 |
| Datenmodell api_client, post_read | Task 6 |
| Datenmodell user, user_project_role | Plan 2, gehört zu Auth und Admin |
| Datenmodell reaction, delivery, audit_log | Plan 2 und Plan 4 |
| Sichtbarkeit nach Zielgruppe, serverseitig | Task 4, angewendet in Task 7 und 8 |
| Statusübergänge, Prüfungen mit Hinweis und Sperre | Task 5 |
| Lese-API inklusive unread und read | Task 8 und 9 |
| Schreib-API und Medien | Plan 5 |
| Feeds | Plan 3 |
| Admin, Editor, Import-Vorschläge | Plan 2 und Plan 5 |
| Web-Archiv, Routen, Suche | Plan 3 |
| Client-Pakete React und Vue | Plan 4 |
| Tokens, Hell und Dunkel | Task 1 legt die Tokens, Umschalter in Plan 3 |
| Tests auf Sichtbarkeit, Status, Rechte, API | Tasks 4, 5, 6, 8, 9 |
| Playwright-Smoketest | Plan 3, sobald es eine Oberfläche gibt |
## Offene Punkte, die vor Plan 2 geklärt werden müssen
- Subdomain für das Deployment
- Ob der Admin-Login über better-auth mit E-Mail und Passwort startet oder direkt auf Portal-SSO wartet