Refactor database handling to require PostgreSQL connection, removing fallback to JSON. Implement error handling for database unavailability in key files. Update .env.example to reflect mandatory DATABASE_URL for local development. Remove Firebase configuration and related code from the project.

This commit is contained in:
2026-05-23 10:23:28 +02:00
parent 547778bba5
commit 6577db475a
20 changed files with 823 additions and 789 deletions
+331 -96
View File
@@ -7,136 +7,371 @@ if (session_status() === PHP_SESSION_NONE) {
require_once __DIR__ . '/config.php';
function init_db() {
$pdo = get_db_connection();
if (!$pdo) return;
try {
$pdo->exec("CREATE TABLE IF NOT EXISTS recipes (
slug VARCHAR(255) PRIMARY KEY,
data JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
$stmt = $pdo->query("SELECT COUNT(*) FROM recipes");
if ($stmt && $stmt->fetchColumn() == 0) {
$path = __DIR__ . '/data/recipes.json';
if (file_exists($path)) {
$json = file_get_contents($path);
$data = json_decode($json, true);
if (is_array($data) && count($data) > 0) {
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data)");
foreach ($data as $recipe) {
if (isset($recipe['slug'])) {
$insert->execute([
'slug' => $recipe['slug'],
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
]);
}
}
}
}
function handle_database_unavailable(DatabaseUnavailableException $e): void {
http_response_code(503);
$dbError = $e->getMessage();
include __DIR__ . '/maintenance/db-unavailable.php';
exit;
}
function db_table_has_column(PDO $pdo, string $table, string $column): bool {
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?'
);
$stmt->execute([$table, $column]);
return (bool) $stmt->fetchColumn();
}
function apply_recipe_schema(PDO $pdo): void {
$path = __DIR__ . '/scripts/schema.sql';
if (!file_exists($path)) {
throw new RuntimeException('Missing scripts/schema.sql');
}
$pdo->exec(file_get_contents($path));
}
function recipe_empty_i18n(): array {
return [
'title' => '',
'description' => '',
'category' => '',
'difficulty' => '',
'tags' => [],
'ingredients' => [],
'utensils' => [],
'steps' => [],
'step_videos' => [],
'step_timers' => [],
];
}
function recipe_base_from_row(array $row): array {
return [
'slug' => $row['slug'],
'hero' => $row['hero'] ?? '',
'prep_time' => (int) ($row['prep_time'] ?? 0),
'cook_time' => (int) ($row['cook_time'] ?? 0),
'total_time' => (int) ($row['total_time'] ?? 0),
'servings' => (int) ($row['servings'] ?? 2),
'featured' => (bool) ($row['featured'] ?? false),
'coming_soon' => (bool) ($row['coming_soon'] ?? false),
'nutrition' => [
'calories' => (int) ($row['calories'] ?? 0),
'protein' => (int) ($row['protein'] ?? 0),
'carbs' => (int) ($row['carbs'] ?? 0),
'fat' => (int) ($row['fat'] ?? 0),
],
'i18n' => [
'en' => recipe_empty_i18n(),
'de' => recipe_empty_i18n(),
],
];
}
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;
}
} catch (\Exception $e) {
error_log("DB Init Error: " . $e->getMessage());
}
$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 load_recipes_local(): array {
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;
}
function require_database(): PDO {
$pdo = get_db_connection();
$data = [];
if (!$pdo) {
$path = __DIR__ . '/data/recipes.json';
if (file_exists($path)) {
$json = file_get_contents($path);
$data = json_decode($json, true) ?: [];
$message = getenv('DATABASE_URL')
? 'Database connection failed. Check DATABASE_URL and that Postgres is running.'
: 'DATABASE_URL is not set in .env.';
throw new DatabaseUnavailableException($message);
}
ensure_recipe_schema($pdo);
return $pdo;
}
function init_db(): void {
require_database();
}
function hydrate_recipes_from_db(PDO $pdo): array {
$recipes = [];
$stmt = $pdo->query(
'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$recipes[$row['slug']] = recipe_base_from_row($row);
}
if ($recipes === []) {
return [];
}
$stmt = $pdo->query('SELECT * FROM recipe_translations');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$slug = $row['recipe_slug'];
$lang = $row['lang'];
if (!isset($recipes[$slug])) {
continue;
}
} else {
static $initialized = false;
if (!$initialized) {
init_db();
$initialized = true;
}
try {
$stmt = $pdo->query("SELECT data FROM recipes");
if ($stmt) {
while ($row = $stmt->fetch()) {
$recipe = json_decode($row['data'], true);
if (is_array($recipe)) {
$data[] = $recipe;
}
}
}
} catch (\Exception $e) {
error_log("Failed to load recipes from DB: " . $e->getMessage());
$recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [
'title' => $row['title'],
'description' => $row['description'],
'category' => $row['category'],
'difficulty' => $row['difficulty'],
]);
}
$stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag'];
}
}
foreach ($data as &$recipe) {
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content'];
}
}
$stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
$recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content'];
}
}
$stmt = $pdo->query(
'SELECT recipe_slug, lang, content, video_url, timer_minutes
FROM recipe_steps ORDER BY sort_order, id'
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) {
continue;
}
$lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']];
$lang['steps'][] = $row['content'];
$lang['step_videos'][] = $row['video_url'] ?? '';
$lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : '';
unset($lang);
}
$list = array_values($recipes);
foreach ($list as &$recipe) {
if (!empty($recipe['hero']) && is_string($recipe['hero'])) {
$recipe['hero'] = normalize_asset_path($recipe['hero']);
}
}
unset($recipe);
return $data;
return $list;
}
function load_recipes(): array {
return load_recipes_local();
$pdo = require_database();
return hydrate_recipes_from_db($pdo);
}
function load_recipe_by_slug(string $slug): ?array {
return find_recipe_by_slug(load_recipes_local(), $slug);
foreach (load_recipes() as $recipe) {
if (($recipe['slug'] ?? '') === $slug) {
return $recipe;
}
}
return null;
}
function save_recipes(array $recipes): bool {
$pdo = get_db_connection();
$path = __DIR__ . '/data/recipes.json';
if (!$pdo) {
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return (bool) file_put_contents($path, $json);
function clear_featured_recipes(?PDO $pdo = null): void {
$pdo = $pdo ?? require_database();
$pdo->exec('UPDATE recipes SET featured = FALSE');
}
function delete_recipe(string $slug): bool {
$pdo = require_database();
$stmt = $pdo->prepare('DELETE FROM recipes WHERE slug = ?');
$stmt->execute([$slug]);
return $stmt->rowCount() > 0;
}
function save_recipe(array $recipe, ?PDO $pdo = null): bool {
if (empty($recipe['slug'])) {
return false;
}
$pdo = $pdo ?? require_database();
$nutrition = $recipe['nutrition'] ?? [];
$slug = $recipe['slug'];
try {
$pdo->beginTransaction();
$slugs = [];
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data) ON CONFLICT (slug) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP");
foreach ($recipes as $recipe) {
if (isset($recipe['slug'])) {
$slugs[] = $recipe['slug'];
$insert->execute([
'slug' => $recipe['slug'],
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
]);
$stmt = $pdo->prepare(
'INSERT INTO recipes (
slug, hero, prep_time, cook_time, total_time, servings,
featured, coming_soon, calories, protein, carbs, fat, updated_at
) VALUES (
:slug, :hero, :prep_time, :cook_time, :total_time, :servings,
:featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW()
)
ON CONFLICT (slug) DO UPDATE SET
hero = EXCLUDED.hero,
prep_time = EXCLUDED.prep_time,
cook_time = EXCLUDED.cook_time,
total_time = EXCLUDED.total_time,
servings = EXCLUDED.servings,
featured = EXCLUDED.featured,
coming_soon = EXCLUDED.coming_soon,
calories = EXCLUDED.calories,
protein = EXCLUDED.protein,
carbs = EXCLUDED.carbs,
fat = EXCLUDED.fat,
updated_at = NOW()'
);
$stmt->bindValue(':slug', $slug);
$stmt->bindValue(':hero', $recipe['hero'] ?? '');
$stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT);
$stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL);
$stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL);
$stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT);
$stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT);
$stmt->execute();
$pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]);
$pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]);
$translationStmt = $pdo->prepare(
'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty)
VALUES (?, ?, ?, ?, ?, ?)'
);
$tagStmt = $pdo->prepare(
'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)'
);
$ingredientStmt = $pdo->prepare(
'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
);
$utensilStmt = $pdo->prepare(
'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)'
);
$stepStmt = $pdo->prepare(
'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach (['en', 'de'] as $lang) {
$block = $recipe['i18n'][$lang] ?? [];
$translationStmt->execute([
$slug,
$lang,
$block['title'] ?? '',
$block['description'] ?? '',
$block['category'] ?? '',
$block['difficulty'] ?? '',
]);
foreach (array_values($block['tags'] ?? []) as $i => $tag) {
$tag = trim((string) $tag);
if ($tag !== '') {
$tagStmt->execute([$slug, $lang, $tag, $i]);
}
}
foreach (array_values($block['ingredients'] ?? []) as $i => $line) {
$line = trim((string) $line);
if ($line !== '') {
$ingredientStmt->execute([$slug, $lang, $line, $i]);
}
}
foreach (array_values($block['utensils'] ?? []) as $i => $line) {
$line = trim((string) $line);
if ($line !== '') {
$utensilStmt->execute([$slug, $lang, $line, $i]);
}
}
$steps = array_values($block['steps'] ?? []);
$videos = array_values($block['step_videos'] ?? []);
$timers = array_values($block['step_timers'] ?? []);
foreach ($steps as $i => $step) {
$step = trim((string) $step);
if ($step === '') {
continue;
}
$video = trim((string) ($videos[$i] ?? ''));
$timerRaw = $timers[$i] ?? '';
$timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null;
$stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $i]);
}
}
if (!empty($slugs)) {
$placeholders = implode(',', array_fill(0, count($slugs), '?'));
$delete = $pdo->prepare("DELETE FROM recipes WHERE slug NOT IN ($placeholders)");
$delete->execute($slugs);
} else {
$pdo->exec("DELETE FROM recipes");
}
$pdo->commit();
// Also update local JSON as backup
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents($path, $json);
return true;
} catch (Exception $e) {
} catch (\Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log("Failed to save recipes to DB: " . $e->getMessage());
error_log('save_recipe failed: ' . $e->getMessage());
return false;
}
}