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

18 KiB

Logbuch - Design

Stand: 30.07.2026

Zweck

Logbuch ist die zentrale Stelle, an der pro Projekt festgehalten wird, was passiert ist. Alle zwei Wochen picken sich Moderatoren die stärksten Neuerungen eines Projekts heraus, schreiben sie mit Screenshots auf und veröffentlichen sie. Die jeweilige Anwendung holt die Beiträge über eine API und zeigt sie ihren Nutzern als "Was ist neu" an. Zusätzlich gibt es ein Web-Archiv in Blog-Form: alle Projekte auf einer Übersicht, oder ein einzelnes Projekt mit Jahres- und Monatsarchiv.

Der Aufbau ist durchgängig dynamisch. Marken, Projekte, Beitragstypen, Inhaltsblöcke und Kanäle sind Daten, nicht Code. Ein neues Projekt anzulegen ist eine Eingabe im Admin, kein Deployment.

Rahmenbedingungen und Entscheidungen

Punkt Entscheidung
Reichweite Zuerst intern, Datenmodell aber mit Marke, Projekt und Zielgruppe, damit Kundennutzung ohne Umbau möglich ist
Inhalte Von Hand im Admin aus Blöcken gebaut, dazu Import-Vorschläge aus Trakk und GitHub als Rohmaterial
Kanäle Runde eins Web-Archiv und In-App über API
Kanäle später Mail über SES, Push über ntfy. Im Datenmodell vorgesehen, nicht gebaut
Deployment Eigene App, eigenes Repo, eigene Subdomain, Coolify. Nicht Teil von Portal-v2
Auth better-auth für Moderatoren und Admins, Portal-SSO später. Basic-Auth-Gate vor der Instanz wie bei allen NYO-Apps
Medien Coolify-Volume hinter einer Storage-Abstraktion, S3 später als Adapter
Web-Archiv Runde eins Interne Ansicht hinter Basic-Auth. Kundensicht läuft ausschließlich über die App-API
Lesen Bewusst offen. Wer durch das Basic-Auth-Gate kommt, darf alle Projekte und alle internen Beiträge lesen. Kein Leser-Konto, keine Leserechte-Pflege
Erfassen Muss in unter zwei Minuten gehen, ohne Einarbeitung. Wenn Kollegen es als Aufwand empfinden, wird es nicht benutzt. Siehe Abschnitt "Erfassen ohne Reibung"
Erscheinungsbild Wie ein guter Blog, nicht wie ein Verwaltungswerkzeug. Hell ist Standard, Dunkel vollständig unterstützt

Ausdrücklich nicht im Umfang

Mailversand, eigene Absenderdomains pro Kunde, Kampagnen-Automationen, A/B-Tests, Billing, Kommentarfunktion, öffentliche Anmeldeformulare, Double-Opt-in.

Firmen- und Projektstruktur

Spaceport Adventures ist das Dach, darunter die Schwestern NYO, Pocket Rocket, Tajo und Aliens Exist. Marke ist deshalb eine eigene Ebene über dem Projekt, nicht ein Feld am Projekt. Die Startseite kann nach Marke gruppieren, ein Projekt gehört immer zu genau einer Marke.

Architektur

Stack

Übernommen aus Trakk und Portal-v2, damit der Kosmos einheitlich bleibt:

  • Next.js 16.2 (App Router), React 19
  • PostgreSQL mit Drizzle ORM
  • Tailwind 4
  • better-auth für Authentifizierung
  • next-intl für Zweisprachigkeit (de, en)
  • zod für Validierung an allen API-Grenzen
  • react-icons mit Tabler-Icons
  • overlayscrollbars statt native Scrollbars
  • pnpm

Schichten

Vier klar getrennte Einheiten, jede mit eigener Aufgabe und eigener Schnittstelle:

  1. Domain (src/domain/): Kernlogik ohne Framework-Bezug. Sichtbarkeitsregeln, Statusübergänge, Rechteprüfung, Slug-Erzeugung. Reine Funktionen, vollständig testbar ohne Datenbank.
  2. Data (src/data/): Drizzle-Schema und Repositories. Jede Tabelle bekommt ein Repository mit benannten Abfragen. Kein SQL außerhalb dieser Schicht.
  3. API (src/app/api/v1/): HTTP-Grenze. Nimmt Anfragen an, validiert per zod, prüft Token oder Session, ruft Domain und Data, gibt JSON zurück. Keine Logik.
  4. UI (src/app/(admin)/, src/app/(public)/): Admin und Web-Archiv. Holt Daten über Server Components, schreibt über Server Actions oder die API.

