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:
+331
-96
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user