diff --git a/docs/api.md b/docs/api.md index b85dea5..e05abf5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -229,6 +229,7 @@ Antwort: `{ "published": [...], "meta": { "total": 0 } }`. |---|---| | `GET /api/v1/openapi.json` | maschinenlesbare Beschreibung für Postman, Bruno, Insomnia oder einen KI-Assistenten | | `GET /api/v1/style-guide` | dieser Redaktionsleitfaden als Markdown | +| `GET /api/v1/skill` | fertiger Skill für Claude, gehört als `~/.claude/skills/logbuch/SKILL.md` abgelegt | Beide verlangen ein Token. In der Verwaltung unter API lädt ein angemeldeter Admin beide Dateien ohne Token herunter. diff --git a/docs/skill.md b/docs/skill.md new file mode 100644 index 0000000..327811e --- /dev/null +++ b/docs/skill.md @@ -0,0 +1,154 @@ +--- +name: logbuch +description: Use when writing project updates into Logbuch, the update archive of the NYO group. Covers the API at /api/v1, the editorial rules, images, dating and publishing. Trigger on "Logbuch", "Eintrag schreiben", "Projekt-Update", "Änderung festhalten", or when work is finished and someone should hear about it. +--- + +# Logbuch + +Logbuch hält fest, was in den Produkten passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag über das, was fertig geworden ist. Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung, sondern für Kollegen und Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. + +## Was du wissen musst, bevor du schreibst + +Ein Zugang kann **niemals veröffentlichen**. Alles, was du anlegst, ist ein Entwurf. Ein Mensch schaut drauf und gibt frei. Schreib entsprechend fertig, nicht halb. + +Jede Anfrage trägt das Token im Kopf: + +``` +Authorization: Bearer +``` + +Die Basis-Adresse steht in deiner Aufgabe, im Zweifel `https://logbuch.nyo.de`. Alles liegt unter `/api/v1`. + +Dein Zugang hat drei Eigenschaften, die bestimmen, was du darfst: + +| Eigenschaft | Bedeutung | +|---|---| +| Modus | `read` liest, `write` schreibt zusätzlich | +| Zielgruppe | `internal`, `customer` oder `public`. Du siehst und setzt nie etwas Offeneres | +| Projektbindung | Ist ein Projekt gesetzt, gilt der Zugang ausschließlich dort | + +## Reihenfolge + +1. `GET /api/v1/projects` - welche Projekte darfst du bespielen +2. `GET /api/v1/post-types` - die gültigen Schlüssel der Beitragsarten, nicht raten +3. `GET /api/v1/style-guide` - der Redaktionsleitfaden im Wortlaut +4. `POST /api/v1/media` - Bilder hochladen, wenn du welche hast +5. `POST /api/v1/posts` - Entwurf anlegen +6. `PUT /api/v1/posts/:id/blocks` - Inhalt setzen +7. `GET /api/v1/posts/:id` - zurücklesen und prüfen + +## Einen Eintrag anlegen + +```bash +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -H "content-type: application/json" \ + -d '{ + "project": "trakk", + "title": "SLA-Uhr zählt Feiertage nicht mehr mit", + "teaser": "Reaktionszeiten liefen über Wochenenden weiter und lösten falsche Eskalationen aus.", + "type": "fix", + "audience": "customer", + "cover_media_id": "", + "publish_at": "2026-06-04T09:00:00Z", + "idempotency_key": "sla-2026-06-04" + }' \ + "$BASE/api/v1/posts" +``` + +Die Antwort trägt die Kennung. **Merk sie dir**, du brauchst sie für Blöcke, Änderungen und Löschen. Hast du sie verloren, findest du den Entwurf über `GET /api/v1/posts/`. + +`idempotency_key` setzen, wenn dein Lauf abbrechen könnte. Derselbe Schlüssel liefert denselben Eintrag zurück, statt einen zweiten anzulegen. + +`number` ist bei Entwürfen `null`. Die laufende Nummer vergibt Logbuch beim Veröffentlichen, damit im Archiv keine Lücken entstehen. + +## Inhalt setzen + +```bash +curl -s -X PUT -H "Authorization: Bearer $TOKEN" \ + -H "content-type: application/json" \ + -d '{"blocks":[ + {"type":"text","data":{"text":"Bisher lief die Uhr durch. Wer freitags meldete, hatte montags eine Eskalation im Postfach."}}, + {"type":"image","data":{"mediaId":""}}, + {"type":"text","data":{"text":"Jetzt zählt nur noch die vereinbarte **Servicezeit**."}} + ]}' \ + "$BASE/api/v1/posts//blocks" +``` + +Der Aufruf setzt die Blöcke vollständig, in der übergebenen Reihenfolge. Höchstens 100. + +| Typ | Felder in `data` | +|---|---| +| `text` | `text`, als Markdown: Absätze, **fett**, *kursiv*, Verweise, Listen, `###` als Zwischenüberschrift. Kein rohes HTML | +| `image` | `mediaId` | +| `gallery` | `mediaIds` | +| `before_after` | `beforeMediaId`, `afterMediaId` | +| `video` | `url`, `title` | +| `quote` | `text`, `source` | +| `code` | `code`, `language` | +| `link` | `url`, `title`, `description` | +| `callout` | `text`, `tone` | + +## Bilder + +```bash +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -F "file=@screenshot.png" \ + -F "project=trakk" \ + -F "alt=Spaltenmenü mit den neuen Einträgen" \ + "$BASE/api/v1/media" +``` + +`project` ist der Slug, bei projektgebundenem Token darf er wegbleiben. Nur Bildformate, höchstens 15 MB. Die Antwort trägt die Medien-Kennung für `cover_media_id` und für Bildblöcke. + +Ein Bild zeigt, was sich geändert hat. Kein Firmenlogo, kein Symbolbild. Findest du nichts, was den Punkt wirklich zeigt, lass es weg. Der Alt-Text beschreibt, was zu sehen ist, und ist bei öffentlichen Beiträgen Pflicht, sonst lässt sich der Beitrag nicht freigeben. + +Ein Bild wieder loswerden: `DELETE /api/v1/media/:id`. Hängt es noch an einem Beitrag, kommt 409. + +## Datum + +`publish_at` nimmt eine ISO-Zeitangabe, beim Anlegen und beim Ändern. Du veröffentlichst damit nichts, du hinterlegst nur das Datum. Gibt ein Mensch frei, übernimmt Logbuch genau dieses statt der aktuellen Uhrzeit. So landen ältere Meldungen an der richtigen Stelle im Archiv. + +## Wie es klingen soll + +**Aufbau.** Titel, Anreißer, dann der Text. Der Titel sagt, was sich geändert hat, nicht dass sich etwas geändert hat. Der Anreißer fasst in zwei bis drei Sätzen zusammen, was der Leser davon hat. Der Text erklärt, was vorher war, was jetzt ist, und was zu tun bleibt. + +Gute Titel: + +- "SLA-Uhr zählt Feiertage nicht mehr mit" +- "Spalten per Rechtsklick verwalten" + +Schlechte Titel: + +- "Verbesserungen an der Tabelle" - sagt nichts +- "Wir haben ein spannendes neues Feature gebaut" - Werbung statt Information + +**Ton.** Sachlich und direkt, aktive Verben. Keine Werbesprache, keine Superlative, keine Ausrufezeichen, keine Emojis. Keine Gedankenstriche, immer normales Minus. Echte Umlaute, niemals ae, oe, ue. + +**Länge.** So lang wie nötig, meist drei bis sechs Absätze. Eine Fehlerbehebung darf aus zwei Sätzen bestehen. + +**Zielgruppe.** `internal` für Umbauten, Zwischenstände und alles über Kunden. `customer` für das, was ein Kunde des Produkts wissen soll. `public` nur für das, was auch außerhalb stehen darf. Im Zweifel die engere wählen. + +## Fehler + +Antworten kommen als Problem-JSON mit `type`, `title`, `status` und `detail`. + +| Status | Bedeutung | +|---|---| +| 400 | fehlerhafte Eingabe, `detail` sagt welches Feld | +| 401 | Token fehlt oder ist widerrufen | +| 403 | dem Zugang fehlt das Recht, etwa eine zu offene Zielgruppe | +| 404 | nicht vorhanden oder für diesen Zugang nicht sichtbar | +| 409 | bereits veröffentlicht, Änderung nicht mehr möglich | +| 422 | unbekannte Beitragsart oder fehlende Projektbindung | + +## Was du nicht tust + +- Veröffentlichen. Geht nicht und soll nicht. +- Eine offenere Zielgruppe setzen, als dein Zugang hat. +- Beitragsarten raten statt `GET /api/v1/post-types` zu fragen. +- Symbolbilder anhängen, damit etwas bunt ist. +- Bei einem Abbruch blind neu anlegen. Erst `GET /api/v1/posts/` fragen oder `idempotency_key` verwenden. + +## Vollständige Beschreibung + +`GET /api/v1/openapi.json` liefert die maschinenlesbare Fassung mit allen Feldern und Antworten. `GET /api/v1/style-guide` liefert den Leitfaden. Beide verlangen dasselbe Token. diff --git a/next.config.ts b/next.config.ts index 029f584..03585df 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,9 @@ const config: NextConfig = { output: 'standalone', outputFileTracingIncludes: { '/api/v1/style-guide': ['./docs/style-guide.md'], + '/api/v1/skill': ['./docs/skill.md'], + '/admin/api/files/skill': ['./docs/skill.md'], + '/admin/api/files/style-guide': ['./docs/style-guide.md'], '/': ['./node_modules/drizzle-orm/**'], '/api/v1/media': sharpNative, '/admin/media': sharpNative, diff --git a/src/app/admin/api/files/skill/route.ts b/src/app/admin/api/files/skill/route.ts new file mode 100644 index 0000000..6be2fd1 --- /dev/null +++ b/src/app/admin/api/files/skill/route.ts @@ -0,0 +1,14 @@ +import { readSkill, skillResponse } from '~/lib/api-docs' +import { requireAdminArea } from '~/lib/auth-guards' + +export async function GET() { + await requireAdminArea() + + const text = await readSkill() + + if (text === null) { + return new Response('docs/skill.md fehlt.', { status: 500 }) + } + + return skillResponse(text, true) +} diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx index 51e1546..927562d 100644 --- a/src/app/admin/api/page.tsx +++ b/src/app/admin/api/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import { headers } from 'next/headers' import Link from 'next/link' -import { TbBook, TbDownload } from 'react-icons/tb' +import { TbBook, TbDownload, TbSparkles } from 'react-icons/tb' import { AdminHeading } from '~/components/admin/AdminHeading' import { ApiEndpoint } from '~/components/admin/ApiEndpoint' import { cellClass, headCellClass } from '~/components/admin/styles' @@ -42,6 +42,7 @@ export default async function AdminApiPage() { const files = [ { label: 'OpenAPI', url: `${origin}/api/v1/openapi.json` }, { label: 'Leitfaden', url: `${origin}/api/v1/style-guide` }, + { label: 'Skill', url: `${origin}/api/v1/skill` }, ] return ( @@ -74,7 +75,10 @@ export default async function AdminApiPage() {