Die Sichtbarkeitsregel lebt ausschließlich in der Domain-Schicht und wird an genau einer Stelle angewendet, bevor Daten die API verlassen. Kein Frontend filtert Zielgruppen.

Datenmodell

brand

Marke oder Firma. id, slug, name, color, logo_media_id, sort, created_at.

project

id, brand_id, slug, name, description, color, logo_media_id, is_active, sort, created_at.

Slug ist projektweit eindeutig und Teil der Web-Routen.

post

Die zentrale Einheit.

Feld Bedeutung
id
project_id Zugehöriges Projekt
issue_id Optionale Ausgabe
slug Eindeutig pro Projekt
title, teaser
type feature, improvement, fix, breaking, info
audience internal, customer, public
status draft, review, scheduled, published, archived
locale de oder en
translation_group Verbindet Übersetzungen desselben Beitrags. Beiträge können auch nur einsprachig existieren
publish_at Termin bei scheduled, Veröffentlichungszeitpunkt bei published
author_id Autor, auch ein API-Client möglich
created_by_client_id Gesetzt, wenn der Beitrag über die API entstand
cover_media_id Aufmacherbild
search_vector tsvector für Volltextsuche
created_at, updated_at

Die Zielgruppen sind aufsteigend offen: public sehen alle, customer sehen Kunden und intern, internal nur intern. Ein Kunde bekommt nie einen internen Beitrag ausgeliefert, auch nicht als Titel in einer Liste.

block

Inhaltsblöcke eines Beitrags. id, post_id, sort, type, data (JSONB).

Blocktypen in Runde eins: text (Rich Text), image, gallery, before_after, video, quote, code, link, callout.

Jeder Typ hat ein zod-Schema in einer Registry und eine Render-Komponente. Ein neuer Typ heißt: Schema plus Komponente plus Editor-Eingabe. Kein Schema-Change an der Datenbank.

issue

Optionale Klammer über mehrere Beiträge. id, project_id, title, period_from, period_to, status, publish_at, created_at.

Eine Ausgabe erzwingt keinen Rhythmus. Beiträge können jederzeit einzeln erscheinen, oder gebündelt als Ausgabe alle zwei Wochen.

tag, post_tag

Freie Schlagworte pro Projekt, Filter im Archiv.

media

id, project_id, kind (image, video), original_filename, mime, width, height, byte_size, variants (JSONB mit Breite, Format und Pfad je Variante), alt, caption, uploaded_by, created_at.

Bilder werden beim Upload nach WebP konvertiert und in mehreren Breiten abgelegt. Alt-Text wird angemahnt, ist aber nur bei öffentlichen Beiträgen Pflicht.

user, user_project_role

user kommt von better-auth, ergänzt um is_admin.

user_project_role: user_id, project_id, role (moderator).

Ein Konto braucht nur, wer schreibt. Lesen ist offen und an keine Rolle gebunden. Schreibrechte gelten pro Projekt, ein Admin hat implizit alle Rechte in allen Projekten.

Die Tabelle trägt eine Rollenspalte statt eines Kennzeichens, damit weitere Rollen später ohne Migration dazukommen können.

api_client

id, name, token_hash, project_id (null bedeutet alle Projekte), scopes, can_publish (Standard false), last_used_at, revoked_at.

Zwei Arten von Clients: Write-Clients legen Drafts an und laden Medien hoch. Read-Clients sind die eingebundenen Anwendungen und holen veröffentlichte Beiträge ihrer Zielgruppe.

post_read

project_id, external_user_id, post_id, read_at.

Der gelesen-Status hängt an der Nutzerkennung der Zielanwendung, nicht an einem Logbuch-Konto. Trakk schickt seine eigene User-ID mit, MTA360 seine. Die Kennung wird pro Projekt gespeichert, damit sich IDs verschiedener Anwendungen nicht überschneiden.

reaction

post_id, project_id, external_user_id, kind (useful, question), created_at. Eindeutig pro Nutzer, Beitrag und Art.

