From 9bc9838d8dfe932ce55aa9bbd66b6e59c2909171 Mon Sep 17 00:00:00 2001 From: Jan Wagner Date: Sun, 16 Aug 2026 01:43:16 +0200 Subject: [PATCH] feat(portfolio): add product updates feed --- .gitignore | 1 + ...8-16_000000-batchmaker-newsfeed-backend.md | 239 ++++++++++++++++++ docker-compose.yml | 43 +++- public/portfolio/batchmaker-recipes.jpg | Bin 0 -> 21307 bytes public/portfolio/batchmaker-settings.jpg | Bin 0 -> 22190 bytes public/portfolio/batchmaker-validation.jpg | Bin 0 -> 30731 bytes services/updates-api/.env.example | 3 + services/updates-api/Dockerfile | 9 + services/updates-api/README.md | 53 ++++ services/updates-api/package.json | 10 + services/updates-api/src/server.js | 166 ++++++++++++ services/updates-api/test/updates.test.js | 54 ++++ src/app/page.tsx | 92 ++++++- src/app/projects/batchmaker-studio/page.tsx | 79 ++++++ 14 files changed, 737 insertions(+), 12 deletions(-) create mode 100644 .hermes/plans/2026-08-16_000000-batchmaker-newsfeed-backend.md create mode 100644 public/portfolio/batchmaker-recipes.jpg create mode 100644 public/portfolio/batchmaker-settings.jpg create mode 100644 public/portfolio/batchmaker-validation.jpg create mode 100644 services/updates-api/.env.example create mode 100644 services/updates-api/Dockerfile create mode 100644 services/updates-api/README.md create mode 100644 services/updates-api/package.json create mode 100644 services/updates-api/src/server.js create mode 100644 services/updates-api/test/updates.test.js create mode 100644 src/app/projects/batchmaker-studio/page.tsx diff --git a/.gitignore b/.gitignore index 5ef6a52..46b8466 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!services/updates-api/.env.example # vercel .vercel diff --git a/.hermes/plans/2026-08-16_000000-batchmaker-newsfeed-backend.md b/.hermes/plans/2026-08-16_000000-batchmaker-newsfeed-backend.md new file mode 100644 index 0000000..d089186 --- /dev/null +++ b/.hermes/plans/2026-08-16_000000-batchmaker-newsfeed-backend.md @@ -0,0 +1,239 @@ +# Batchmaker Newsfeed Backend Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Einen kleinen, dauerhaft betriebenen Update-Feed für `Batchmaker` und `Batchmaker Standalone` bauen, der von beiden Produkten und dem Portfolio gelesen werden kann. + +**Architecture:** Separater, schlanker `updates-api`-Service statt Newsdaten im Next.js-Build oder in der Portfolio-App. Der Service bietet eine öffentliche, read-only JSON-/RSS-Schnittstelle und einen geschützten Admin-Schreibpfad. SQLite ist für die geringe erwartete Änderungsfrequenz ausreichend, wird aber auf einem persistenten Docker-Volume betrieben und regelmäßig gesichert. Das Portfolio und beide Batchmaker-Oberflächen bleiben Clients; kein Produkt greift direkt auf die Datenbank zu. + +**Tech Stack:** Node.js 22, TypeScript, native `node:sqlite`, kleiner HTTP-Server ohne neues Framework sofern die vorhandene Runtime das zulässt, Docker Compose, SQLite WAL, Bearer-Token aus Secret/Environment, Next.js Server Component oder Route-Proxy für das Portfolio. + +--- + +## 1. Ist-Zustand und Integrationsgrenzen verifizieren + +**Objective:** Vor der Implementierung die realen Repositories, Deployment-Ziele und vorhandenen Batchmaker-Backend-Konventionen festhalten. + +**Read-only checks:** + +```bash +find /home/eldov-ryzen5/workspace/Coding -maxdepth 3 -type f \( -name package.json -o -name docker-compose.yml -o -name Dockerfile \) +rg -n "express|node:sqlite|sqlite|health|PORT|docker" /home/eldov-ryzen5/workspace/Coding/Batchmaker +``` + +**Festhalten:** +- Welches Repository ist die kanonische Quelle für `Batchmaker`? +- Wo läuft `Batchmaker Standalone` tatsächlich? +- Soll der Feed auf `free-warez.win` oder am selben Host wie das Portfolio betrieben werden? +- Existiert bereits ein Secret-/Backup-Verfahren, das wiederverwendet werden muss? +- Welche Produkt-URLs sollen aus einem Update verlinkt werden? + +**Gate:** Keine Codeänderung, bevor Host, Repo-Pfade und Deployment-SSOT eindeutig sind. + +## 2. Datenmodell und API-Vertrag als kleine, stabile Oberfläche definieren + +**Objective:** Ein minimales Schema schaffen, das sowohl öffentliche Updates als auch spätere redaktionelle Pflege trägt. + +**Proposed entity `updates`:** + +```sql +id INTEGER PRIMARY KEY +slug TEXT NOT NULL UNIQUE +product TEXT NOT NULL CHECK (product IN ('batchmaker', 'standalone')) +title TEXT NOT NULL +summary TEXT NOT NULL +body_markdown TEXT NOT NULL DEFAULT '' +status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived')) +published_at TEXT NULL +created_at TEXT NOT NULL +updated_at TEXT NOT NULL +link_url TEXT NULL +``` + +**Indexes:** `(product, status, published_at DESC)` und `slug`. + +**Public API:** +- `GET /healthz` → `{ "status": "ok" }`, ohne Datenbankdetails. +- `GET /v1/updates?product=batchmaker|standalone&limit=1..50&cursor=...` → nur `published` und `published_at <= now`, newest first. +- `GET /v1/updates/:slug` → ein veröffentlichtes Update. +- `GET /feed.xml?product=...` → RSS 2.0 oder Atom; für Browser/Feed-Reader und SEO. + +**Admin API:** +- `POST /v1/admin/updates` +- `PATCH /v1/admin/updates/:id` +- `POST /v1/admin/updates/:id/publish` +- `POST /v1/admin/updates/:id/archive` + +Admin-Endpunkte verlangen `Authorization: Bearer ...`; kein Token in URLs, Logs oder Frontend-Code. Schreibdaten werden serverseitig validiert; `slug`, `product`, Titel, Status, Limits und Datumswerte sind keine vertrauenswürdigen Eingaben. + +**Entscheidungen:** Cursor-Pagination nur, wenn sie mit vertretbarem Aufwand sauber umgesetzt wird; sonst zunächst `limit` mit hartem Maximum. Kein Login-System, kein CMS und keine Datei-Uploads in v1. + +## 3. Backend als eigenständigen Service anlegen + +**Likely files:** +- Create: `services/updates-api/package.json` +- Create: `services/updates-api/tsconfig.json` +- Create: `services/updates-api/src/server.ts` +- Create: `services/updates-api/src/db.ts` +- Create: `services/updates-api/src/validation.ts` +- Create: `services/updates-api/src/feed.ts` +- Create: `services/updates-api/migrations/001_initial.sql` +- Create: `services/updates-api/Dockerfile` +- Create: `services/updates-api/.env.example` + +**Implementation constraints:** +- Native Node APIs und `node:sqlite` zuerst; keine neue ORM-Abhängigkeit. +- DB-Verzeichnis per `UPDATES_DB_PATH`, Standard lokal unter `data/updates.sqlite`. +- Beim Start Migrationen idempotent ausführen, WAL und foreign keys aktivieren. +- JSON-Fehlerformat stabil halten: `{ "error": { "code": "...", "message": "..." } }`. +- Request body und Content-Length begrenzen, damit der Admin-Endpunkt kein unkontrollierter Speicher-/Disk-Sink wird. +- `Cache-Control` für öffentliche Antworten setzen; Admin-Antworten nicht cachen. +- Logs ohne Authorization-Header und ohne vollständigen Markdown-Body. +- HTML-Escaping/Content-Type beim RSS-Generator korrekt behandeln; Markdown nicht ungefiltert als HTML ausgeben. + +## 4. Tests zuerst für die Invarianten schreiben + +**Test target:** `services/updates-api/test/updates.test.ts` + +Abdecken: +1. Drafts erscheinen niemals in öffentlichen Listen. +2. Zukünftige `published_at`-Einträge erscheinen erst nach ihrem Veröffentlichungszeitpunkt. +3. `product` akzeptiert nur `batchmaker` und `standalone`. +4. `limit` wird auf den erlaubten Bereich begrenzt oder mit 400 abgewiesen. +5. Fehlendes/falsches Bearer-Token blockiert alle Admin-Schreibpfade. +6. Publish setzt `status` und `published_at` atomar. +7. Archivierte Einträge verschwinden aus Public API und RSS. +8. RSS enthält keine ungefilterten/kaputten XML-Zeichen. +9. `/healthz` prüft die DB-Verbindung, gibt aber keine internen Pfade preis. + +**Run:** + +```bash +node --test --import tsx services/updates-api/test/updates.test.ts +``` + +Falls `tsx` nicht bereits verfügbar ist: nicht blind eine Dependency hinzufügen; zuerst prüfen, ob TypeScript-Tests über den vorhandenen Build/Node-Mechanismus laufen. Nur dann die kleinste notwendige devDependency ergänzen. + +## 5. Öffentliche Clients anbinden + +### Portfolio + +**Likely files:** +- Modify: `src/app/page.tsx` oder neue `src/components/updates-feed.tsx` +- Modify: `src/app/layout.tsx` nur falls Metadata/Feed-Link benötigt wird +- Modify: `docker-compose.yml` nur für URL-/Netzwerk-Konfiguration +- Modify: `.env.example` oder Runtime-Dokumentation + +Serverseitig vom Next.js-Server abrufen, nicht per Browser direkt gegen eine private interne URL. Bei Feed-Ausfall darf die Portfolio-Seite nicht failen: leerer Zustand bzw. „Updates momentan nicht verfügbar“, mit serverseitigem Timeout. Produktfilter sichtbar machen: „Batchmaker“ / „Standalone“ / „Alle“. Keine Admin-Funktion im öffentlichen Portfolio. + +### Batchmaker und Standalone + +Je Produkt eine kleine, vorhandene UI-Integration verwenden; keine gemeinsame UI-Bibliothek nur für drei Karten einführen. Die Clients konsumieren denselben Public API-Vertrag und zeigen maximal die letzten drei Updates plus Link „Alle Updates“. Die Feed-API-URL wird konfiguriert, nicht hart codiert. + +## 6. Deployment- und Persistenzpfad definieren + +**Likely files:** +- Modify/create: Compose-Datei des Zielhosts bzw. eines dedizierten `updates-api`-Stacks +- Create: `services/updates-api/backup.sh` oder vorhandenes Backup-Verfahren erweitern +- Modify: Traefik dynamic config/labels, falls öffentliches Routing dort erfolgt + +**Deployment design:** +- Service intern auf Port 8080. +- Traefik-Router z. B. `updates.w-make.com` oder ein eindeutig festgelegter Pfad; Entscheidung erst nach Hostprobe. +- SQLite unter `/data/updates.sqlite` auf named volume, nicht im Container-Layer. +- Admin-Token ausschließlich über Secret/Environment auf dem Host. +- `/healthz` als Container-Healthcheck. +- DB-Backup vor jedem Deployment und per täglichem, atomarem `sqlite3 .backup`/Copy-Verfahren; Restore-Test in temporärem Verzeichnis. +- Keine Docker-Socket-Berechtigung für den Newsfeed-Service. + +**Gate:** Erst deployen, wenn Traefik-Docker-Socket-Zugriff und Router-Konfiguration live geprüft sind; kein blindes 404-Debugging. + +## 7. Redaktions- und Betriebsworkflow festlegen + +**V1:** Updates werden per dokumentiertem CLI-Skript oder `curl` aus einer sicheren Admin-Umgebung angelegt/publiziert. Kein browserbasiertes Admin-Panel, solange nicht mehrere Autoren oder regelmäßige Redaktionsarbeit nachgewiesen sind. + +**Create:** `services/updates-api/README.md` mit: +- lokalem Start +- Beispiel für Draft → Publish +- Token-Handling ohne Credential-Beispiele +- Backup/Restore +- Rollback +- Public API examples + +Optional später: kleines Admin-Formular hinter Auth, wenn der CLI-Workflow nachweislich hinderlich ist. + +## 8. Ende-zu-Ende-Verifikation + +**Local gates:** + +```bash +npm run lint +npm run build +node --test --import tsx services/updates-api/test/updates.test.ts +curl -fsS http://127.0.0.1:8080/healthz +curl -fsS 'http://127.0.0.1:8080/v1/updates?product=standalone&limit=3' +``` + +**Integration checks:** +- Ein Test-Draft je Produkt anlegen. +- Sicherstellen, dass Public API beide Drafts nicht zeigt. +- Beide veröffentlichen und Responses/RSS prüfen. +- Portfolio und beide Produkte laden; Feed-Karten und Deep-Link prüfen. +- API absichtlich stoppen: Clients bleiben renderbar und zeigen Fallback. +- Falsches Admin-Token, übergroßer Body und ungültiges Produkt testen. + +**Production gates:** +- `curl` über den finalen HTTPS-Host → 200 für `/healthz`, `/v1/updates`, `/feed.xml`. +- Traefik-Router trifft den richtigen Service; keine 404/502. +- Container-Restart: Test-Update bleibt vorhanden. +- Backup erstellen und in temporäre SQLite-Datei zurücklesen. +- Logs enthalten keine Secrets. +- Erst danach echte News veröffentlichen. + +## Risiken und Trade-offs + +- **SQLite vs. PostgreSQL:** SQLite ist für seltene redaktionelle Writes und wenige Leser ausreichend und reduziert Ops deutlich. Bei mehreren Autoren, hoher Schreiblast oder bestehender PostgreSQL-SSOT migrieren; API-Vertrag bleibt gleich. +- **Separate API vs. Next Route Handler:** Separate API verhindert Kopplung von Persistenz und Portfolio-Build und kann von beiden Batchmaker-Produkten genutzt werden. Mehr Deployment-Artefakt, aber sauberere Verantwortungsgrenze. +- **Token vs. Auth-System:** Ein einzelnes Admin-Token ist für v1 klein und ausreichend, solange es nur server-/CLI-seitig genutzt wird. Bei mehreren Autoren oder Browser-Admin auf OIDC wechseln. +- **Markdown vs. rich HTML:** Markdown als gespeicherter Inhalt bleibt diff-/backup-freundlich; Ausgabe zunächst plain text bzw. strikt sanitised rendern. Kein raw HTML in v1. +- **Public cache:** CDN-/Traefik-Caching kann Updates verzögern. Bei Veröffentlichung `Cache-Control` kurz halten oder gezielt invalidieren; keine clientseitige Dauer-Cacheschicht einführen. + +## Offene Entscheidungen für den Session-Start + +1. Finaler API-Host bzw. Pfad und Zielhost. +2. Sind `Batchmaker` und `Standalone` beide öffentlich erreichbar und in welchen Repositories liegen sie? +3. Soll der öffentliche Feed vollständig anonym sein oder nur über die Produktseiten erreichbar sein? +4. Reicht CLI/`curl` als Redaktionsworkflow für v1? Default: ja. +5. Welche Pflichtfelder braucht ein Update redaktionell: nur Titel/Summary/Body oder auch Release-Version und CTA-Link? +6. Gibt es bereits einen PostgreSQL-Service, der als SSOT genutzt werden muss? Default: nein; nicht aus Gründen der vermeintlichen Zukunftssicherheit einführen. + +## Definition of Done + +- Ein eigenständiger, health-checkbarer Updates-Service läuft reproduzierbar lokal und im Ziel-Compose. +- Beide Produktwerte werden korrekt getrennt, Draft/Published/Archived sind invariant. +- Public JSON und RSS funktionieren; Admin-Schreibpfad ist authentifiziert und validiert. +- Portfolio, Batchmaker und Standalone konsumieren denselben Vertrag mit Graceful Fallback. +- SQLite liegt persistent, Backup und Restore sind real getestet. +- Lint, Build, Unit-/Integrationstests und HTTPS-Smoke-Tests sind grün. +- Keine Credentials, Produktionsdaten oder Formeln im Repository. + +## Nicht in v1 + +- Kein vollständiges CMS. +- Kein User-/Role-System. +- Keine Kommentare, Likes, Suche oder Analytics. +- Keine Webhooks/Event-Sourcing-Struktur. +- Kein PostgreSQL/Redis/Kafka ohne gemessenen Bedarf. +- Keine direkte Kopplung an Batchmaker-interne Datenbanktabellen. + +ponytail: SQLite + separater API-Service ist die kleinste belastbare Grenze. Auf PostgreSQL/OIDC migrieren, sobald Multi-Author-Redaktion, hohe Write-Last oder vorhandene DB-Governance das rechtfertigt. + +--- + +**Session handoff:** Start mit Abschnitt 1, dann Architektur-Gate vor dem ersten Code. Nach jeder implementierten Invariante Tests ausführen; Deployment erst nach Persistenz- und Traefik-Probe. + +**Constitution note:** Die geladene `batchmaker`-Constitution beschränkt Repo-Operationen auf `/home/eldov-ryzen5/workspace/Coding/Batchmaker/`; dieses Portfolio-Repository liegt außerhalb. Für die nächste Session muss die zuständige Portfolio-/CEO-Freigabe bzw. der korrekte Projektkontext geklärt werden, bevor Batchmaker-Repositories verändert werden. + +**Created:** 2026-08-16 +**Status:** Proposed +**No implementation performed.** diff --git a/docker-compose.yml b/docker-compose.yml index 52e18de..14ad0c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: # Das Portfolio (Next.js) w-make-portfolio: @@ -7,6 +5,8 @@ services: image: w-make-portfolio:latest container_name: w-make-portfolio restart: always + environment: + - UPDATES_API_URL=http://updates-api:8080 expose: - "3000" labels: @@ -21,6 +21,45 @@ services: - "traefik.http.services.w-make.loadbalancer.server.port=3000" networks: - proxy + - default + + updates-api: + build: + context: ./services/updates-api + image: w-make-updates-api:latest + container_name: w-make-updates-api + restart: unless-stopped + environment: + - PORT=8080 + - HOSTNAME=0.0.0.0 + - UPDATES_DB_PATH=/data/updates.sqlite + - UPDATES_ADMIN_TOKEN=${UPDATES_ADMIN_TOKEN:?UPDATES_ADMIN_TOKEN must be set} + volumes: + - updates_data:/data + expose: + - "8080" + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:8080/healthz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + security_opt: + - no-new-privileges:true + labels: + - "traefik.enable=true" + - "traefik.docker.network=proxy" + - "traefik.http.routers.w-make-updates.rule=Host(`updates.w-make.com`)" + - "traefik.http.routers.w-make-updates.entrypoints=websecure" + - "traefik.http.routers.w-make-updates.tls=true" + - "traefik.http.routers.w-make-updates.tls.certresolver=http_resolver" + - "traefik.http.routers.w-make-updates.middlewares=default@file,crowdsec-bouncer-plugin@file" + - "traefik.http.services.w-make-updates.loadbalancer.server.port=8080" + networks: + - proxy + - default + +volumes: + updates_data: networks: proxy: diff --git a/public/portfolio/batchmaker-recipes.jpg b/public/portfolio/batchmaker-recipes.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7591057f9cd07cc35c746307e38cd192cc7aa17a GIT binary patch literal 21307 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 Hqmlm)!ESV@ literal 0 HcmV?d00001 diff --git a/public/portfolio/batchmaker-settings.jpg b/public/portfolio/batchmaker-settings.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3409600b3b1c6b762bb9de7b0f1c40f3f9136261 GIT binary patch literal 22190 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 E00bdVasU7T literal 0 HcmV?d00001 diff --git a/public/portfolio/batchmaker-validation.jpg b/public/portfolio/batchmaker-validation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a8b68fdc035c56da6971fdc9479452fcbd1f8cee GIT binary patch literal 30731 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 literal 0 HcmV?d00001 diff --git a/services/updates-api/.env.example b/services/updates-api/.env.example new file mode 100644 index 0000000..ff2ef49 --- /dev/null +++ b/services/updates-api/.env.example @@ -0,0 +1,3 @@ +PORT=8080 +UPDATES_DB_PATH=./data/updates.sqlite +UPDATES_ADMIN_TOKEN=replace-with-a-long-random-token diff --git a/services/updates-api/Dockerfile b/services/updates-api/Dockerfile new file mode 100644 index 0000000..971eafb --- /dev/null +++ b/services/updates-api/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +COPY src ./src +RUN mkdir -p /data && chown -R node:node /app /data +USER node +ENV PORT=8080 HOSTNAME=0.0.0.0 UPDATES_DB_PATH=/data/updates.sqlite +EXPOSE 8080 +CMD ["node", "--disable-warning=ExperimentalWarning", "src/server.js"] diff --git a/services/updates-api/README.md b/services/updates-api/README.md new file mode 100644 index 0000000..1e8ba52 --- /dev/null +++ b/services/updates-api/README.md @@ -0,0 +1,53 @@ +# Updates API + +Small public update feed for W-Make Batchmaker and Batchmaker Standalone. + +## Local + +```bash +npm test +UPDATES_ADMIN_TOKEN='use-a-local-token' npm start +``` + +The service listens on `PORT` (default `8080`) and stores SQLite data at `UPDATES_DB_PATH` (default `./data/updates.sqlite`). + +## API + +Public: + +- `GET /healthz` +- `GET /v1/updates?product=batchmaker|standalone&limit=3` +- `GET /v1/updates/:slug` +- `GET /feed.xml?product=batchmaker|standalone` + +Admin requests require `Authorization: Bearer `: + +```bash +curl -X POST http://127.0.0.1:8080/v1/admin/updates \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"product":"standalone","title":"Studio update","summary":"A new calculation workflow is available."}' + +curl -X POST http://127.0.0.1:8080/v1/admin/updates/1/publish \ + -H 'Authorization: Bearer ' +``` + +Never commit the token or production data. In Docker, `/data` must be a persistent volume and `UPDATES_ADMIN_TOKEN` must be injected by the host secret/environment. + +## Operations + +Before deployment, back up the SQLite database. A restart must preserve published updates. Test restore by copying the backup to a temporary SQLite path and opening it with Node's `node:sqlite`. + +The service intentionally has no CMS, user system, comments, or direct access to Batchmaker databases in v1. + +ponytail: Keep the API contract stable; migrate to PostgreSQL/OIDC only when measured write volume or multi-author editing justifies the additional operational surface. + +## Current integration boundary + +The portfolio is wired as the first client. Batchmaker and Standalone still need their own UI client changes in their canonical repositories after the runtime/deployment URLs are confirmed. + +No production deployment is performed by this change. + +## License + +Private project. diff --git a/services/updates-api/package.json b/services/updates-api/package.json new file mode 100644 index 0000000..3324203 --- /dev/null +++ b/services/updates-api/package.json @@ -0,0 +1,10 @@ +{ + "name": "w-make-updates-api", + "private": true, + "type": "module", + "engines": { "node": ">=22.0.0" }, + "scripts": { + "start": "node --disable-warning=ExperimentalWarning src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/services/updates-api/src/server.js b/services/updates-api/src/server.js new file mode 100644 index 0000000..0236680 --- /dev/null +++ b/services/updates-api/src/server.js @@ -0,0 +1,166 @@ +import { createServer } from 'node:http'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { randomUUID } from 'node:crypto'; + +const PRODUCTS = new Set(['batchmaker', 'standalone']); +const STATUSES = new Set(['draft', 'published', 'archived']); +const MAX_BODY = 64 * 1024; + +function now() { return new Date().toISOString(); } +function json(res, status, value, headers = {}) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers }); + res.end(JSON.stringify(value)); +} +function error(res, status, code, message) { json(res, status, { error: { code, message } }); } +function validProduct(product) { return typeof product === 'string' && PRODUCTS.has(product); } +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', chunk => { + data += chunk; + if (Buffer.byteLength(data) > MAX_BODY) reject(Object.assign(new Error('body too large'), { code: 'BODY_TOO_LARGE' })); + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} +function parsePayload(raw) { + if (!raw) throw Object.assign(new Error('JSON body required'), { code: 'INVALID_JSON' }); + try { return JSON.parse(raw); } catch { throw Object.assign(new Error('valid JSON required'), { code: 'INVALID_JSON' }); } +} +function validatePayload(payload, partial = false) { + const fields = ['product', 'title', 'summary', 'body_markdown', 'link_url', 'published_at']; + const out = {}; + for (const field of fields) if (payload[field] !== undefined) out[field] = payload[field]; + if (!partial || payload.product !== undefined) { + if (!validProduct(out.product)) throw Object.assign(new Error('product must be batchmaker or standalone'), { code: 'INVALID_PRODUCT' }); + } + for (const field of ['title', 'summary']) { + if (!partial || payload[field] !== undefined) { + if (typeof out[field] !== 'string' || !out[field].trim() || out[field].length > 300) throw Object.assign(new Error(`${field} is required and limited to 300 characters`), { code: 'INVALID_FIELD' }); + out[field] = out[field].trim(); + } + } + if (out.body_markdown !== undefined && (typeof out.body_markdown !== 'string' || out.body_markdown.length > 20_000)) throw Object.assign(new Error('body_markdown must be at most 20000 characters'), { code: 'INVALID_FIELD' }); + if (out.link_url !== undefined && out.link_url !== null && (typeof out.link_url !== 'string' || !/^https?:\/\//.test(out.link_url))) throw Object.assign(new Error('link_url must be an http(s) URL'), { code: 'INVALID_FIELD' }); + if (out.published_at !== undefined && out.published_at !== null && Number.isNaN(Date.parse(out.published_at))) throw Object.assign(new Error('published_at must be an ISO date'), { code: 'INVALID_FIELD' }); + return out; +} +function auth(req, token) { return Boolean(token) && req.headers.authorization === `Bearer ${token}`; } +function row(row) { return row ? { ...row } : null; } +function xml(value) { + return String(value ?? '').replace(/[<>&'\"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '\"': '"' })[character]); +} +function rss(items, baseUrl) { + const entries = items.map(item => `${xml(item.title)}${xml(item.link_url || `${baseUrl}/v1/updates/${encodeURIComponent(item.slug)}`)}${xml(item.slug)}${new Date(item.published_at).toUTCString()}${xml(item.summary)}`).join(''); + return `W-Make Batchmaker Updates${xml(baseUrl)}Updates für Batchmaker und Batchmaker Standalone${entries}`; +} + +export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/updates.sqlite', adminToken = process.env.UPDATES_ADMIN_TOKEN || '' } = {}) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;'); + db.exec(`CREATE TABLE IF NOT EXISTS updates ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + product TEXT NOT NULL CHECK (product IN ('batchmaker', 'standalone')), + title TEXT NOT NULL, + summary TEXT NOT NULL, + body_markdown TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived')), + published_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + link_url TEXT + ); + CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`); + + function publishedFilter(product) { + return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] }; + } + function getPublic(product, limit) { + const filter = publishedFilter(product); + return db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE ${filter.sql} ORDER BY published_at DESC, id DESC LIMIT ?`).all(...filter.args, limit).map(row); + } + function getBySlug(slug) { + const f = publishedFilter(); + return row(db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE slug = ? AND ${f.sql}`).get(slug, ...f.args)); + } + function adminCreate(payload) { + const fields = validatePayload(payload); + const created = now(); + const slug = typeof payload.slug === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(payload.slug) ? payload.slug : `${fields.product}-${randomUUID()}`; + db.prepare('INSERT INTO updates (slug, product, title, summary, body_markdown, status, published_at, created_at, updated_at, link_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(slug, fields.product, fields.title, fields.summary, fields.body_markdown || '', 'draft', null, created, created, fields.link_url || null); + return row(db.prepare('SELECT * FROM updates WHERE slug = ?').get(slug)); + } + function adminPatch(id, payload) { + const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id); + if (!current) return null; + const fields = validatePayload({ product: current.product, title: current.title, summary: current.summary, ...payload }, true); + const updated = now(); + db.prepare('UPDATE updates SET product = ?, title = ?, summary = ?, body_markdown = ?, link_url = ?, updated_at = ? WHERE id = ?').run(fields.product, fields.title, fields.summary, fields.body_markdown ?? current.body_markdown, fields.link_url ?? current.link_url, updated, id); + return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id)); + } + function setStatus(id, status) { + if (!STATUSES.has(status)) throw Object.assign(new Error('invalid status'), { code: 'INVALID_STATUS' }); + const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id); + if (!current) return null; + const publishedAt = status === 'published' ? (current.published_at || now()) : current.published_at; + db.prepare('UPDATE updates SET status = ?, published_at = ?, updated_at = ? WHERE id = ?').run(status, publishedAt, now(), id); + return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id)); + } + + async function handler(req, res) { + const url = new URL(req.url, 'http://localhost'); + const path = url.pathname; + try { + if (req.method === 'GET' && path === '/healthz') return json(res, 200, { status: 'ok' }); + if (req.method === 'GET' && path === '/feed.xml') { + const product = url.searchParams.get('product') || null; + if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone'); + return new Promise(resolve => { + const items = getPublic(product, 50); + res.writeHead(200, { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=60' }); + res.end(rss(items, `${url.origin}`)); + resolve(); + }); + } + if (req.method === 'GET' && path === '/v1/updates') { + const product = url.searchParams.get('product') || null; + if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone'); + const requested = Number(url.searchParams.get('limit') || 10); + const limit = Number.isInteger(requested) && requested >= 1 && requested <= 50 ? requested : 10; + return json(res, 200, { data: getPublic(product, limit) }, { 'cache-control': 'public, max-age=60' }); + } + const slugMatch = path.match(/^\/v1\/updates\/([^/]+)$/); + if (req.method === 'GET' && slugMatch) { + const item = getBySlug(decodeURIComponent(slugMatch[1])); + return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found'); + } + if (path.startsWith('/v1/admin/') && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required'); + if (req.method === 'POST' && path === '/v1/admin/updates') return json(res, 201, { data: adminCreate(parsePayload(await readBody(req))) }); + const adminMatch = path.match(/^\/v1\/admin\/updates\/(\d+)(?:\/(publish|archive))?$/); + if (adminMatch && (req.method === 'PATCH' || req.method === 'POST')) { + const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req))); + return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found'); + } + return error(res, 404, 'NOT_FOUND', 'route not found'); + } catch (e) { + if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message); + if (e.code && ['INVALID_JSON', 'INVALID_PRODUCT', 'INVALID_FIELD', 'INVALID_STATUS'].includes(e.code)) return error(res, 400, e.code, e.message); + if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return error(res, 409, 'CONFLICT', 'slug already exists'); + console.error(e); + return error(res, 500, 'INTERNAL_ERROR', 'internal server error'); + } + } + const server = createServer(handler); + return { server, db, close: () => { db.close(); } }; +} + +if (process.argv[1] && process.argv[1].endsWith('/src/server.js')) { + const app = createApp(); + const port = Number(process.env.PORT || 8080); + app.server.listen(port, process.env.HOSTNAME || '0.0.0.0', () => console.log(`updates-api listening on ${port}`)); +} diff --git a/services/updates-api/test/updates.test.js b/services/updates-api/test/updates.test.js new file mode 100644 index 0000000..f41b7db --- /dev/null +++ b/services/updates-api/test/updates.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../src/server.js'; + +async function setup() { + const app = createApp({ dbPath: join(mkdtempSync(join(tmpdir(), 'updates-')), 'updates.sqlite'), adminToken: 'test-token' }); + await new Promise(resolve => app.server.listen(0, resolve)); + const port = app.server.address().port; + const request = (path, options = {}) => fetch(`http://127.0.0.1:${port}${path}`, options); + return { app, request }; +} +async function teardown(app) { await new Promise(resolve => app.server.close(resolve)); app.close(); } + +const headers = { 'content-type': 'application/json', authorization: 'Bearer test-token' }; +const draft = { product: 'standalone', title: 'Standalone 1', summary: 'First update', body_markdown: 'Details' }; + +test('public feed hides drafts until authenticated publish', async () => { + const { app, request } = await setup(); + try { + const created = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify(draft) }); + assert.equal(created.status, 201); + const hidden = await request('/v1/updates?product=standalone'); + assert.deepEqual((await hidden.json()).data, []); + const id = (await (await request('/v1/admin/updates?product=standalone')).json()).data; // route must remain private + assert.equal(id, undefined); + const unauthorized = await request('/v1/admin/updates/1/publish', { method: 'POST' }); + assert.equal(unauthorized.status, 401); + const published = await request('/v1/admin/updates/1/publish', { method: 'POST', headers }); + assert.equal(published.status, 200); + const visible = await request('/v1/updates?product=standalone'); + assert.equal((await visible.json()).data.length, 1); + } finally { await teardown(app); } +}); + +test('validates product and keeps health response minimal', async () => { + const { app, request } = await setup(); + try { + assert.deepEqual(await (await request('/healthz')).json(), { status: 'ok' }); + const response = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify({ ...draft, product: 'other' }) }); + assert.equal(response.status, 400); + assert.equal((await response.json()).error.code, 'INVALID_PRODUCT'); + } finally { await teardown(app); } +}); + +test('rejects oversized request bodies', async () => { + const { app, request } = await setup(); + try { + const response = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify({ ...draft, body_markdown: 'x'.repeat(70_000) }) }); + assert.equal(response.status, 413); + } finally { await teardown(app); } +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 2936630..b0ceb0c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,6 +11,29 @@ type Project = { cta: string; }; +type Update = { + slug: string; + product: "batchmaker" | "standalone"; + title: string; + summary: string; + published_at: string; +}; + +async function getUpdates(): Promise { + const baseUrl = process.env.UPDATES_API_URL || "http://updates-api:8080"; + try { + const response = await fetch(`${baseUrl}/v1/updates?limit=3`, { + next: { revalidate: 60 }, + signal: AbortSignal.timeout(1500), + }); + if (!response.ok) return []; + const payload = await response.json() as { data?: Update[] }; + return payload.data || []; + } catch { + return []; + } +} + const projects: Project[] = [ { number: "01", @@ -26,15 +49,15 @@ const projects: Project[] = [ }, { number: "02", - label: "AI Engineering · Tooling", - title: "Hermes Tools", - summary: "Werkzeuge, die mehrere AI-Agents, Projekte und technische Arbeitsabläufe zuverlässig verbinden.", - details: "Hier geht es um die unsichtbare Schicht hinter produktiver AI-Arbeit: klare Delegationsverträge, gemeinsame Kontext-Schnittstellen, Registry-Logik und nachvollziehbare Übergaben. Gute Automatisierung braucht dabei genauso klare Grenzen wie gute Fachsoftware.", - role: "Architektur, Python-Tooling und Integrationsdesign", - stack: "Python · MCP · APIs · CLI-Workflows · strukturierte Protokolle", - status: "laufendes Engineering-Projekt", - href: "#arbeitsweise", - cta: "Entscheidungen ansehen", + label: "Standalone-Produkt · Recipe Engineering", + title: "Batchmaker Studio", + summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.", + details: "Die eigenständig betreibbare Variante konzentriert sich auf Rezeptentwicklung und Berechnung — ohne den vollständigen Produktions- und Silo-Overhead von W-Make Batch.", + role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine", + stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests", + status: "eigenständig betreibbares Produkt", + href: "/projects/batchmaker-studio", + cta: "Case Study öffnen", }, { number: "03", @@ -62,7 +85,8 @@ const offers = [ ["Technische Zusammenarbeit", "Architekturentscheidungen, Integrationen und gewachsenen Code gemeinsam in wartbare Systeme überführen."], ]; -export default function Home() { +export default async function Home() { + const updates = await getUpdates(); return (
@@ -122,6 +146,54 @@ export default function Home() {
+
+
+
+

Batchmaker Studio

+

Recipe Engineering ohne Produktions-Overhead.

+
+
+

Studio fokussiert die fachliche Arbeit vor der Produktion: Rezepte entwickeln, Rohstoffanalysen verwalten, Glaschemie berechnen und Satzzettel druckfertig machen.

+

Die eigenständige Variante nutzt denselben Domänenkern, bleibt aber bewusst kleiner: eigener Express-Server, eigene SQLite-Datenbank und eine schlanke Browser-Oberfläche.

+
+
+
+ {[ + ["Rezepte", "Versionieren, vergleichen, importieren und exportieren."], + ["Glaschemie", "Oxide, Redox, Physik, Spektral- und NNLS-Berechnungen."], + ["Satzzettel", "Entwürfe, Skalierung, Vorschau und Schmelzer-Druck."], + ].map(([title, text]) => ( +
+

{title}

+

{text}

+
+ ))} +
+
+

Status: eigenständig betreibbares Produkt · Browser-Ansichten verifiziert.

+ Zur Case Study → +
+
+ +
+

Produkt-Updates

+

Was sich bei Batchmaker bewegt.

+ {updates.length ? ( +
+ {updates.map((update) => ( +
+

{update.product === "standalone" ? "Standalone" : "Batchmaker"}

+

{update.title}

+

{update.summary}

+ +
+ ))} +
+ ) : ( +

Noch keine veröffentlichten Updates. Neue Releases und fachliche Fortschritte erscheinen hier.

+ )} +
+
diff --git a/src/app/projects/batchmaker-studio/page.tsx b/src/app/projects/batchmaker-studio/page.tsx new file mode 100644 index 0000000..591026e --- /dev/null +++ b/src/app/projects/batchmaker-studio/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Batchmaker Studio — Case Study | Jan Wagner", + description: "Case Study zum eigenständig betreibbaren Recipe Studio für Glaschemie und Satzzettel.", +}; + +const screens = [ + ["Rezeptarbeitsplatz", "Rezepte, Rohstoffe und Versionen als fokussierter Arbeitsbereich.", "/portfolio/batchmaker-recipes.jpg"], + ["Validierung", "Fachliche Prüfung als eigener Schritt statt versteckter Nebenwirkung.", "/portfolio/batchmaker-validation.jpg"], + ["Konfiguration", "Redox-Grenzwerte, Kalibrierung und Oxid-Panel bleiben sichtbar steuerbar.", "/portfolio/batchmaker-settings.jpg"], +]; + +export default function BatchmakerStudioCaseStudy() { + return ( +
+
+
+ + +
+

Case Study · Standalone Product

+

Batchmaker Studio

+

Recipe Engineering für die Glasindustrie — bewusst kleiner als ein Produktionssystem, aber fachlich belastbar genug für echte Rezeptarbeit.

+
+

Rolle
Produktarchitektur und Full-Stack-Entwicklung

+

Stack
Node.js · Express · SQLite · Vanilla JS · Vite

+

Evidenz
Lokale Browser-Session verifiziert

+
+
+ +
+
+

Der Zuschnitt

+

Ein klarer fachlicher Kern statt Produktions-Overhead.

+
+
+

Batchmaker Studio konzentriert sich auf Rezepte, Rohstoffe, Glaschemie, Validierung und druckfertige Satzzettel. Silos, Schichten und laufende Produktionsbuchungen bleiben bewusst außerhalb des Produkts.

+

Diese Grenze ist eine Designentscheidung: Das Studio kann eigenständig betrieben und für Rezeptentwicklung eingesetzt werden, ohne die vollständige Betriebslogik von W-Make Batch mitzuschleppen.

+
+
+ +
+

Verifizierte Ansichten

+

Die Oberfläche zeigt den fachlichen Prozess.

+
+ {screens.map(([title, text, image]) => ( +
+ {`${title} +
+

{title}

+

{text}

+
+
+ ))} +
+
+ +
+
+

Engineering-Lektion

+

Produktgrenzen sind Teil der Architektur.

+

Ein gutes Werkzeug muss nicht den gesamten Nachbarprozess abbilden. Der kleinere Zuschnitt macht die fachliche Aufgabe verständlicher, testbarer und eigenständig betreibbar.

+
+
+ + +
+
+ ); +} \ No newline at end of file