Files
logbuch/docs/superpowers/plans/2026-07-30-logbuch-fundament.md
T
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

64 KiB

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

  • Step 1: Arbeitsverzeichnis festlegen

cd /Volumes/M2mini/WORK/NYO/projects/logbuch
ls docs

Erwartet: design, superpowers. Kein git init, siehe Global Constraints.

  • Step 2: Abhängigkeiten installieren
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
  • Step 3: package.json Skripte setzen
{
	"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"
	}
}
  • Step 4: tsconfig.json anlegen
{
	"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"]
}
  • Step 5: Postgres über Docker bereitstellen

docker-compose.yml, YAML mit 2 Leerzeichen:

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:

cp .env.example .env
docker compose up -d
docker compose exec db psql -U logbuch -d logbuch -c "CREATE DATABASE logbuch_test"
  • Step 6: Drizzle konfigurieren

drizzle.config.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! },
})
  • Step 7: Tailwind und Tokens anlegen

postcss.config.mjs:

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:

@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.

  • Step 8: Minimale App-Hülle anlegen

src/app/layout.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:

export default function Page() {
	return <main>Logbuch</main>
}
  • Step 9: Vitest konfigurieren

vitest.config.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:

import 'dotenv/config'

tests/smoke.test.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')
	})
})
  • Step 10: Tests und Build prüfen
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

  • Step 1: Datenbankverbindung anlegen

src/data/db.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
  • Step 2: Tabellen anlegen

src/data/schema/brands.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:

export * from './brands'
  • Step 3: Migration erzeugen und anwenden
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.

  • Step 4: Testaufräumen einrichten

tests/setup.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`)
})
  • Step 5: Failing Test schreiben

tests/data/projects.test.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'])
	})
})
  • Step 6: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/data/projects.test.ts

Erwartet: FAIL, listProjects existiert nicht.

  • Step 7: Repository schreiben

src/data/repositories/projects.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]
}
  • Step 8: Test laufen lassen
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

  • Step 1: Enums und Tabellen anlegen

src/data/schema/media.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:

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:

export * from './brands'
export * from './media'
export * from './posts'
  • Step 2: Migration erzeugen und anwenden
pnpm db:generate
pnpm db:migrate
DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate
  • Step 3: Aufräumen im Test erweitern

In tests/setup.ts die Truncate-Anweisung ersetzen:

beforeEach(async () => {
	await db.execute(sql`truncate table post_tag, tag, block, post, issue, media, project, brand restart identity cascade`)
})
  • Step 4: Prüfen, dass bestehende Tests weiter grün sind
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

  • Step 1: Failing Test mit vollständiger Matrix schreiben

tests/domain/audience.test.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'])
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/domain/audience.test.ts

Erwartet: FAIL, Modul ~/domain/audience fehlt.

  • Step 3: Domain-Typen und Logik schreiben

src/domain/types.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:

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]
}
  • Step 4: Test laufen lassen
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[] }

  • Step 1: Failing Test für Statusübergänge schreiben

tests/domain/post-status.test.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)
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/domain/post-status.test.ts

Erwartet: FAIL, Modul fehlt.

  • Step 3: Statusübergänge schreiben

src/domain/post-status.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)
}
  • Step 4: Test laufen lassen
pnpm vitest run tests/domain/post-status.test.ts

Erwartet: PASS.

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

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']))
	})
})
  • Step 6: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/domain/publish-checks.test.ts

Erwartet: FAIL, Modul fehlt.

  • Step 7: Prüfungen schreiben

src/domain/publish-checks.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 }
}
  • Step 8: Test laufen lassen
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

  • Step 1: Tabellen anlegen

src/data/schema/clients.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:

export * from './clients'
  • Step 2: Migration erzeugen und anwenden, Aufräumen erweitern
pnpm db:generate
pnpm db:migrate
DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate

In tests/setup.ts die Truncate-Anweisung ersetzen:

beforeEach(async () => {
	await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand restart identity cascade`)
})
  • Step 3: Failing Test schreiben

tests/lib/api-auth.test.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')
	})
})
  • Step 4: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/lib/api-auth.test.ts

Erwartet: FAIL, Modul ~/lib/api-auth fehlt.

  • Step 5: Token-Auflösung und Problem-JSON schreiben

src/lib/api-auth.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:

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' } },
	)
}
  • Step 6: Test laufen lassen
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>

  • Step 1: Failing Test schreiben

tests/data/posts.test.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()
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/data/posts.test.ts

Erwartet: FAIL, Modul ~/data/repositories/posts fehlt.

  • Step 3: Repository schreiben

src/data/repositories/posts.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]
}
  • Step 4: Test laufen lassen
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

  • Step 1: Failing Test schreiben

Die Route-Handler werden direkt als Funktionen aufgerufen, ohne laufenden Server.

tests/api/posts.test.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)
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/api/posts.test.ts

Erwartet: FAIL, Route fehlt.

  • Step 3: Endpunkte schreiben

src/app/api/v1/posts/route.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:

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:

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.

  • Step 4: Test laufen lassen
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

  • Step 1: Failing Test schreiben

tests/api/reads.test.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)
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/api/reads.test.ts

Erwartet: FAIL, Module fehlen.

  • Step 3: Repository schreiben

src/data/repositories/reads.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
}
  • Step 4: Endpunkte schreiben

src/app/api/v1/unread/route.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:

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.


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 })
}
  • Step 5: Test laufen lassen
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

  • Step 1: Failing Test schreiben

tests/scripts/seed.test.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()
	})
})
  • Step 2: Test laufen lassen und Fehlschlag prüfen
pnpm vitest run tests/scripts/seed.test.ts

Erwartet: FAIL, scripts/seed.ts fehlt.

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

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:

if (process.argv[1]?.endsWith('seed.ts')) {
	await seed()
	await pool.end()
}

Dafür pool mit importieren: import { db, pool } from '~/data/db'.

  • Step 4: Test laufen lassen und Seed ausführen
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.

  • Step 5: Endpunkt von Hand prüfen
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.

  • Step 6: Gesamtlauf
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