In Runde eins nur aus der App, weil das Web-Archiv keine Leser-Identität hat.

delivery

id, post_id, channel (web, inapp, mail, push), status, dispatched_at, error.

Web und In-App werden beim Veröffentlichen als erledigt vermerkt. Die Tabelle existiert, damit Mail und Push später nur einen Adapter brauchen.

audit_log

id, actor_type (user, client), actor_id, action, entity, entity_id, data, created_at.

Protokolliert mindestens: Veröffentlichen, Zurückziehen, Zielgruppenwechsel, Rechteänderung, Token-Erzeugung.

Rollen und Rechte

Rolle Darf
Admin Alles. Marken, Projekte, Nutzer, Tokens verwalten
Moderator (pro Projekt) Beiträge und Ausgaben anlegen, bearbeiten, veröffentlichen, zurückziehen, Medien hochladen
Jeder im internen Archiv Alle Projekte und alle Beiträge lesen, ohne Konto und ohne Rollenzuweisung
Write-Client Drafts anlegen und bearbeiten, Medien hochladen. Nicht veröffentlichen
Read-Client Veröffentlichte Beiträge der erlaubten Zielgruppe abrufen, gelesen melden

Lesen ist bewusst nicht eingeschränkt. Der Schutz liegt in Runde eins allein am Basic-Auth-Gate vor der Instanz. Das ist eine bewusste Entscheidung, damit niemand Leserechte pflegen muss, und gilt nur solange das Web-Archiv intern ist. Sobald Kunden eine Web-Ansicht bekommen sollen, muss diese Entscheidung neu getroffen werden.

can_publish bleibt für Write-Clients auf false. Damit landet nichts unkontrolliert bei Kunden, auch nicht aus einem automatisierten Lauf.

API

Version im Pfad, /api/v1. Alle Eingaben zod-validiert. Fehler einheitlich als Problem-JSON mit type, title, status, detail.

Lesend, Bearer eines Read-Clients

Route Zweck
GET /api/v1/brands Marken
GET /api/v1/projects Projekte, gefiltert auf die Berechtigung des Clients
GET /api/v1/posts Beiträge. Parameter: project, since, type, tag, q, page, per_page
GET /api/v1/posts/:slug Einzelner Beitrag mit Blöcken und Medien
GET /api/v1/issues Ausgaben eines Projekts
GET /api/v1/unread Ungelesene Beiträge. Parameter: external_user_id
POST /api/v1/read Meldet einen oder mehrere Beiträge als gelesen
POST /api/v1/reactions Reaktion setzen oder entfernen

Die Zielgruppe wird nicht vom Aufrufer bestimmt, sondern aus dem Client abgeleitet. Ein Read-Client der MTA360-Kundenoberfläche bekommt niemals interne Beiträge, egal welche Parameter er schickt.

Schreibend, Bearer eines Write-Clients

Route Zweck
POST /api/v1/posts Beitrag anlegen, Status immer draft
PATCH /api/v1/posts/:id Beitrag ändern, solange er nicht veröffentlicht ist
PUT /api/v1/posts/:id/blocks Blöcke setzen, vollständige Liste in Reihenfolge
POST /api/v1/media Datei hochladen, multipart. Liefert Media-ID und Varianten

POST /api/v1/posts akzeptiert einen idempotency_key, damit ein wiederholter Aufruf keinen Doppelbeitrag erzeugt.

Feeds

GET /feed/:project.xml als Atom, nur Beiträge der Zielgruppe public.

Einbindung in die Anwendungen

Keine iframes. Die Anwendungen holen JSON und rendern selbst, damit das Panel im jeweiligen Design sitzt.

Dazu zwei dünne Client-Pakete im Repo:

  • packages/client-react für Trakk, Orbit, Arc und weitere Next-Apps
  • packages/client-vue für MTA360 und nyo-frontend

Beide bieten dasselbe: Beiträge laden, ungelesene Anzahl als Badge, Panel mit Beitragsliste und Detailansicht, gelesen melden, reagieren. Die Anwendung übergibt Read-Key, Projekt-Slug und ihre eigene Nutzerkennung.

Der Read-Key gehört serverseitig in die einbindende App. Die Pakete rufen einen kleinen Proxy-Endpunkt der jeweiligen App auf, damit der Key nie im Browser liegt.