Die Beschreibung im OpenAPI-Format lässt sich in Postman, Bruno, Insomnia und ähnliche Werkzeuge - einlesen, und ein KI-Assistent kann sie direkt verwenden. + einlesen. Für Claude gibt es zusätzlich einen fertigen Skill: die Datei landet als + {' '} + ~/.claude/skills/logbuch/SKILL.md, danach weiß jede + Sitzung, wofür Logbuch da ist, wie ein Eintrag klingt und in welcher Reihenfolge die Aufrufe gehen.

Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen @@ -189,6 +201,14 @@ export default async function AdminApiPage() { example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/posts/regel-engine'} /> + + { + await db.update(apiClients).set({ lastUsedAt: new Date() }).where(eq(apiClients.id, id)) +} + export async function listClients(): Promise { return db.select().from(apiClients).orderBy(asc(apiClients.revokedAt), desc(apiClients.createdAt)) } diff --git a/src/lib/admin-routes.ts b/src/lib/admin-routes.ts index 0f85b2c..602a9aa 100644 --- a/src/lib/admin-routes.ts +++ b/src/lib/admin-routes.ts @@ -12,7 +12,7 @@ export const adminClientsPath = '/admin/clients' as Route export const adminPostTypesPath = '/admin/post-types' as Route export const adminNewPostTypePath = '/admin/post-types/new' as Route -export function adminApiFilePath(file: 'openapi' | 'style-guide'): Route { +export function adminApiFilePath(file: 'openapi' | 'style-guide' | 'skill'): Route { return `/admin/api/files/${file}` as Route } diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 0f7f5b0..a5b2405 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { findActiveClientByTokenHash } from '~/data/repositories/clients' +import { findActiveClientByTokenHash, markClientUsed } from '~/data/repositories/clients' import type { ApiClient } from '~/data/schema' export function hashToken(token: string): string { @@ -19,5 +19,11 @@ export async function resolveClient(request: Request): Promise { +async function readDoc(name: string): Promise { try { - return await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8') + return await readFile(join(process.cwd(), 'docs', name), 'utf8') } catch { return null } } +export async function readStyleGuide(): Promise { + return readDoc('style-guide.md') +} + +export async function readSkill(): Promise { + return readDoc('skill.md') +} + +export function skillResponse(text: string, download = false): Response { + const headers: Record = { 'content-type': 'text/markdown; charset=utf-8' } + + if (download) { + headers['content-disposition'] = 'attachment; filename="SKILL.md"' + } + + return new Response(text, { headers }) +} + export function styleGuideResponse(text: string, download = false): Response { const headers: Record = { 'content-type': 'text/markdown; charset=utf-8' } diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index d567231..2a3a10f 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -129,6 +129,16 @@ export function openApiDocument(baseUrl: string) { }, }, }, + '/api/v1/skill': { + get: { + summary: 'Skill für Claude abrufen', + description: 'Markdown mit Frontmatter, gehört als ~/.claude/skills/logbuch/SKILL.md abgelegt.', + responses: { + '200': { description: 'Skill als Markdown', content: { 'text/markdown': { schema: { type: 'string' } } } }, + ...errors([401]), + }, + }, + }, '/api/v1/style-guide': { get: { summary: 'Redaktionsleitfaden abrufen', diff --git a/tests/docs/skill.test.ts b/tests/docs/skill.test.ts new file mode 100644 index 0000000..a137567 --- /dev/null +++ b/tests/docs/skill.test.ts @@ -0,0 +1,59 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const skill = readFileSync(join(process.cwd(), 'docs', 'skill.md'), 'utf8') + +function routes(): string[] { + const found: string[] = [] + + function walk(dir: string, url: string) { + for (const name of readdirSync(dir)) { + const path = join(dir, name) + + if (statSync(path).isDirectory()) { + walk(path, `${url}/${name}`) + continue + } + + if (name === 'route.ts') { + found.push(url) + } + } + } + + walk(join(process.cwd(), 'src', 'app', 'api', 'v1'), '/api/v1') + + return found +} + +describe('Skill für Claude', () => { + it('trägt Frontmatter mit Namen und Beschreibung', () => { + const head = skill.split('---')[1] ?? '' + + expect(head).toMatch(/\nname: logbuch\n/u) + expect(head).toMatch(/\ndescription: .{40,}/u) + }) + + it('nennt nur Endpunkte, die es gibt', () => { + const known = new Set(routes().map(url => url.replace(/\/\[[^\]]+\]/gu, ''))) + const mentioned = [...skill.matchAll(/`?(?:GET|POST|PUT|PATCH|DELETE) (\/api\/v1[a-z0-9./-]*)/giu)] + .map(match => match[1]!.replace(/\/(?::[a-z]+|<[a-z-]+>)/giu, '').replace(/\/$/u, '')) + + expect(mentioned.length).toBeGreaterThan(5) + + for (const url of new Set(mentioned)) { + expect(known.has(url), `${url} steht im Skill, aber nicht im Code`).toBe(true) + } + }) + + it('hält sich an die Hausregeln', () => { + expect(skill).not.toMatch(/[—–]/u) + expect(skill).not.toMatch(/\p{Extended_Pictographic}/u) + expect(skill).not.toMatch(/\b(fuer|ueber|koennen|muessen|loeschen|aendern|groesser|gehoert|laesst|veroeffentlich\w*)\b/iu) + }) + + it('sagt, dass ein Zugang nicht veröffentlichen kann', () => { + expect(skill).toMatch(/niemals veröffentlichen/u) + }) +})