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.
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, niemalsnpmoderyarn. - 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
remoder 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, keingit 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, React19.2.8, Drizzle ORM0.45.2, drizzle-kit0.31.10, zod4.4.3, Tailwind4.3.3, Vitest4.1.10, pg8.22.0, Postgres-Image18. Vor dem Installieren erneutnpm view <paket> versionprü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 teststartet Vitest,DATABASE_URLundDATABASE_URL_TESTals 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.jsonSkripte 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.jsonanlegen
{
"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_TESTaus Task 1 -
Produces:
db, Tabellenbrands,projects, FunktionenlistBrands(): Promise<Brand[]>undlistProjects(): Promise<Project[]>, TypenBrandundProject -
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,projectsaus Task 2 -
Produces: Tabellen
posts,blocks,issues,tags,postTags,media, EnumspostType,postAudience,postStatus, TypenPost,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', FunktionenvisibleAudiences(scope: ViewerScope): Audience[]undcanSee(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,Audienceaussrc/domain/types.ts -
Produces:
canTransition(from: PostStatus, to: PostStatus): boolean,PublishCandidateundcheckPublish(candidate: PublishCandidate): PublishResultmitPublishResult = { 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:
projectsaus Task 2,postsaus Task 3,ViewerScopeaus Task 4 -
Produces: Tabellen
apiClients,postReads,hashToken(token: string): string,resolveClient(request: Request): Promise<ApiClient | undefined>,problem(status: number, type: string, detail?: string): Response, TypApiClient -
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,postTagsaus Tasks 2 und 3,visibleAudiencesaus Task 4 -
Produces:
listPosts(query: PostQuery): Promise<PostListResult>mitPostQuery = { scope: ViewerScope; projectSlug?: string; projectId?: string; since?: Date; type?: PostType; tag?: string; page: number; perPage: number }undPostListResult = { 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:
resolveClientundproblemaus Task 6,listPostsundfindPostaus Task 7,listProjectsaus 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:
postReadsaus Task 6,listPostsaus Task 7,resolveClientundproblemaus 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>, EndpunkteGET /api/v1/unreadundPOST /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 überpnpm 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