Admin

  • Projekt-Switcher in der Kopfzeile, Rechte bestimmen die Auswahl
  • Beitragsliste mit Status, Typ, Zielgruppe, Termin, Filter und Suche
  • Block-Editor: Blöcke hinzufügen, sortieren per Drag & Drop, Bilder direkt in den Editor ziehen
  • Vorschau umschaltbar zwischen den Zielgruppen, damit vor dem Veröffentlichen sichtbar ist, was ein Kunde sieht
  • Workflow draft nach review nach published, mit Zurückziehen nach archived
  • Terminierung: Termin setzen, ein Cron veröffentlicht
  • Ausgaben: Zeitraum wählen, Beiträge zuordnen, gemeinsam veröffentlichen
  • Medienbibliothek pro Projekt mit Alt-Text-Pflege
  • Verwaltung von Marken, Projekten, Nutzern, Rollen und Tokens für Admins

Erfassen ohne Reibung

Das Projekt scheitert oder gelingt an dieser Stelle. Wenn ein Kollege für einen Beitrag über eine neue Funktion mehr als zwei Minuten braucht oder erst etwas lernen muss, schreibt er nichts. Deshalb sind das harte Anforderungen, keine Verbesserungen für später:

  • Ein Feld zum Anfangen. Neuer Beitrag heißt: Titel eingeben, losschreiben. Projekt ist aus dem Kontext vorbelegt, Typ ist feature, Zielgruppe ist internal, Sprache ist de. Alles änderbar, nichts abzufragen
  • Screenshot aus der Zwischenablage. Bild mit Strg+V direkt in den Editor einfügen, ohne Datei-Dialog. Genauso Drag & Drop von mehreren Bildern auf einmal, die dann als Galerie landen
  • Schreiben wie in einem Textfeld. Der Text-Block versteht Markdown-Eingaben beim Tippen (Überschrift, Liste, Fettung, Link). Wer Markdown nicht kennt, benutzt die Werkzeugleiste. Eingefügter Text aus anderen Quellen behält Struktur, verliert Fremdformatierung
  • Automatisches Speichern. Entwürfe speichern sich selbst. Kein verlorener Text, keine Speicher-Frage beim Verlassen
  • Blöcke sind unsichtbar, bis man sie braucht. Wer nur Text und Bilder schreibt, sieht keine Block-Verwaltung. Weitere Blocktypen kommen über einen Einfüge-Knopf oder / im Text
  • Veröffentlichen ist ein Knopf. Kein Pflicht-Review, kein Pflicht-Termin, keine Pflicht-Schlagworte. Der Review-Status existiert für die, die ihn wollen
  • Nichts blockiert ohne Grund. Prüfungen vor dem Veröffentlichen sind Hinweise, keine Sperren. Gesperrt wird nur, was echten Schaden anrichtet: fehlende Zielgruppe und öffentliche Beiträge ohne Alt-Text

Erscheinungsbild

Es soll aussehen wie ein guter Blog, den man freiwillig liest, nicht wie eine Verwaltungsmaske. Große Aufmacherbilder, ruhige Typografie mit ordentlicher Zeilenlänge im Lesetext, klare Abstände, Beitragstypen als dezente Badges, Projektfarbe als Akzent statt als Anstrich.

Harte Regeln:

  • Keine nativen Scrollbars. Überall overlayscrollbars, auch in Panels, Modalen, Listen, Medienbibliothek und im In-App-Panel. Keine Ausnahme
  • Kein großes Stylesheet. Gestaltet wird mit Tailwind-Utilities und Design-Tokens. Das globale CSS enthält nur Tokens, Basis-Typografie und das overlayscrollbars-Thema und bleibt unter 150 Zeilen. Keine CSS-Datei pro Komponente, kein wachsendes Sammel-Stylesheet
  • Hell ist Standard, Dunkel ist vollwertig. Umsetzung über CSS-Variablen als Tokens plus Tailwind-Dark-Variante. Beide Modi über dieselben Tokens, kein zweites Stylesheet, keine Sonderfälle je Komponente. Umschalter im Kopfbereich, Auswahl wird gespeichert, Systemeinstellung als dritte Option
  • Keine Emojis in Oberfläche und Inhalten
  • Keine Pixelangaben, Abstände und Größen über die Tailwind-Skala
  • Icons ausschließlich Tabler über react-icons

