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 0000000..7591057 Binary files /dev/null and b/public/portfolio/batchmaker-recipes.jpg differ diff --git a/public/portfolio/batchmaker-settings.jpg b/public/portfolio/batchmaker-settings.jpg new file mode 100644 index 0000000..3409600 Binary files /dev/null and b/public/portfolio/batchmaker-settings.jpg differ diff --git a/public/portfolio/batchmaker-validation.jpg b/public/portfolio/batchmaker-validation.jpg new file mode 100644 index 0000000..a8b68fd Binary files /dev/null and b/public/portfolio/batchmaker-validation.jpg differ 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