diff --git a/.env.example b/.env.example index a8b1b0a..50cdb9e 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,16 @@ # Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen # beim Hoster (Live-Betrieb). Echte Werte niemals committen! -# Verbindung zur (selbst gehosteten) PostgreSQL-Datenbank. +# Nur für Docker Compose relevant: Passwort für den mitgelieferten +# PostgreSQL-Container (Dienst "db" in docker-compose.yml). Daraus baut +# docker-compose.yml die DATABASE_URL für die App-Container selbst +# zusammen – die Zeile unten wird beim Betrieb per Docker Compose +# NICHT verwendet, nur bei manuellem "npm run dev"/"npm run start" +# gegen eine selbst betriebene PostgreSQL-Instanz. +LOCAL_PG_PASSWORD= + +# Verbindung zur PostgreSQL-Datenbank (nur für den Betrieb OHNE Docker +# Compose relevant, s. o.). DATABASE_URL=postgres://postgres:@localhost:5432/canteen_menu # Öffentliche URL, unter der die App erreichbar ist (Schema + Domain). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b79f83..781b2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.6.0] – 2026-07-18 + +### Hinzugefügt +- RSS-Feed des Speiseplans (`/feed.xml`, ein Eintrag pro Tag mit den + Gerichten je Essenszeit für die aktuelle und die nächste Woche) zum + Abonnieren in einem Feedreader. Autodiscovery-Link im Seiten-`` + sowie sichtbares RSS-Icon im Kopfbereich der öffentlichen + Speisekarte. +- Kantinen-Bereich: „Vorschläge“-Navigationspunkt zeigt jetzt die + Anzahl offener Rezeptvorschläge als Zahl im roten Kreis. +- Neues [DEPLOYMENT.md](DEPLOYMENT.md): Schritt-für-Schritt-Anleitung + für die Inbetriebnahme auf einem neuen Server (Docker Compose, + Konfiguration, Reverse-Proxy/HTTPS, Updates, Backup). + +### Geändert +- Letzte Erwähnungen von Supabase in aktiven Doku-Texten und + Code-Kommentaren entfernt/umformuliert (README, Migrationsschema, + Passkey-Konfiguration, Branding-Ablage) – die App läuft seit v1.5 + vollständig ohne Supabase, das sollte sich jetzt auch durchgängig in + der Dokumentation widerspiegeln. Historische CHANGELOG-Einträge + bleiben unverändert. +- `.env.example`: `LOCAL_PG_PASSWORD` (von `docker-compose.yml` + verwendet, um die Datenbank-Verbindung der App-Container + zusammenzubauen) war bisher nirgends dokumentiert – beim Schreiben + der Deployment-Anleitung aufgefallen und ergänzt. + ## [1.5.0] – 2026-07-18 ### Hinzugefügt diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..ab90368 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,165 @@ +# Deployment auf einem neuen Server + +Schritt-für-Schritt-Anleitung, um Canteen Menu auf einem frischen +(Linux-)Server produktiv zu betreiben. Die App läuft vollständig selbst +gehostet (PostgreSQL + eigene Auth), einzige optionale externe +Abhängigkeit ist DeepL für automatische Übersetzungen. + +## Voraussetzungen + +- Ein Server (VM oder Root-Server) mit Docker und Docker Compose v2 + (`docker compose version` sollte funktionieren; auf den meisten + aktuellen Distributionen per `apt install docker.io + docker-compose-v2` bzw. dem [offiziellen Docker-Install-Skript](https://docs.docker.com/engine/install/) + installierbar). +- Eine Domain, deren DNS-A/AAAA-Eintrag auf die Server-IP zeigt (z. B. + `menu.example.de`). Ohne eigene Domain funktioniert die App auch + unter der reinen Server-IP, dann aber ohne gültiges HTTPS-Zertifikat + und ohne funktionierende Passkeys (siehe unten). +- Mindestens 1 GB RAM frei (App + PostgreSQL sind genügsam, für ~250 + Mitarbeiter reicht die kleinste übliche VM-Größe). + +## 1. Repository auf den Server holen + +```bash +git clone https://github.com//canteen-menu.git +cd canteen-menu +``` + +(Oder per `git pull`/`scp`, falls kein direkter Git-Zugriff vom Server +aus gewünscht ist.) + +## 2. Konfiguration anlegen + +```bash +cp .env.example .env.local +``` + +`.env.local` mit einem Editor öffnen und ausfüllen: + +| Variable | Was rein muss | +| --- | --- | +| `LOCAL_PG_PASSWORD` | Ein neues, zufälliges Passwort (z. B. `openssl rand -base64 24`) – wird beim ersten Start zum Datenbank-Passwort des mitgelieferten PostgreSQL-Containers | +| `APP_URL` | Die öffentliche URL mit Schema, z. B. `https://menu.example.de` (auch wenn HTTPS erst in Schritt 5 eingerichtet wird – schon jetzt korrekt eintragen, da daraus u. a. die Passkey-Domain abgeleitet wird) | +| `SITE_TITLE` | Optional, Titel im Browser-Tab/Überschrift ohne Logo | +| `ADMIN_EMAIL` | Optional, E-Mail des automatisch angelegten Admin-Erstnutzers. Ohne Angabe: `admin@` | +| `DEEPL_API_KEY` | Optional, nur für automatische Übersetzung | + +`DATABASE_URL` in `.env.local` **nicht** anpassen – die wird nur für +den Betrieb ohne Docker gebraucht (siehe README) und beim +Docker-Compose-Start ignoriert. + +Zusätzlich eine leere Datei für die automatisch generierten +Admin-Zugangsdaten anlegen, da sie per Bind-Mount in den Container +eingebunden wird: + +```bash +touch ZUGANGSDATEN.md +``` + +## 3. App starten + +```bash +docker compose --env-file .env.local up -d --build +``` + +Das baut das Docker-Image, startet PostgreSQL und die App, wendet beim +ersten Start automatisch das Datenbankschema an und legt – da die +Datenbank leer ist – einen einzigen Admin-Benutzer mit zufällig +generiertem Passwort an. + +Kurz prüfen, ob beides läuft: + +```bash +docker compose ps +docker compose logs app --tail 30 +``` + +In den Logs sollte u. a. `bootstrap-admin – Admin angelegt: ...` +stehen. + +## 4. Admin-Zugangsdaten abholen + +```bash +cat ZUGANGSDATEN.md +``` + +Enthält E-Mail-Adresse und Passwort des automatisch angelegten +Admin-Kontos. Direkt nach dem ersten Login unter „Mein Konto“ das +Passwort ändern und optional einen Passkey registrieren. + +Die App ist jetzt unter `http://:3000` erreichbar – für den +produktiven Betrieb fehlt noch HTTPS/die Domain (nächster Schritt). + +## 5. Reverse-Proxy + HTTPS einrichten + +Die App selbst spricht nur HTTP auf Port 3000; TLS/HTTPS übernimmt ein +Reverse-Proxy davor. Am einfachsten mit [Caddy](https://caddyserver.com/) +(automatisches Let’s-Encrypt-Zertifikat, keine manuelle Zertifikats- +verwaltung nötig): + +```bash +sudo apt install caddy # oder: siehe caddyserver.com/docs/install +``` + +`/etc/caddy/Caddyfile`: + +``` +menu.example.de { + reverse_proxy localhost:3000 +} +``` + +```bash +sudo systemctl reload caddy +``` + +Caddy holt beim ersten Aufruf automatisch ein Zertifikat und leitet +danach HTTP→HTTPS um. Alternative: nginx + certbot, falls bereits ein +nginx-Setup existiert (Standard-`proxy_pass`-Konfiguration auf +`http://127.0.0.1:3000`, Zertifikat separat per `certbot --nginx`). + +Danach in `.env.local` sicherstellen, dass `APP_URL=https://menu.example.de` +gesetzt ist (Schema `https`, keine Portangabe), und die App neu +starten, falls diese Variable nach Schritt 2 noch geändert wurde: + +```bash +docker compose --env-file .env.local up -d +``` + +## 6. Danach + +- Passkeys funktionieren erst mit korrekt gesetzter `APP_URL` (RP-Domain + wird direkt daraus abgeleitet) und über HTTPS (Passkeys verlangen eine + sichere Origin, `localhost` ausgenommen). +- Weitere Kantinenmitarbeiter/Admins unter „Benutzer“ (nur als Admin + sichtbar) anlegen. +- Essenszeiten, Gerichte, Allergene/Zusatzstoffe sind zu Beginn leer und + werden im Kantinen-Bereich gepflegt. +- Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen. + +## Updates einspielen + +```bash +git pull +docker compose --env-file .env.local up -d --build +``` + +Neue Datenbank-Migrationen (`db/migrations/*.sql`) werden beim Start +automatisch und idempotent angewendet, bestehende Daten bleiben +erhalten. + +## Backup + +Zwei Docker-Volumes enthalten alle persistenten Daten: + +- `canteen-menu_pgdata` – die komplette PostgreSQL-Datenbank +- `canteen-menu_branding_data` – hochgeladenes Logo/Favicon + +```bash +docker run --rm -v canteen-menu_pgdata:/data -v "$PWD":/backup alpine \ + tar czf /backup/pgdata-backup.tar.gz -C /data . +``` + +Für ein reines Datenbank-Backup eignet sich auch `docker compose exec db +pg_dump -U postgres canteen_menu > backup.sql`. diff --git a/README.md b/README.md index 6cb6253..69d4095 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,16 @@ DeepL als einzige externe API (optional). Läuft vollständig selbst gehostet, keine Cloud-Abhängigkeit außer optional DeepL. Änderungen je Version stehen in [CHANGELOG.md](CHANGELOG.md), Git-Tags -markieren die jeweiligen Versionsstände (`git tag`). +markieren die jeweiligen Versionsstände (`git tag`). Schritt-für-Schritt- +Anleitung für die Inbetriebnahme auf einem neuen Server: +[DEPLOYMENT.md](DEPLOYMENT.md). ## Funktionen | Bereich | Funktionen | | --- | --- | -| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen | -| Kantinen-Team (`/intern`) | Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) | +| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen; RSS-Feed (`/feed.xml`, Icon im Kopfbereich) mit einem Eintrag pro Tag zum Abonnieren | +| Kantinen-Team (`/intern`) | Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte per Suche Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen (Anzahl offener Vorschläge als Badge in der Navigation); eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) | | Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen | Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey** @@ -37,6 +39,7 @@ mit optionaler automatischer Übersetzung über DeepL (siehe ``` app/ Next.js-Routen page.tsx Öffentliche Speisekarte + feed.xml/ RSS-Feed des Speiseplans (öffentlich) login/ Login (Passwort + Passkey) vorschlag/ Öffentliches Rezeptvorschlag-Formular intern/ Kantinen-Bereich (Rolle: staff oder admin) @@ -131,7 +134,7 @@ müssen neu registriert werden. ## Datenbank -Reines PostgreSQL (kein Supabase, keine Row-Level-Security-Policies) – +Reines, selbst gehostetes PostgreSQL (keine Row-Level-Security-Policies) – alle Zugriffsrechte werden in den Route-Handlern geprüft (`lib/auth/session.ts`, `requireRole`). diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts new file mode 100644 index 0000000..7e36011 --- /dev/null +++ b/app/feed.xml/route.ts @@ -0,0 +1,106 @@ +import { query } from "@/lib/db"; +import { loadMenuEntries } from "@/lib/db/menu-entries"; +import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu"; +import { localized, SITE_TITLE } from "@/lib/types"; +import type { Section } from "@/lib/types"; + +// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten, +// damit man ihn in einem Feedreader abonnieren kann und täglich sieht, +// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie, +// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default. +export const dynamic = "force-dynamic"; + +function escapeXml(text: string): string { + return text.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function formatFeedDate(iso: string): string { + const d = new Date(`${iso}T00:00:00Z`); + return d.toLocaleDateString("de-DE", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }); +} + +export async function GET() { + const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); + const monday = mondayOf(todayISO()); + // Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per + // Wochennavigation anzeigt. + const rangeEnd = addDays(monday, 11); + + const [sections, entries] = await Promise.all([ + query
("select * from sections order by sort"), + loadMenuEntries(monday, rangeEnd), + ]); + + const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort(); + + const items = days + .map((iso) => { + const dayContent = sections + .map((s) => { + const dishes = entriesFor(entries, iso, s.key); + if (dishes.length === 0) return ""; + const list = dishes + .map( + (e) => + `
  • ${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}
  • ` + ) + .join(""); + return `

    ${escapeXml(localized(s, "de"))} (${escapeXml( + s.time_range + )})

      ${list}
    `; + }) + .join(""); + if (!dayContent) return ""; + + const [y, m, d] = iso.split("-").map(Number); + const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString(); + + return ` + ${escapeXml(formatFeedDate(iso))} + ${appUrl}/?woche=${mondayOf(iso)} + ${appUrl}/feed.xml#${iso} + ${pubDate} + + `; + }) + .join("\n"); + + const xml = ` + + + ${escapeXml(SITE_TITLE)} – Speiseplan + ${appUrl} + Täglicher Speiseplan der Kantine + de + ${new Date().toUTCString()} +${items} + + +`; + + return new Response(xml, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=1800", + }, + }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 171e108..f90916a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,6 +25,14 @@ export async function generateMetadata(): Promise { // einem eigenen Favicon eingebunden (Next.js kombiniert Datei- // Konvention und Metadata statt sie zu ersetzen). icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" }, + // Autodiscovery für Feedreader (Browser zeigen dafür kein eigenes + // Icon mehr an, daher zusätzlich ein sichtbares RSS-Icon im Header + // der öffentlichen Speisekarte, s. app/page.tsx). + alternates: { + types: { + "application/rss+xml": [{ url: "/feed.xml", title: `${SITE_TITLE} – Speiseplan` }], + }, + }, }; } diff --git a/app/page.tsx b/app/page.tsx index 1948bc4..afd70d9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -56,6 +56,25 @@ export default async function Home({
    + + + { + fetch("/api/dish-suggestions") + .then((res) => (res.ok ? res.json() : [])) + .then((data) => setSuggestionCount(Array.isArray(data) ? data.length : 0)) + .catch(() => {}); + }, [pathname]); const menuLinks = [ { href: "/intern", label: dict.header.weekPlan }, @@ -51,8 +61,18 @@ export default function AppHeader({ {dict.header.menuGroup} {menuLinks.map((l) => ( - + {l.label} + {l.href === "/intern/vorschlaege" && suggestionCount > 0 && ( + + {suggestionCount} + + )} ))} diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql index d300752..94585f8 100644 --- a/db/migrations/0001_init.sql +++ b/db/migrations/0001_init.sql @@ -1,5 +1,5 @@ -- ============================================================= --- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase) +-- Canteen Menu – Initiales Schema (vollständig selbst gehostet) -- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine) -- Öffentliche Speisekarte: lesbar ohne Login -- ============================================================= @@ -27,8 +27,8 @@ create table sessions ( ); create index sessions_user_id_idx on sessions (user_id); --- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher --- unsichtbares Passkey-Schema). +-- WebAuthn-Credentials (Passkeys) – eigenes Schema, wird von +-- @simplewebauthn/server geprüft (lib/auth/). create table passkey_credentials ( id uuid primary key default gen_random_uuid(), user_id uuid not null references users (id) on delete cascade, diff --git a/lib/auth/webauthn-config.ts b/lib/auth/webauthn-config.ts index c8fb7c7..62e68d9 100644 --- a/lib/auth/webauthn-config.ts +++ b/lib/auth/webauthn-config.ts @@ -1,9 +1,7 @@ import "server-only"; -// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein -// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige -// scripts/configure-passkeys.mjs, das die Supabase-Management-API -// aufrief). +// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – +// kein externer Konfigurationsschritt oder Cloud-API-Aufruf mehr nötig. function appUrl(): URL { return new URL(process.env.APP_URL ?? "http://localhost:3000"); } diff --git a/lib/branding.ts b/lib/branding.ts index ed7bfa6..1e77a84 100644 --- a/lib/branding.ts +++ b/lib/branding.ts @@ -2,8 +2,8 @@ import "server-only"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -// Ersetzt den früheren Supabase-Storage-Bucket "branding" durch ein -// lokales Verzeichnis (Docker-Volume in Produktion, s. docker-compose.yml). +// Logo/Favicon liegen als Dateien in einem lokalen Verzeichnis +// (Docker-Volume in Produktion, s. docker-compose.yml). export function brandingDir(): string { // turbopackIgnore: process.cwd() ist hier dynamisch, aber der Pfad // bleibt statisch unter "data/branding" – ohne den Kommentar versucht diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index d8c81a9..a6f0d2b 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -37,6 +37,7 @@ const de = { users: "Benutzer", design: "Design", suggestions: "Vorschläge", + openSuggestions: "{count} offene Rezeptvorschläge", viewMenu: "Speisekarte ansehen", myAccount: "Mein Konto", }, @@ -64,6 +65,7 @@ const de = { additives: "Zusatzstoffe", suggestDish: "Rezept vorschlagen", noEntry: "–", + rssFeed: "RSS-Feed abonnieren", }, weekEditor: { title: "Wochenplan", @@ -291,6 +293,7 @@ const en: Dict = { users: "Users", design: "Design", suggestions: "Suggestions", + openSuggestions: "{count} open dish suggestions", viewMenu: "View menu", myAccount: "My account", }, @@ -318,6 +321,7 @@ const en: Dict = { additives: "Additives", suggestDish: "Suggest a dish", noEntry: "–", + rssFeed: "Subscribe to RSS feed", }, weekEditor: { title: "Weekly plan", @@ -543,6 +547,7 @@ const pl: Dict = { users: "Użytkownicy", design: "Wygląd", suggestions: "Propozycje", + openSuggestions: "{count} otwartych propozycji dań", viewMenu: "Zobacz jadłospis", myAccount: "Moje konto", }, @@ -570,6 +575,7 @@ const pl: Dict = { additives: "Dodatki", suggestDish: "Zaproponuj danie", noEntry: "–", + rssFeed: "Subskrybuj kanał RSS", }, weekEditor: { title: "Plan tygodnia", diff --git a/package-lock.json b/package-lock.json index dc57df9..4befdbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "canteen-menu", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "canteen-menu", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", diff --git a/package.json b/package.json index 99e1cee..674e8ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canteen-menu", - "version": "1.5.0", + "version": "1.6.0", "private": true, "scripts": { "predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",