Import-Vorschläge

Ein Panel im Beitrags-Editor zieht Rohmaterial für einen Zeitraum:

  • Erledigte Tickets aus der Trakk-API des zugeordneten Projekts
  • Releases und zusammengeführte Pull Requests aus GitHub

Ergebnis ist eine Auswahlliste. Angehakte Einträge werden als Blöcke mit Titel und Link eingefügt. Kein generierter Text. Die Zuordnung Projekt zu Trakk-Projekt und GitHub-Repository steht am Projekt.

Web-Archiv

Route Inhalt
/ Übersicht aller Projekte, gruppiert nach Marke, neueste Beiträge
/[project] Projekt-Blog mit Seitenweiterschaltung
/[project]/[year] und /[project]/[year]/[month] Zeitarchiv
/[project]/[slug] Einzelner Beitrag
/[project]/tag/[tag] Schlagwort-Filter
/issues/[id] Ausgabe als Ganzes
/search Volltextsuche über Postgres tsvector

Zweisprachig über next-intl, keine festverdrahteten Texte. Beitragstypen als Badges. OG-Bilder werden aus Projektfarbe, Logo und Titel erzeugt. Bilder als WebP mit srcset, Galerie mit Lightbox.

Fehlerbehandlung

  • Eingaben scheitern laut und früh: zod an jeder API-Grenze, Antwort mit Feldfehlern
  • Veröffentlichen prüft vorab und unterscheidet Hinweis von Sperre. Gesperrt wird nur bei fehlender Zielgruppe, fehlendem Titel, leerem Beitrag oder öffentlichem Beitrag ohne Alt-Text. Fehlender Alt-Text bei internen Beiträgen, fehlende Schlagworte oder fehlendes Aufmacherbild sind Hinweise und halten niemanden auf
  • Medien-Upload ist zweistufig: Datei speichern, dann Varianten erzeugen. Scheitert die Konvertierung, bleibt das Original nutzbar und der Fehler steht am Medium
  • Der Veröffentlichungs-Cron ist wiederholbar: er nimmt Beiträge mit status = scheduled und publish_at <= jetzt, ein Doppellauf erzeugt keine Doppelauslieferung, weil delivery den Zustand hält
  • Ausfälle von Trakk oder GitHub beim Import blockieren den Editor nicht, das Panel zeigt den Fehler und bleibt schließbar
  • Unbekannte Blocktypen im Renderer werden übersprungen und protokolliert, statt die Seite zu zerlegen

Tests

Der Schwerpunkt liegt dort, wo ein Fehler weh tut.

Bereich Art
Sichtbarkeitsregel (Zielgruppe je Read-Client) Vitest, vollständige Matrix
Statusübergänge und Veröffentlichungs-Prüfungen Vitest
Schreibrechte pro Projekt, Write-Client darf nicht veröffentlichen Vitest
Block-Schemas Vitest gegen die Registry
API-Routen Vitest mit Testdatenbank, Schwerpunkt Auth und Filter
Draft bis Veröffentlicht bis In-App-Abruf Ein Playwright-Smoketest

Die Sichtbarkeitsmatrix bekommt bewusst mehr Tests als der Rest, weil ein Fehler dort interne Inhalte an Kunden ausliefert.

Betrieb

  • Eigenes Repository, Verzeichnis projects/logbuch
  • Coolify-Deployment, eigene Subdomain, Basic-Auth-Gate davor
  • Eigene PostgreSQL-Instanz
  • Migrationen über Drizzle, beim Deployment ausgeführt
  • Medien auf einem Coolify-Volume, Zugriff über eine Storage-Schnittstelle mit einem lokalen Adapter. Ein S3-Adapter kann später ohne Änderung am Rest ergänzt werden
  • Cron für terminierte Veröffentlichung
  • Rybbit für Zugriffszahlen des Web-Archivs

Offene Punkte

  • Subdomain noch nicht festgelegt
  • Welche Nutzerkennung MTA360 und Trakk für den gelesen-Status liefern, muss beim Einbinden je App geklärt werden
  • Ob Tajo und Aliens Exist eigene Projekte in Logbuch bekommen, ist offen. Das Datenmodell trägt sie