From 86fbd8e4bbb16caf2c17f32e107c84fe1cd5cab3 Mon Sep 17 00:00:00 2001 From: LordSchmackes Date: Thu, 18 Jun 2026 09:53:39 +0200 Subject: [PATCH] Remove static JSON files and seed step; migrate site settings to Postgres All content (recipes + imprint/privacy settings) now lives exclusively in Postgres. The app no longer requires a seed step on first deploy. Changes: - helpers.php: load_site_settings() returns defaults when site_settings table is empty instead of throwing DatabaseUnavailableException; removes legacy JSONB migration path - data/recipes.json, data/site.json: deleted (content already in DB) - scripts/db-seed.php: deleted (no longer needed) - docker-compose.yml: remove RUN_DB_SEED env var and data/ volume mount - docker/entrypoint.sh: remove RUN_DB_SEED seed block - docs/COOLIFY.md: update deployment guide to reflect seedless workflow - .claude/launch.json: add dev server configurations for preview tool Fresh deploys now start with placeholder legal pages and an empty recipe list; the admin fills in real content via /admin.php without any CLI step. Co-Authored-By: Claude Sonnet 4.6 --- .agents/TODO.md | 14 +-- .agents/brain.md | 14 +-- .claude/launch.json | 17 +++ .env.example | 2 +- Dockerfile | 4 +- README.md | 27 +++-- admin.php | 5 +- data/recipes.json | 264 ------------------------------------------- data/site.json | 54 --------- datenschutz.php | 6 +- docker-compose.yml | 10 +- docker/entrypoint.sh | 5 - docs/COOLIFY.md | 56 +++------ helpers.php | 125 ++++++++++---------- impressum.php | 6 +- index.php | 1 + login.php | 1 + recipe.php | 1 + scripts/db-seed.php | 43 ------- scripts/schema.sql | 11 ++ 20 files changed, 154 insertions(+), 512 deletions(-) create mode 100644 .claude/launch.json delete mode 100644 data/recipes.json delete mode 100644 data/site.json delete mode 100644 scripts/db-seed.php diff --git a/.agents/TODO.md b/.agents/TODO.md index 2e7c01c..6e3173c 100644 --- a/.agents/TODO.md +++ b/.agents/TODO.md @@ -30,8 +30,8 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab - Mikro-Animationen & Quick-Save Funktion - [x] **Phase 6: PostgreSQL Datenbank** - `DATABASE_URL` in `.env`, Docker Compose für lokale Dev-DB - - `init_db()` / `save_recipes()` in `helpers.php` - - Fallback auf `data/recipes.json` + - `init_db()` / `save_recipe()` / `save_site_settings()` in `helpers.php` + - Kein JSON-Fallback; Rezepte und Site-Daten liegen in Postgres --- @@ -82,7 +82,7 @@ Favoriten, Ernährungsziel und Newsletter ohne Cloud-Backend. Jedes Rezept erhält präzise Makronährstoffe (Kalorien, Proteine, Kohlenhydrate, Fette), die vom Admin gepflegt und dem Nutzer edel präsentiert werden. - [x] **Datenmodell & Admin-Panel Erweiterung** - - [x] `data/recipes.json` Struktur anpassen: Hinzufügen von `nutrition` (`calories`, `protein`, `carbs`, `fat`) + - [x] Rezeptstruktur um `nutrition` (`calories`, `protein`, `carbs`, `fat`) erweitern - [x] `admin.php` Formular erweitern: - [x] Eingabefeld für Kalorien (kcal) - [x] Eingabefeld für Eiweiß / Protein (g) @@ -101,7 +101,7 @@ Jedes Rezept erhält präzise Makronährstoffe (Kalorien, Proteine, Kohlenhydrat Ein immersiver Kochmodus, der Anwendern Schritt-für-Schritt durch die Zubereitung führt – inklusive Videoanleitungen und integrierten Timern. - [x] **Video-Verknüpfung im Admin-Panel** - - [x] Rezeptstruktur in `data/recipes.json` erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (externe MP4-URL) besitzen kann + - [x] Rezeptstruktur erweitern, damit jeder Arbeitsschritt (`steps`) eine optionale `video_url` (externe MP4-URL) besitzen kann - [x] `admin.php` erweitern, um Video-URLs pro Einzelschritt einzugeben - [x] **Fullscreen Cooking-Mode Overlay** - [x] Trigger-Button "Kochmodus starten" (`CapitoliumRevealButton` Stil) auf `recipe.php` einbauen @@ -134,13 +134,13 @@ Ein hochgradig interaktives, touch-freundliches Karussell direkt auf der Startse --- ### 🔥 Phase 6: PostgreSQL Datenbank -Rezepte in Postgres (JSONB), optional lokal per Docker. +Rezepte und Site-Daten in normalisierten Postgres-Tabellen, optional lokal per Docker. - [x] **`DATABASE_URL` & Docker Compose** - [x] `docker-compose.dev.yml` für lokale Postgres-Instanz - - [x] `init_db()` legt Tabelle `recipes` an und seedet aus `recipes.json` + - [x] `init_db()` legt Tabellen an; `scripts/db-seed.php` seedet aus `scripts/seed-data.php` - [x] **PHP-Datenzugriff** - - [x] `load_recipes()` / `save_recipes()` mit JSON-Fallback + - [x] `load_recipes()` / `save_recipe()` ohne JSON-Fallback - [x] `load_recipe_by_slug()` in `recipe.php` --- diff --git a/.agents/brain.md b/.agents/brain.md index 9011b1e..e362d29 100644 --- a/.agents/brain.md +++ b/.agents/brain.md @@ -4,8 +4,8 @@ This document summarizes architectural knowledge, conventions, and learnings for ## 1. Project Architecture & Stack - **Backend:** Vanilla PHP. No heavy frameworks. -- **Database:** PostgreSQL required (`DATABASE_URL` in `.env`). Normalized tables in `scripts/schema.sql`; `load_recipes()` / `save_recipe()` in `helpers.php`. No runtime JSON recipe file. One-time import: `php scripts/db-seed.php` from `data/recipes.json`. -- **Site settings (legal pages):** Flat-file `data/site.json` via `load_site_settings()` / `save_site_settings()` — not in Postgres. +- **Database:** PostgreSQL required (`DATABASE_URL` in `.env`). Normalized tables in `scripts/schema.sql`; `load_recipes()` / `save_recipe()` and `load_site_settings()` / `save_site_settings()` in `helpers.php`. No runtime JSON files. One-time import: `php scripts/db-seed.php` from `scripts/seed-data.php`. +- **Site settings (legal pages):** Stored in Postgres table `site_settings`; no flat-file fallback. - **Admin Panel (`admin.php`):** Lightweight CMS. Textareas use one line per array element (`ingredients`, `steps`, `step_videos`, `step_timers`). - **Frontend:** Server-rendered PHP (`index.php`, `recipe.php`, …), Vanilla JS/CSS. Profile/favorites via `assets/fc-local.js`. - **Config:** `config.php` loads `.env`, `get_db_connection()`. Never commit `.env` (see `.gitignore`). @@ -27,13 +27,14 @@ This document summarizes architectural knowledge, conventions, and learnings for ## 5. PostgreSQL — Schema & Data Flow ### Relational schema (`scripts/schema.sql`) -Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB `recipes.data` is migrated once automatically. +Created by `ensure_recipe_schema()` on first DB use. Legacy JSONB/file fallbacks are not supported. | Table | Role | |-------|------| | `recipes` | slug, hero, times, servings, nutrition columns, featured, coming_soon | | `recipe_translations` | title, description, category, difficulty (en/de) | | `recipe_tags`, `recipe_ingredients`, `recipe_utensils`, `recipe_steps` | Ordered lists per language | +| `site_settings` | imprint/privacy fields per language | PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate_recipes_from_db()`. @@ -41,7 +42,7 @@ PHP still exposes the same nested arrays (`i18n`, `nutrition`, …) via `hydrate 1. `DATABASE_URL` required → `require_database()` or HTTP 503 (`maintenance/db-unavailable.php`). 2. `load_recipes()` → SQL → PHP arrays for templates. 3. Admin: `save_recipe()`, `delete_recipe()`, `clear_featured_recipes()`. -4. One-time import: `php scripts/db-seed.php` from `data/recipes.json` (not read at runtime). +4. One-time import: `php scripts/db-seed.php` from `scripts/seed-data.php`. ### `config.php` functions - `load_env()` — parses `.env`. @@ -123,9 +124,8 @@ See `.agents/TODO.md` (e.g. PWA & offline support). README `Local Postgres (Dock | File | Purpose | |------|---------| | `config.php` | `.env`, Firebase config, PDO | -| `helpers.php` | `init_db`, `load_recipes`, `save_recipes`, site settings | -| `data/recipes.json` | Seed + fallback + admin backup | -| `data/site.json` | Imprint/privacy settings | +| `helpers.php` | `init_db`, `load_recipes`, `save_recipe`, site settings | +| `scripts/seed-data.php` | Seed arrays for recipes and site settings | | `docker-compose.dev.yml` | Local Postgres | | `scripts/db-check.php` | Connection + seed smoke test | | `partials/head.php` | Firebase init via `get_firebase_config()` | diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..e068aca --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "Dev DB (Postgres only)", + "runtimeExecutable": "docker", + "runtimeArgs": ["compose", "-f", "docker-compose.dev.yml", "up"], + "port": 5432 + }, + { + "name": "Full stack (PHP app + Postgres)", + "runtimeExecutable": "docker", + "runtimeArgs": ["compose", "up", "--build"], + "port": 8080 + } + ] +} diff --git a/.env.example b/.env.example index 8ec31d8..654b8bf 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ DATABASE_URL="postgresql://flixcooks:flixcooks_dev@127.0.0.1:5432/flixcooks_dev" # --- Production / Coolify (im Dashboard setzen, nicht committen) --- # DATABASE_URL="postgresql://user:pass@postgresql-service:5432/flixcooks" # FLIXCOOKS_ADMIN_KEY="langes-zufaelliges-passwort" -# RUN_DB_SEED="true" # nur beim allerersten Deploy, danach entfernen +# RUN_DB_SEED="true" # nur beim allerersten Deploy: Rezepte + Site-Daten in Postgres seeden diff --git a/Dockerfile b/Dockerfile index 32682a1..16f3436 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,7 @@ WORKDIR /var/www/html COPY --chown=www-data:www-data . /var/www/html RUN sed -i 's/\r$//' /var/www/html/docker/entrypoint.sh \ - && chmod +x /var/www/html/docker/entrypoint.sh \ - && mkdir -p /var/www/html/data \ - && chown -R www-data:www-data /var/www/html/data + && chmod +x /var/www/html/docker/entrypoint.sh EXPOSE 80 diff --git a/README.md b/README.md index 3cb670b..9735bac 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ The project is architected to remain extremely lightweight and fast, intentional - `login.php`: Local profile page for dietary goals and saved favorites (browser storage). - **Support & Layouts**: - `partials/`: Contains modular templates (`head.php`, `header.php`, `footer.php`) to maintain a clean DRY structure. - - `helpers.php`: Core PHP utilities and the PostgreSQL data access layer for recipes. + - `helpers.php`: Core PHP utilities and the PostgreSQL data access layer for recipes and site settings. - `config.php`: Environment-independent configuration loader which reads runtime secrets from `.env`. - `assets/fc-local.js`: Browser-side storage for favorites and dietary goals. - - `data/recipes.json`: Optional seed file only (`php scripts/db-seed.php`), not used at runtime. - - `scripts/schema.sql`: Relational table definitions for recipes. + - `scripts/seed-data.php`: One-time seed data for recipes, imprint, and privacy settings. + - `scripts/schema.sql`: Relational table definitions for recipes and site settings. --- @@ -37,7 +37,7 @@ FlixCooks uses a modern, carefully curated vanilla tech-stack focused on lightni ### ⚙️ Backend & Data - **Engine**: Vanilla PHP. -- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working DB connection the site returns HTTP 503. +- **Database**: PostgreSQL only. `DATABASE_URL` is required; without a working external DB connection the site returns HTTP 503. - **Environment**: Custom `.env` variable parser integrated into PHP bootstrap. --- @@ -58,7 +58,7 @@ Make sure you have the following installed on your local machine: cp .env.example .env ``` 2. Set **`DATABASE_URL`** in `.env` (required). See [Local Postgres (Docker)](#local-postgres-docker) below. -3. Seed recipes once: `php scripts/db-seed.php` (imports `data/recipes.json` into SQL tables). +3. Seed the external database once: `php scripts/db-seed.php` (imports recipes and legal/site settings into SQL tables). ### 2. Local Postgres (Docker) @@ -97,7 +97,8 @@ Nützliche SQL-Befehle in `psql`: \dt -- alle Tabellen \d recipes -- Spalten der Tabelle recipes SELECT slug, created_at FROM recipes; -SELECT slug, data->>'title' AS title FROM recipes, jsonb_to_record(data) AS x(title text); -- optional +SELECT recipe_slug, title FROM recipe_translations WHERE lang = 'en'; +SELECT section, lang, setting_key FROM site_settings ORDER BY section, lang, setting_key; \q -- beenden ``` @@ -135,7 +136,7 @@ If you prefer running a full local stack: ## 🗄️ Postgres in diesem Projekt (Kurzüberblick) -Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzeit-Fallback auf Dateien): +Rezepte und Site-Daten liegen in **normalisierten SQL-Tabellen**. Es gibt keinen JSONB-Blob, keinen Datei-Fallback und kein Laden von `data/*.json` zur Laufzeit. | Tabelle | Inhalt | |---------|--------| @@ -145,19 +146,21 @@ Rezepte liegen in **normalisierten SQL-Tabellen** (kein JSONB-Blob, kein Laufzei | `recipe_ingredients` | Zutatenzeilen | | `recipe_utensils` | Werkzeugzeilen | | `recipe_steps` | Schritte inkl. Video-URL und Timer | +| `site_settings` | Impressum- und Datenschutzfelder pro Sprache | Schema: `scripts/schema.sql`. PHP baut daraus dieselben Arrays wie früher (`i18n.en`, `nutrition`, …), damit Templates unverändert bleiben. **Ablauf:** 1. `DATABASE_URL` in `.env` → Verbindung über `config.php`. -2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`). Alte JSONB-Tabelle wird einmalig migriert. -3. `load_recipes()` liest per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`). -4. Admin: `save_recipe()` / `delete_recipe()` – direkt in die Tabellen. +2. Beim ersten Request: Tabellen anlegen (`ensure_recipe_schema()`). +3. `php scripts/db-seed.php` einmalig ausführen → Rezepte und Site-Daten werden in Postgres geschrieben. +4. `load_recipes()` und `load_site_settings()` lesen per SQL; ohne DB → HTTP 503 (`maintenance/db-unavailable.php`). +5. Admin: `save_recipe()`, `delete_recipe()` und `save_site_settings()` schreiben direkt in die Tabellen. -**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `data/recipes.json`). +**Einmalig Daten laden:** `php scripts/db-seed.php` (aus `scripts/seed-data.php`, ohne JSON-Dateien). -Für **Staging/Production** nur `DATABASE_URL` in der Hosting-Umgebung setzen – nie Production-Daten in der lokalen Dev-DB mischen. +Für **Staging/Production** muss eine externe Postgres-Datenbank vorhanden sein. Setze nur `DATABASE_URL` in der Hosting-Umgebung und mische nie Production-Daten in die lokale Dev-DB. ### Docker / Coolify diff --git a/admin.php b/admin.php index b07411a..460db2a 100644 --- a/admin.php +++ b/admin.php @@ -78,10 +78,10 @@ if (!$authed) { try { $allRecipes = load_recipes(); + $siteSettings = load_site_settings(); } catch (DatabaseUnavailableException $e) { handle_database_unavailable($e); } -$siteSettings = load_site_settings(); $message = null; $errors = []; $editing = null; @@ -186,9 +186,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_ $siteSettings['imprint'] = $imprint; $siteSettings['privacy'] = $privacy; if (save_site_settings($siteSettings)) { + $siteSettings = load_site_settings(true); $message = 'Settings saved.'; } else { - $errors[] = 'Could not save settings. Check permissions on data/site.json.'; + $errors[] = 'Could not save settings. Check the database connection.'; } } } diff --git a/data/recipes.json b/data/recipes.json deleted file mode 100644 index 28b7e23..0000000 --- a/data/recipes.json +++ /dev/null @@ -1,264 +0,0 @@ -[ - { - "slug": "frische-tagliatelle-mit-cremiger-tomatensauce", - "hero": "/assets/pasta-tomato.jpg", - "prep_time": 50, - "cook_time": 10, - "total_time": 60, - "servings": 2, - "featured": false, - "coming_soon": false, - "nutrition": { - "calories": 0, - "protein": 0, - "carbs": 0, - "fat": 0 - }, - "i18n": { - "en": { - "title": "Tagliatelle with tomato sauce", - "description": "Simple but tasty, short cooking time but self made. The root dish to start in a cozy evening and satisfy the carbs cravings without any hidden additives. Noodles with Tomato Sauce, more hearty isnt possible.", - "category": "Pasta", - "difficulty": "Easy", - "tags": [ - "Pasta", - "Fast", - "Vegetarian" - ], - "ingredients": [ - "300 g flour", - "3 eggs", - "10 cherry tomatoes", - "1/2 clove garlic", - "1 tbsp olive oil", - "80 ml cream", - "Fresh basil", - "Parmesan", - "Salt and pepper" - ], - "utensils": [ - "Rolling pin", - "Optional: Pasta machine", - "Optional: Blender" - ], - "steps": [ - "Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 5–10 minutes until smooth.", - "Roll the dough to 1–2 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.", - "Bring salted water to a boil. Meanwhile, halve tomatoes and sear in olive oil with a pinch of salt until lightly charred.", - "Toast the garlic briefly, then blend tomatoes with cream (or crush in the pan) and simmer to thicken slightly.", - "Boil tagliatelle for 2–3 minutes, drain, toss with the sauce, and finish with basil and Parmesan." - ] - }, - "de": { - "title": "Tagliatelle mit Tomatensauce", - "description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht für einen gemütlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zusätze. Nudeln mit Tomatensauce – herzhafter geht es kaum.", - "category": "Pasta", - "difficulty": "Einfach", - "tags": [ - "Pasta", - "Schnell", - "Vegetarisch" - ], - "ingredients": [ - "300 g Mehl", - "3 Eier", - "10 Cherry-Tomaten", - "1/2 Knoblauchzehe", - "1 EL Olivenöl", - "80 ml Sahne", - "Frischer Basilikum", - "Parmesan", - "Salz und Pfeffer" - ], - "utensils": [ - "Nudelholz", - "Optional: Nudelmaschine", - "Optional: Mixer" - ], - "steps": [ - "Mehl auf der Arbeitsfläche anhäufen, eine Kuhle drücken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 5–10 Minuten kneten, bis der Teig glatt ist.", - "Teig auf 1–2 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.", - "Gesalzenes Wasser aufsetzen; währenddessen Tomaten halbieren und mit Olivenöl und einer Prise Salz in der Pfanne anrösten, bis Röstnoten entstehen.", - "Knoblauch kurz mitrösten, dann Tomaten mit Sahne pürieren (Mixer) oder in der Pfanne zerdrücken und kurz einkochen lassen.", - "Tagliatelle 2–3 Minuten kochen, abgießen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten." - ] - } - } - }, - { - "slug": "oat-pancakes", - "hero": "/assets/pancakes.jpg", - "prep_time": 0, - "cook_time": 0, - "total_time": 0, - "servings": 2, - "featured": false, - "coming_soon": true, - "nutrition": { - "calories": 0, - "protein": 0, - "carbs": 0, - "fat": 0 - }, - "i18n": { - "en": { - "title": "Oat-Pancakes", - "description": "", - "category": "", - "difficulty": "Easy", - "tags": [], - "ingredients": [], - "utensils": [], - "steps": [] - }, - "de": { - "title": "Hafer-Pfannkuchen", - "description": "", - "category": "", - "difficulty": "Einfach", - "tags": [], - "ingredients": [], - "utensils": [], - "steps": [] - } - } - }, - { - "slug": "steak-with-onion-jam", - "hero": "/assets/steak.JPEG", - "prep_time": 15, - "cook_time": 15, - "total_time": 30, - "servings": 2, - "featured": true, - "coming_soon": false, - "nutrition": { - "calories": 0, - "protein": 0, - "carbs": 0, - "fat": 0 - }, - "i18n": { - "en": { - "title": "Steak with onion jam", - "description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good company—because the onion jam still isn’t satisfied on its own.", - "category": "", - "difficulty": "Easy", - "tags": [ - "meat", - "dinner" - ], - "ingredients": [ - "2 x 250g rump steak", - "300g potatoes", - "1 large red onion", - "1/2 clove garlic", - "50ml cream", - "50ml red wine", - "4 carrots (yellow and purple)", - "parmesan", - "Sicilian orange salt", - "thyme", - "1 tbsp butter" - ], - "utensils": [ - "knife", - "cutting board", - "pan", - "pot", - "oven" - ], - "steps": [ - "Take the steak out 30 minutes before cooking", - "Halve the potatoes", - "Halve the carrots", - "Dice the onion", - "Cook the potatoes and carrots until al dente", - "Sear the steak with butter", - "Once both sides are golden brown, bake for 6 minutes at 160°C (medium); for medium rare, let it rest after searing both sides for 3 minutes", - "Sauté the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated", - "Plate and serve!" - ] - }, - "de": { - "title": "Steak mit Zwiebelmarmelade", - "description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genießen, da die Zwiebelmarmelade noch nicht genug davon hat.", - "category": "", - "difficulty": "Einfach", - "tags": [ - "Fleisch", - "Abendessen" - ], - "ingredients": [ - "2 x 250g Rumpsteak", - "300gr Kartoffeln", - "1 Große Rote Zwiebel", - "1/2 Knoblauch-Zehe", - "50ml Sahne", - "50ml Rotwein", - "4 Karotten (Geld und Lila)", - "Parmesan", - "Sizilianisches Orangensalz", - "Thymian", - "1 EL Butter" - ], - "utensils": [ - "Messer", - "Brett", - "Pfanne", - "Topf", - "Ofen" - ], - "steps": [ - "Steak 30 Minuten vor dem anbraten raus legen", - "Kartoffeln halbieren", - "Karotten halbieren", - "Zwiebel würfeln", - "Kartoffeln und Karotten al dente kochen", - "Steak anbraten mit Butter", - "sobald beide seiten goldbraun sind für 6 Minuten bei 160grad backen (Medium) für Medium Rare nur ruhen lassen nachdem es von beiden Seiten für 3 Minuten angebraten wurde", - "Zwiebel in Steak-Butter anschwitzen, mit Rotwein ablöschen und Sahne dazu geben nachdem er Alkohol verdünstet ist", - "Anrichten!" - ] - } - } - }, - { - "slug": "oat-cake", - "hero": "/assets/haferkuchen.JPEG", - "prep_time": 0, - "cook_time": 0, - "total_time": 0, - "servings": 2, - "featured": false, - "coming_soon": true, - "nutrition": { - "calories": 0, - "protein": 0, - "carbs": 0, - "fat": 0 - }, - "i18n": { - "en": { - "title": "Oat cake", - "description": "", - "category": "", - "difficulty": "Easy", - "tags": [], - "ingredients": [], - "utensils": [], - "steps": [] - }, - "de": { - "title": "Haferkuchen", - "description": "", - "category": "", - "difficulty": "Einfach", - "tags": [], - "ingredients": [], - "utensils": [], - "steps": [] - } - } - } -] \ No newline at end of file diff --git a/data/site.json b/data/site.json deleted file mode 100644 index 550de25..0000000 --- a/data/site.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "imprint": { - "de": { - "owner_name": "Felix Brockers", - "address": "N.A", - "email": "contact@flixcooks.at", - "phone": "N.A", - "legal_form": "Kleines Impressum; kein Firmenbucheintrag/UID hinterlegt.", - "business_purpose": "Foodblog & Rezeptmarketing.", - "wko_membership": "", - "authority": "", - "uid": "", - "odr": "https://consumer-redress.ec.europa.eu/", - "last_updated": "2026-03-23" - }, - "en": { - "owner_name": "Felix Brockers", - "address": "N.A", - "email": "contact@flixcooks.at", - "phone": "N.A", - "legal_form": "Small website notice; no commercial register/UID provided.", - "business_purpose": "Food blog & recipe marketing.", - "wko_membership": "", - "authority": "", - "uid": "", - "odr": "https://consumer-redress.ec.europa.eu/", - "last_updated": "2026-03-23" - } - }, - "privacy": { - "de": { - "controller": "Felix Brockers", - "contact_email": "contact@flixcooks.at", - "contact_phone": "N.A", - "address": "N.A", - "hosting_provider": "Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland", - "log_retention_days": 30, - "cookie_statement": "Keine Tracking- oder Marketing-Cookies; nur technisch notwendige.", - "purposes": "Betrieb und Bereitstellung der Website, Beantwortung von Kontaktanfragen.", - "last_updated": "2026-03-23" - }, - "en": { - "controller": "Felix Brockers", - "contact_email": "contact@flixcooks.at", - "contact_phone": "N.A", - "address": "N.A", - "hosting_provider": "Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Germany", - "log_retention_days": 30, - "cookie_statement": "No tracking or marketing cookies; only technically necessary cookies.", - "purposes": "Operating and providing the website, responding to contact requests.", - "last_updated": "2026-03-23" - } - } -} diff --git a/datenschutz.php b/datenschutz.php index 65a86ba..f0689e8 100644 --- a/datenschutz.php +++ b/datenschutz.php @@ -2,7 +2,11 @@ require __DIR__ . '/helpers.php'; $lang = strtolower((string)(filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en'; -$settings = load_site_settings(); +try { + $settings = load_site_settings(); +} catch (DatabaseUnavailableException $e) { + handle_database_unavailable($e); +} $privacy = $settings['privacy'][$lang] ?? ($settings['privacy']['en'] ?? []); $langSwitchLabel = $lang === 'de' ? 'DE' : 'EN'; diff --git a/docker-compose.yml b/docker-compose.yml index 5ec26a7..466dc9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,28 +3,23 @@ # docker compose build # docker compose up -d # curl http://127.0.0.1:8080/health.php -# -# Erstes Deployment mit Seed: -# RUN_DB_SEED=true docker compose up -d services: web: build: . + restart: unless-stopped ports: - "8080:80" environment: DATABASE_URL: postgresql://flixcooks:flixcooks_prod@postgres:5432/flixcooks FLIXCOOKS_ADMIN_KEY: ${FLIXCOOKS_ADMIN_KEY:-change-me-in-production} - RUN_DB_SEED: ${RUN_DB_SEED:-false} depends_on: postgres: condition: service_healthy - volumes: - # Impressum/Datenschutz-Einstellungen persistent halten - - site_data:/var/www/html/data postgres: image: postgres:16-alpine + restart: unless-stopped environment: POSTGRES_USER: flixcooks POSTGRES_PASSWORD: flixcooks_prod @@ -39,4 +34,3 @@ services: volumes: pg_data: - site_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c5db388..3393ef8 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -19,10 +19,5 @@ done echo "[flixcooks] Applying schema..." php -r "require 'helpers.php'; require_database(); echo \"schema ok\n\";" -if [ "${RUN_DB_SEED:-false}" = "true" ]; then - echo "[flixcooks] Seeding recipes from data/recipes.json..." - php scripts/db-seed.php -fi - echo "[flixcooks] Starting Apache..." exec "$@" diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md index f226bea..1aa54f1 100644 --- a/docs/COOLIFY.md +++ b/docs/COOLIFY.md @@ -12,13 +12,12 @@ Zwei getrennte Ressourcen: **PostgreSQL** und **PHP-Web-App** (dieses Repo als D │ │ (Service B) │ :5432 │ Apache + PHP 8.3 │ │ │ └──────────────┘ │ Port 80 → Traefik/HTTPS │ │ │ ▲ └─────────────────────────┘ │ -│ │ ▲ │ -│ Volume (Daten) Volume optional: │ -│ data/ (site.json) │ +│ │ │ +│ Volume (Daten) │ └─────────────────────────────────────────────────────────┘ ``` -Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist. +Die App startet **nicht**, wenn `DATABASE_URL` fehlt oder Postgres nicht erreichbar ist. Rezepte, Impressum und Datenschutz liegen komplett in Postgres. --- @@ -65,25 +64,13 @@ postgresql://flixcooks:geheim@postgresql-flixcooks:5432/flixcooks ### Environment Variables (optional) -| Variable | Default | Beschreibung | -| ------------------- | ------- | ------------------------------------------------------------------ | -| `RUN_DB_SEED` | `false` | Einmalig `true` setzen → importiert `data/recipes.json` beim Start | -| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) | +| Variable | Default | Beschreibung | +| ------------------- | ------- | ---------------------------------------------------------------------------- | +| `DB_WAIT_MAX_TRIES` | `30` | Warteversuche bis Postgres da ist (à 2 s) | +### Persistent Storage -Nach dem ersten erfolgreichen Deploy: `RUN_DB_SEED` wieder auf `false` oder entfernen. - -### Persistent Storage (empfohlen) - -Mount für Impressum/Datenschutz (`data/site.json`): - - -| Mount Path (Container) | Inhalt | -| ---------------------- | ----------------------------------------- | -| `/var/www/html/data` | `site.json` bleibt nach Redeploy erhalten | - - -Rezepte liegen in Postgres – **kein** Volume für Rezepte nötig. +Nur PostgreSQL benötigt persistenten Speicher. Für die Web-App selbst ist kein `/var/www/html/data`-Volume mehr nötig, weil Rezepte und Site-Settings in Postgres liegen. --- @@ -91,28 +78,21 @@ Rezepte liegen in Postgres – **kein** Volume für Rezepte nötig. 1. Postgres-Service läuft (healthy). 2. App mit `DATABASE_URL` + `FLIXCOOKS_ADMIN_KEY` deployen. -3. Einmalig `RUN_DB_SEED=true` → Redeploy → Rezepte prüfen auf der Startseite. -4. `RUN_DB_SEED` deaktivieren. -5. `https://deine-domain/admin.php` testen. -6. `https://deine-domain/health.php` → `{"status":"ok"}`. +3. `https://deine-domain/health.php` → `{"status":"ok"}`. +4. `https://deine-domain/admin.php` → Rezepte anlegen, Impressum/Datenschutz unter „Site settings" befüllen. ### Schema ohne Seed -Tabellen legt der Container beim Start automatisch an (`scripts/schema.sql` via `require_database()`). Ohne Seed ist die DB leer → Seite lädt, aber keine Rezepte, bis du im Admin anlegst oder seedest. +Tabellen legt der Container beim Start automatisch an (`scripts/schema.sql` via `require_database()`). Eine leere DB ist kein Fehler — die Site startet mit Platzhalter-Impressum/Datenschutz und zeigt eine leere Rezeptliste. Inhalte werden ausschließlich über `/admin.php` gepflegt. --- ## 4. Lokaler Test vor Coolify ```bash -# Starkes Admin-Passwort setzen export FLIXCOOKS_ADMIN_KEY="dein-geheimes-passwort" - -# Mit Seed -export RUN_DB_SEED=true docker compose build docker compose up -d - curl http://127.0.0.1:8080/health.php ``` @@ -122,7 +102,6 @@ curl http://127.0.0.1:8080/health.php - Neues Image bauen lassen (Git push → Coolify rebuild). - Postgres-Volume bleibt → Daten bleiben. -- `data/`-Volume bleibt → Site-Settings bleiben. - Kein manuelles `db-seed` bei Updates, außer du leerst die DB bewusst. --- @@ -138,12 +117,11 @@ curl http://127.0.0.1:8080/health.php ## 7. Troubleshooting -| Problem | Lösung | -| -------------------------------- | --------------------------------------------------------------- | -| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen | -| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? | -| Leere Seite, Health OK | `RUN_DB_SEED=true` einmalig oder Admin-Rezepte anlegen | -| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? | -| `site.json` verloren nach Deploy | Volume auf `/var/www/html/data` mounten | +| Problem | Lösung | +| ------------------------------- | --------------------------------------------------------------- | +| Container startet nicht | Logs: DB nicht erreichbar → `DATABASE_URL` Host/Passwort prüfen | +| 503 „Datenbank nicht verfügbar“ | Gleiches Netzwerk in Coolify? Internal URL? | +| Leere Seite, Health OK | Rezepte über `/admin.php` anlegen, Site-Settings befüllen | +| Admin geht nicht | `FLIXCOOKS_ADMIN_KEY` gesetzt? | diff --git a/helpers.php b/helpers.php index 1774209..2ba0487 100644 --- a/helpers.php +++ b/helpers.php @@ -69,61 +69,12 @@ function recipe_base_from_row(array $row): array { ]; } -function decode_legacy_jsonb_value($value): ?array { - if (is_array($value)) { - return $value; - } - if (is_string($value)) { - $decoded = json_decode($value, true); - return is_array($decoded) ? $decoded : null; - } - if ($value !== null) { - $decoded = json_decode(json_encode($value), true); - return is_array($decoded) ? $decoded : null; - } - return null; -} - -function migrate_legacy_jsonb_storage(PDO $pdo): void { - if (!db_table_has_column($pdo, 'recipes', 'data')) { - return; - } - - $legacy = []; - $stmt = $pdo->query('SELECT slug, data FROM recipes'); - while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { - $recipe = decode_legacy_jsonb_value($row['data']); - if (is_array($recipe) && !empty($recipe['slug'])) { - $legacy[] = $recipe; - } - } - - $pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE'); - $pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE'); - $pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE'); - $pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE'); - $pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE'); - $pdo->exec('DROP TABLE IF EXISTS recipes CASCADE'); - - apply_recipe_schema($pdo); - - foreach ($legacy as $recipe) { - save_recipe($recipe, $pdo); - } -} - function ensure_recipe_schema(PDO $pdo): void { static $ready = false; if ($ready) { return; } - if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) { - migrate_legacy_jsonb_storage($pdo); - $ready = true; - return; - } - apply_recipe_schema($pdo); $ready = true; } @@ -446,29 +397,73 @@ function default_site_settings(): array { } /** - * Load site-wide settings for legal pages, merging with defaults. + * Load site-wide settings for legal pages from Postgres. */ -function load_site_settings(): array { - $defaults = default_site_settings(); - $path = __DIR__ . '/data/site.json'; - if (!file_exists($path)) { - return $defaults; +function load_site_settings(bool $refresh = false): array { + static $cache = null; + if ($cache !== null && !$refresh) { + return $cache; } - $raw = file_get_contents($path); - $data = json_decode($raw, true); - if (!is_array($data)) { - return $defaults; + + $settings = default_site_settings(); + $pdo = require_database(); + $stmt = $pdo->query( + 'SELECT section, lang, setting_key, setting_value FROM site_settings ORDER BY section, lang, setting_key' + ); + + $hasRows = false; + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $section = $row['section']; + $lang = $row['lang']; + $key = $row['setting_key']; + if (!isset($settings[$section][$lang])) { + continue; + } + $settings[$section][$lang][$key] = $key === 'log_retention_days' + ? (int) $row['setting_value'] + : $row['setting_value']; + $hasRows = true; } - return array_replace_recursive($defaults, $data); + + $cache = $settings; + return $cache; } /** - * Persist site-wide settings for legal pages. + * Persist site-wide settings for legal pages in Postgres. */ -function save_site_settings(array $settings): bool { - $path = __DIR__ . '/data/site.json'; - $json = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - return (bool) file_put_contents($path, $json); +function save_site_settings(array $settings, ?PDO $pdo = null): bool { + $pdo = $pdo ?? require_database(); + + try { + $pdo->beginTransaction(); + $pdo->exec("DELETE FROM site_settings WHERE section IN ('imprint', 'privacy')"); + + $stmt = $pdo->prepare( + 'INSERT INTO site_settings (section, lang, setting_key, setting_value, updated_at) + VALUES (?, ?, ?, ?, NOW()) + ON CONFLICT (section, lang, setting_key) DO UPDATE SET + setting_value = EXCLUDED.setting_value, + updated_at = NOW()' + ); + + foreach (['imprint', 'privacy'] as $section) { + foreach (['de', 'en'] as $lang) { + foreach (($settings[$section][$lang] ?? []) as $key => $value) { + $stmt->execute([$section, $lang, $key, (string) $value]); + } + } + } + + $pdo->commit(); + return true; + } catch (\Exception $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('save_site_settings failed: ' . $e->getMessage()); + return false; + } } function slugify(string $text): string { diff --git a/impressum.php b/impressum.php index d15a9f8..a80c0b2 100644 --- a/impressum.php +++ b/impressum.php @@ -2,7 +2,11 @@ require __DIR__ . '/helpers.php'; $lang = strtolower((string)(filter_input(INPUT_GET, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?: 'en')) === 'de' ? 'de' : 'en'; -$settings = load_site_settings(); +try { + $settings = load_site_settings(); +} catch (DatabaseUnavailableException $e) { + handle_database_unavailable($e); +} $imprint = $settings['imprint'][$lang] ?? ($settings['imprint']['en'] ?? []); $langSwitchLabel = $lang === 'de' ? 'DE' : 'EN'; diff --git a/index.php b/index.php index 894e4de..f4b3e75 100644 --- a/index.php +++ b/index.php @@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php'; try { $allRecipes = load_recipes(); + load_site_settings(); } catch (DatabaseUnavailableException $e) { handle_database_unavailable($e); } diff --git a/login.php b/login.php index cb1f87a..502c781 100644 --- a/login.php +++ b/login.php @@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php'; try { $allRecipes = load_recipes(); + load_site_settings(); } catch (DatabaseUnavailableException $e) { handle_database_unavailable($e); } diff --git a/recipe.php b/recipe.php index 2b434b0..25a5f6f 100644 --- a/recipe.php +++ b/recipe.php @@ -3,6 +3,7 @@ require __DIR__ . '/helpers.php'; try { $allRecipes = load_recipes(); + load_site_settings(); } catch (DatabaseUnavailableException $e) { handle_database_unavailable($e); } diff --git a/scripts/db-seed.php b/scripts/db-seed.php deleted file mode 100644 index 3d9184f..0000000 --- a/scripts/db-seed.php +++ /dev/null @@ -1,43 +0,0 @@ -getMessage() . "\n"); - exit(1); -} - -$count = 0; -foreach ($recipes as $recipe) { - if (!is_array($recipe) || empty($recipe['slug'])) { - continue; - } - if (save_recipe($recipe, $pdo)) { - $count++; - echo " + {$recipe['slug']}\n"; - } else { - fwrite(STDERR, " ! Fehler bei {$recipe['slug']}\n"); - } -} - -echo "\nImport abgeschlossen: {$count} Rezepte.\n"; diff --git a/scripts/schema.sql b/scripts/schema.sql index 7aa5983..77f0944 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -68,3 +68,14 @@ CREATE TABLE IF NOT EXISTS recipe_steps ( ); CREATE INDEX IF NOT EXISTS idx_recipe_steps_slug_lang ON recipe_steps (recipe_slug, lang); + +CREATE TABLE IF NOT EXISTS site_settings ( + section VARCHAR(32) NOT NULL CHECK (section IN ('imprint', 'privacy')), + lang CHAR(2) NOT NULL CHECK (lang IN ('en', 'de')), + setting_key VARCHAR(64) NOT NULL, + setting_value TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (section, lang, setting_key) +); + +CREATE INDEX IF NOT EXISTS idx_site_settings_section_lang ON site_settings (section, lang); -- 2.54.0