feat(portfolio): add product updates feed

This commit is contained in:
Jan Wagner
2026-08-16 01:43:16 +02:00
parent 091d0bdeec
commit 9bc9838d8d
14 changed files with 737 additions and 12 deletions
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!services/updates-api/.env.example
# vercel # vercel
.vercel .vercel
@@ -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.**
+41 -2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services: services:
# Das Portfolio (Next.js) # Das Portfolio (Next.js)
w-make-portfolio: w-make-portfolio:
@@ -7,6 +5,8 @@ services:
image: w-make-portfolio:latest image: w-make-portfolio:latest
container_name: w-make-portfolio container_name: w-make-portfolio
restart: always restart: always
environment:
- UPDATES_API_URL=http://updates-api:8080
expose: expose:
- "3000" - "3000"
labels: labels:
@@ -21,6 +21,45 @@ services:
- "traefik.http.services.w-make.loadbalancer.server.port=3000" - "traefik.http.services.w-make.loadbalancer.server.port=3000"
networks: networks:
- proxy - 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: networks:
proxy: proxy:
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+3
View File
@@ -0,0 +1,3 @@
PORT=8080
UPDATES_DB_PATH=./data/updates.sqlite
UPDATES_ADMIN_TOKEN=replace-with-a-long-random-token
+9
View File
@@ -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"]
+53
View File
@@ -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 <token>`:
```bash
curl -X POST http://127.0.0.1:8080/v1/admin/updates \
-H 'Authorization: Bearer <token>' \
-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 <token>'
```
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.
+10
View File
@@ -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"
}
}
+166
View File
@@ -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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '\"': '&quot;' })[character]);
}
function rss(items, baseUrl) {
const entries = items.map(item => `<item><title>${xml(item.title)}</title><link>${xml(item.link_url || `${baseUrl}/v1/updates/${encodeURIComponent(item.slug)}`)}</link><guid>${xml(item.slug)}</guid><pubDate>${new Date(item.published_at).toUTCString()}</pubDate><description>${xml(item.summary)}</description></item>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>W-Make Batchmaker Updates</title><link>${xml(baseUrl)}</link><description>Updates für Batchmaker und Batchmaker Standalone</description>${entries}</channel></rss>`;
}
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}`));
}
+54
View File
@@ -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); }
});
+82 -10
View File
@@ -11,6 +11,29 @@ type Project = {
cta: string; cta: string;
}; };
type Update = {
slug: string;
product: "batchmaker" | "standalone";
title: string;
summary: string;
published_at: string;
};
async function getUpdates(): Promise<Update[]> {
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[] = [ const projects: Project[] = [
{ {
number: "01", number: "01",
@@ -26,15 +49,15 @@ const projects: Project[] = [
}, },
{ {
number: "02", number: "02",
label: "AI Engineering · Tooling", label: "Standalone-Produkt · Recipe Engineering",
title: "Hermes Tools", title: "Batchmaker Studio",
summary: "Werkzeuge, die mehrere AI-Agents, Projekte und technische Arbeitsabläufe zuverlässig verbinden.", summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
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.", 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: "Architektur, Python-Tooling und Integrationsdesign", role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine",
stack: "Python · MCP · APIs · CLI-Workflows · strukturierte Protokolle", stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
status: "laufendes Engineering-Projekt", status: "eigenständig betreibbares Produkt",
href: "#arbeitsweise", href: "/projects/batchmaker-studio",
cta: "Entscheidungen ansehen", cta: "Case Study öffnen",
}, },
{ {
number: "03", number: "03",
@@ -62,7 +85,8 @@ const offers = [
["Technische Zusammenarbeit", "Architekturentscheidungen, Integrationen und gewachsenen Code gemeinsam in wartbare Systeme überführen."], ["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 ( return (
<main className="min-h-screen bg-grid-pattern"> <main className="min-h-screen bg-grid-pattern">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
@@ -122,6 +146,54 @@ export default function Home() {
</div> </div>
</section> </section>
<section id="batchmaker-studio" className="scroll-mt-8 border-t border-slate-800 py-20">
<div className="grid gap-10 md:grid-cols-[0.75fr_1.25fr] md:items-start">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Batchmaker Studio</p>
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Recipe Engineering ohne Produktions-Overhead.</h2>
</div>
<div className="grid gap-6 text-sm leading-7 text-slate-400 md:grid-cols-2">
<p>Studio fokussiert die fachliche Arbeit vor der Produktion: Rezepte entwickeln, Rohstoffanalysen verwalten, Glaschemie berechnen und Satzzettel druckfertig machen.</p>
<p>Die eigenständige Variante nutzt denselben Domänenkern, bleibt aber bewusst kleiner: eigener Express-Server, eigene SQLite-Datenbank und eine schlanke Browser-Oberfläche.</p>
</div>
</div>
<div className="mt-10 grid gap-5 md:grid-cols-3">
{[
["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]) => (
<div key={title} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
<h3 className="text-lg font-medium text-white">{title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-400">{text}</p>
</div>
))}
</div>
<div className="mt-8 flex flex-wrap items-center gap-5 text-xs text-slate-500">
<p>Status: eigenständig betreibbares Produkt · Browser-Ansichten verifiziert.</p>
<a href="/projects/batchmaker-studio" className="text-primary hover:text-cyan-300">Zur Case Study </a>
</div>
</section>
<section id="updates" className="scroll-mt-8 border-t border-slate-800 py-20">
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Produkt-Updates</p>
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Was sich bei Batchmaker bewegt.</h2>
{updates.length ? (
<div className="mt-10 grid gap-5 md:grid-cols-3">
{updates.map((update) => (
<article key={update.slug} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
<p className="font-mono text-xs uppercase tracking-[0.16em] text-primary">{update.product === "standalone" ? "Standalone" : "Batchmaker"}</p>
<h3 className="mt-4 text-xl font-medium text-white">{update.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-400">{update.summary}</p>
<time className="mt-5 block text-xs text-slate-600" dateTime={update.published_at}>{new Date(update.published_at).toLocaleDateString("de-DE")}</time>
</article>
))}
</div>
) : (
<p className="mt-8 max-w-xl text-sm leading-6 text-slate-500">Noch keine veröffentlichten Updates. Neue Releases und fachliche Fortschritte erscheinen hier.</p>
)}
</section>
<section id="arbeitsweise" className="scroll-mt-8 border-t border-slate-800 py-20"> <section id="arbeitsweise" className="scroll-mt-8 border-t border-slate-800 py-20">
<div className="grid gap-12 md:grid-cols-[0.8fr_1.2fr]"> <div className="grid gap-12 md:grid-cols-[0.8fr_1.2fr]">
<div> <div>
@@ -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 (
<main className="min-h-screen bg-grid-pattern">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-8 md:px-10 md:py-12">
<nav className="flex items-center justify-between border-b border-slate-800/80 pb-6" aria-label="Hauptnavigation">
<Link href="/" className="font-mono text-sm tracking-[0.22em] text-primary">W-MAKE</Link>
<Link href="/#projekte" className="text-sm text-slate-400 hover:text-white"> Projekte</Link>
</nav>
<section className="max-w-4xl py-20 md:py-28">
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Case Study · Standalone Product</p>
<h1 className="mt-5 text-5xl font-semibold tracking-tight text-white md:text-7xl">Batchmaker Studio</h1>
<p className="mt-8 max-w-3xl text-xl leading-9 text-slate-300">Recipe Engineering für die Glasindustrie bewusst kleiner als ein Produktionssystem, aber fachlich belastbar genug für echte Rezeptarbeit.</p>
<div className="mt-10 grid gap-6 border-y border-slate-800 py-6 text-sm md:grid-cols-3">
<p><span className="font-mono text-xs uppercase text-slate-600">Rolle</span><br /><span className="text-slate-300">Produktarchitektur und Full-Stack-Entwicklung</span></p>
<p><span className="font-mono text-xs uppercase text-slate-600">Stack</span><br /><span className="text-slate-300">Node.js · Express · SQLite · Vanilla JS · Vite</span></p>
<p><span className="font-mono text-xs uppercase text-slate-600">Evidenz</span><br /><span className="text-primary/80">Lokale Browser-Session verifiziert</span></p>
</div>
</section>
<section className="grid gap-10 border-t border-slate-800 py-20 md:grid-cols-2">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Der Zuschnitt</p>
<h2 className="mt-3 text-3xl font-semibold text-white">Ein klarer fachlicher Kern statt Produktions-Overhead.</h2>
</div>
<div className="space-y-5 leading-7 text-slate-400">
<p>Batchmaker Studio konzentriert sich auf Rezepte, Rohstoffe, Glaschemie, Validierung und druckfertige Satzzettel. Silos, Schichten und laufende Produktionsbuchungen bleiben bewusst außerhalb des Produkts.</p>
<p>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.</p>
</div>
</section>
<section className="border-t border-slate-800 py-20">
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Verifizierte Ansichten</p>
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Die Oberfläche zeigt den fachlichen Prozess.</h2>
<div className="mt-10 grid gap-8">
{screens.map(([title, text, image]) => (
<figure key={title} className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/70">
<Image src={image} alt={`${title} im Batchmaker Studio`} width={948} height={403} className="block h-auto w-full" />
<figcaption className="grid gap-2 p-6 md:grid-cols-[0.4fr_1fr]">
<h3 className="text-xl font-medium text-white">{title}</h3>
<p className="text-sm leading-6 text-slate-400">{text}</p>
</figcaption>
</figure>
))}
</div>
</section>
<section className="border-t border-slate-800 py-20">
<div className="rounded-3xl border border-primary/30 bg-primary/5 p-8 md:p-12">
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Engineering-Lektion</p>
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white md:text-5xl">Produktgrenzen sind Teil der Architektur.</h2>
<p className="mt-6 max-w-3xl leading-7 text-slate-300">Ein gutes Werkzeug muss nicht den gesamten Nachbarprozess abbilden. Der kleinere Zuschnitt macht die fachliche Aufgabe verständlicher, testbarer und eigenständig betreibbar.</p>
</div>
</section>
<footer className="flex flex-wrap justify-between gap-4 border-t border-slate-800 py-8 text-xs text-slate-500">
<Link href="/" className="hover:text-white">Jan Wagner · W-MAKE</Link>
<span>Software Engineering · Glass Technology · Systems Thinking</span>
</footer>
</div>
</main>
);
}