pdo->exec($sql); } $columnStatements = [ 'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind', 'listing_places.opening_hours_json' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_json LONGTEXT NULL AFTER opening_hours_note', 'listings.image_path' => 'ALTER TABLE listings ADD COLUMN image_path VARCHAR(255) NULL AFTER description', 'listings.special_conditions_note' => 'ALTER TABLE listings ADD COLUMN special_conditions_note TEXT NULL AFTER image_path', ]; foreach ($columnStatements as $key => $sql) { [$table, $column] = explode('.', $key, 2); if (!$this->hasColumn($table, $column)) { $this->pdo->exec($sql); $this->columnCache[$key] = true; } } $seed = $this->pdo->prepare( 'INSERT INTO listing_categories (slug, title, category_group, sort_order) VALUES (:slug, :title, :groupName, :sortOrder) ON DUPLICATE KEY UPDATE title = VALUES(title), category_group = VALUES(category_group), sort_order = VALUES(sort_order), updated_at = CURRENT_TIMESTAMP' ); foreach ($this->defaultCategories() as $index => $category) { $seed->execute([ 'slug' => $category['slug'], 'title' => $category['title'], 'groupName' => $category['group'], 'sortOrder' => $index + 1, ]); } } public function status(): array { $tables = [ 'listing_categories', 'listing_places', 'listings', 'listing_category_map', 'listing_occurrences', 'listing_prices', 'listing_benefits', 'listing_moderation_requests', ]; $missing = []; foreach ($tables as $table) { if (!$this->hasTable($table)) { $missing[] = $table; } } return [ 'complete' => $missing === [], 'missing' => $missing, 'tables' => $tables, ]; } public function listCategories(?array $groups = null): array { $this->ensureSchema(); $params = []; $sql = 'SELECT id, slug, title, category_group FROM listing_categories'; if ($groups && $groups !== []) { $placeholders = implode(',', array_fill(0, count($groups), '?')); $sql .= " WHERE category_group IN ($placeholders)"; $params = array_values($groups); } $sql .= ' ORDER BY sort_order ASC, title ASC'; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } public function ensureCategoryForInput(string $input, string $group = 'general'): ?array { $this->ensureSchema(); $input = trim($input); if ($input === '') { return null; } $slug = $this->slugify($input); $stmt = $this->pdo->prepare( 'SELECT id, slug, title, category_group, sort_order FROM listing_categories WHERE slug = :slug OR LOWER(title) = LOWER(:title) ORDER BY sort_order ASC, id ASC LIMIT 1' ); $stmt->execute([ 'slug' => $slug, 'title' => $input, ]); $existing = $stmt->fetch(\PDO::FETCH_ASSOC); if ($existing) { $existing['is_new'] = false; return $existing; } $insert = $this->pdo->prepare( 'INSERT INTO listing_categories (slug, title, category_group, sort_order) VALUES (:slug, :title, :groupName, 999)' ); $insert->execute([ 'slug' => $slug, 'title' => $input, 'groupName' => in_array($group, ['general', 'event', 'place', 'food', 'family', 'partner'], true) ? $group : 'general', ]); return [ 'id' => (int)$this->pdo->lastInsertId(), 'slug' => $slug, 'title' => $input, 'category_group' => $group, 'sort_order' => 999, 'is_new' => true, ]; } public function listCategoryReviewItems(): array { $this->ensureSchema(); $stmt = $this->pdo->query( 'SELECT c.id, c.slug, c.title, c.category_group, c.sort_order, (SELECT COUNT(*) FROM listing_category_map m WHERE m.category_id = c.id) AS listing_count, (SELECT COUNT(*) FROM listing_places p WHERE p.place_kind = c.slug) AS place_count FROM listing_categories c ORDER BY CASE WHEN c.sort_order >= 900 THEN 0 ELSE 1 END, c.sort_order ASC, c.title ASC' ); $rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : []; $eventStmt = $this->pdo->query('SELECT category_slug, COUNT(*) AS total FROM events WHERE category_slug IS NOT NULL AND category_slug <> "" GROUP BY category_slug'); $eventCounts = []; foreach (($eventStmt ? $eventStmt->fetchAll(\PDO::FETCH_ASSOC) : []) as $row) { $eventCounts[(string)$row['category_slug']] = (int)$row['total']; } foreach ($rows as &$row) { $row['event_count'] = $eventCounts[(string)$row['slug']] ?? 0; $row['needs_review'] = ((int)($row['sort_order'] ?? 0)) >= 900; } unset($row); return $rows; } public function mergeCategories(string $sourceSlug, string $targetSlug): void { $this->ensureSchema(); $sourceSlug = trim($sourceSlug); $targetSlug = trim($targetSlug); if ($sourceSlug === '' || $targetSlug === '' || $sourceSlug === $targetSlug) { throw new \RuntimeException('Bitte zwei unterschiedliche Kategorien auswählen.'); } $lookup = $this->pdo->prepare('SELECT id, slug FROM listing_categories WHERE slug = :slug LIMIT 1'); $lookup->execute(['slug' => $sourceSlug]); $source = $lookup->fetch(\PDO::FETCH_ASSOC); $lookup->execute(['slug' => $targetSlug]); $target = $lookup->fetch(\PDO::FETCH_ASSOC); if (!$source || !$target) { throw new \RuntimeException('Kategorie nicht gefunden.'); } $sourceId = (int)$source['id']; $targetId = (int)$target['id']; $this->pdo->beginTransaction(); try { $mapRows = $this->pdo->prepare('SELECT listing_id FROM listing_category_map WHERE category_id = :categoryId'); $mapRows->execute(['categoryId' => $sourceId]); $listingIds = $mapRows->fetchAll(\PDO::FETCH_COLUMN) ?: []; $insertMap = $this->pdo->prepare( 'INSERT IGNORE INTO listing_category_map (listing_id, category_id) VALUES (:listingId, :categoryId)' ); foreach ($listingIds as $listingId) { $insertMap->execute([ 'listingId' => (int)$listingId, 'categoryId' => $targetId, ]); } $this->pdo->prepare('DELETE FROM listing_category_map WHERE category_id = :categoryId')->execute(['categoryId' => $sourceId]); $this->pdo->prepare('UPDATE listing_places SET place_kind = :target WHERE place_kind = :source')->execute([ 'target' => $targetSlug, 'source' => $sourceSlug, ]); $this->pdo->prepare('UPDATE events SET category_slug = :target WHERE category_slug = :source')->execute([ 'target' => $targetSlug, 'source' => $sourceSlug, ]); $this->pdo->prepare('DELETE FROM listing_categories WHERE id = :id')->execute(['id' => $sourceId]); $this->pdo->commit(); } catch (\Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } public function listPublishedDirectoryEntries(): array { $this->ensureSchema(); $stmt = $this->pdo->query( 'SELECT l.id, l.created_by, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, l.image_path, lp.title AS place_title, lp.street, lp.zip, lp.city, lp.region, lp.place_kind, lo.starts_at, lo.ends_at, lo.occurrence_type, lc.title AS category_title, lc.slug AS category_slug FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id LEFT JOIN listing_categories lc ON lc.id = lcm.category_id WHERE l.listing_type IN ("place","editorial_event") AND l.status = "published" AND (lp.status = "published" OR lp.status IS NULL) ORDER BY CASE l.listing_type WHEN "place" THEN 1 WHEN "editorial_event" THEN 2 ELSE 3 END, l.title ASC, COALESCE(lo.starts_at, l.created_at) ASC' ); return $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : []; } public function listUserSubmittedEntries(int $userId): array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT l.id, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, l.image_path, lp.title AS place_title, lp.city, lp.region, lp.place_kind, lo.starts_at, lo.ends_at, lo.occurrence_type, lc.title AS category_title, lc.slug AS category_slug, mr.request_type AS moderation_request_type, mr.request_status AS moderation_request_status, mr.review_note AS moderation_review_note, mr.created_at AS moderation_created_at FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id LEFT JOIN listing_categories lc ON lc.id = lcm.category_id LEFT JOIN listing_moderation_requests mr ON mr.id = ( SELECT sub.id FROM listing_moderation_requests sub WHERE sub.listing_id = l.id ORDER BY sub.created_at DESC, sub.id DESC LIMIT 1 ) WHERE l.created_by = :uid ORDER BY CASE l.listing_type WHEN "place" THEN 1 WHEN "editorial_event" THEN 2 ELSE 3 END, COALESCE(lo.starts_at, l.created_at) ASC, l.created_at DESC' ); $stmt->execute(['uid' => $userId]); return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } public function listDashboardEntries(int $userId): array { return $this->listUserSubmittedEntries($userId); } public function getMemberEntry(int $listingId): ?array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json, lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type, lo.recurrence_rule, lo.recurrence_until, lo.capacity_total, lc.slug AS category_slug FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id LEFT JOIN listing_categories lc ON lc.id = lcm.category_id WHERE l.id = :id LIMIT 1' ); $stmt->execute(['id' => $listingId]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$row) { return null; } $row['prices'] = $this->listPrices((int)$row['id']); $row['opening_hours'] = $this->decodeJsonRows((string)($row['opening_hours_json'] ?? '')); return $row; } public function getDashboardEntry(int $userId, int $listingId): ?array { $row = $this->getMemberEntry($listingId); if (!$row || (int)($row['created_by'] ?? 0) !== $userId) { return null; } return $row; } public function listPlaceSuggestions(int $limit = 40): array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT DISTINCT lp.id, lp.title, lp.street, lp.zip, lp.city, lp.region, lp.place_kind FROM listing_places lp INNER JOIN listings l ON l.primary_place_id = lp.id WHERE lp.status IN ("draft","published") AND l.status IN ("draft","published") AND l.listing_type IN ("place","editorial_event") ORDER BY lp.updated_at DESC LIMIT :limit' ); $stmt->bindValue(':limit', max(1, min(200, $limit)), \PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } public function listEventLocationOptions(int $limit = 80): array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT l.id, l.listing_type, l.title, l.status, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lo.starts_at, lo.recurrence_until, lc.slug AS category_slug, lc.title AS category_title FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id LEFT JOIN listing_categories lc ON lc.id = lcm.category_id WHERE l.listing_type IN ("place","editorial_event") AND l.status IN ("draft","published") AND ( l.listing_type = "place" OR ( lo.starts_at IS NOT NULL AND DATE(lo.starts_at) <= CURRENT_DATE() AND ( lo.recurrence_until IS NULL OR DATE(lo.recurrence_until) >= CURRENT_DATE() ) ) ) ORDER BY CASE l.listing_type WHEN "place" THEN 1 WHEN "editorial_event" THEN 2 ELSE 3 END, l.title ASC, COALESCE(lo.starts_at, l.created_at) ASC LIMIT :limit' ); $stmt->bindValue(':limit', max(1, min(250, $limit)), \PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } public function getEventLocationSource(int $listingId): ?array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT l.id, l.listing_type, l.title, l.status, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lo.starts_at, lo.recurrence_until, lc.slug AS category_slug, lc.title AS category_title FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id LEFT JOIN listing_categories lc ON lc.id = lcm.category_id WHERE l.id = :id AND l.listing_type IN ("place","editorial_event") AND l.status IN ("draft","published") LIMIT 1' ); $stmt->execute(['id' => $listingId]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); return $row ?: null; } public function listUserOpenModerationRequestMap(int $userId): array { $this->ensureSchema(); $stmt = $this->pdo->prepare( 'SELECT listing_id, GROUP_CONCAT(request_type ORDER BY created_at ASC SEPARATOR ",") AS request_types FROM listing_moderation_requests WHERE requested_by = :uid AND request_status = "open" GROUP BY listing_id' ); $stmt->execute(['uid' => $userId]); $map = []; foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { $types = array_values(array_filter(array_map('trim', explode(',', (string)($row['request_types'] ?? ''))))); $map[(int)$row['listing_id']] = $types; } return $map; } public function submitCreateSuggestion(int $userId, array $data): int { $this->ensureSchema(); $this->assertNoDuplicateEntry($data); $listingId = $this->saveDashboardEntry($userId, $data, null, 'draft', 'draft'); $this->createModerationRequest($listingId, 'create', $userId, null, null); return $listingId; } public function submitUpdateRequest(int $userId, int $listingId, array $data, string $reason): int { $this->ensureSchema(); $reason = trim($reason); if ($reason === '') { throw new \RuntimeException('Bitte gib eine Begründung für die Änderungsanfrage an.'); } $entry = $this->getMemberEntry($listingId); if (!$entry || (string)($entry['status'] ?? '') !== 'published') { throw new \RuntimeException('Eintrag nicht gefunden.'); } if ($this->hasOpenModerationRequest($listingId, ['update'])) { throw new \RuntimeException('Für diesen Eintrag gibt es bereits eine offene Änderungsanfrage.'); } $proposal = $this->buildModerationPayload($data, $entry); $this->assertNoDuplicateEntry($proposal, $listingId); return $this->createModerationRequest($listingId, 'update', $userId, $reason, $proposal); } public function submitDeleteRequest(int $userId, int $listingId, string $reason): int { $this->ensureSchema(); $reason = trim($reason); if ($reason === '') { throw new \RuntimeException('Bitte gib eine Begründung für die Löschanfrage an.'); } $entry = $this->getMemberEntry($listingId); if (!$entry || (string)($entry['status'] ?? '') !== 'published') { throw new \RuntimeException('Eintrag nicht gefunden.'); } if ($this->hasOpenModerationRequest($listingId, ['delete'])) { throw new \RuntimeException('Für diesen Eintrag gibt es bereits eine offene Löschanfrage.'); } return $this->createModerationRequest($listingId, 'delete', $userId, $reason, null); } public function listOpenModerationRequests(): array { $this->ensureSchema(); $stmt = $this->pdo->query( 'SELECT mr.*, l.title, l.listing_type, l.status AS listing_status, lp.street, lp.zip, lp.city, lp.region, up.display_name AS requested_by_name FROM listing_moderation_requests mr INNER JOIN listings l ON l.id = mr.listing_id LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN user_profiles up ON up.user_id = mr.requested_by WHERE mr.request_status = "open" ORDER BY mr.created_at ASC, mr.id ASC' ); $rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : []; foreach ($rows as &$row) { $row['payload'] = $this->decodeJsonRows((string)($row['payload_json'] ?? '')); } unset($row); return $rows; } public function decideModerationRequest(int $reviewerUserId, int $requestId, string $decision, ?string $reviewNote = null): void { $this->ensureSchema(); $decision = $decision === 'approved' ? 'approved' : 'rejected'; $stmt = $this->pdo->prepare( 'SELECT mr.*, l.created_by, l.primary_place_id, l.image_path, l.listing_type, l.status AS listing_status FROM listing_moderation_requests mr INNER JOIN listings l ON l.id = mr.listing_id WHERE mr.id = :id AND mr.request_status = "open" LIMIT 1' ); $stmt->execute(['id' => $requestId]); $request = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$request) { throw new \RuntimeException('Moderationsanfrage nicht gefunden.'); } $payload = $this->decodeJsonRows((string)($request['payload_json'] ?? '')); $listingId = (int)$request['listing_id']; $createdBy = (int)($request['created_by'] ?? 0); $this->pdo->beginTransaction(); try { if ($decision === 'approved') { if ((string)$request['request_type'] === 'create') { $currentEntry = $this->getMemberEntry($listingId); if (!$currentEntry) { throw new \RuntimeException('Eintrag nicht gefunden.'); } $this->assertNoDuplicateEntry($currentEntry, $listingId); $this->pdo->prepare('UPDATE listings SET status = "published", updated_at = NOW() WHERE id = :id')->execute(['id' => $listingId]); if (!empty($request['primary_place_id'])) { $this->pdo->prepare('UPDATE listing_places SET status = "published", updated_at = NOW() WHERE id = :id')->execute(['id' => (int)$request['primary_place_id']]); } } elseif ((string)$request['request_type'] === 'update') { if ($payload === []) { throw new \RuntimeException('Für diese Änderungsanfrage fehlen die vorgeschlagenen Daten.'); } $this->assertNoDuplicateEntry($payload, $listingId); $this->saveDashboardEntry($createdBy, $payload, $listingId, 'published', 'published'); } elseif ((string)$request['request_type'] === 'delete') { $this->pdo->prepare('UPDATE listings SET status = "archived", updated_at = NOW() WHERE id = :id')->execute(['id' => $listingId]); if (!empty($request['primary_place_id'])) { $this->pdo->prepare('UPDATE listing_places SET status = "archived", updated_at = NOW() WHERE id = :id')->execute(['id' => (int)$request['primary_place_id']]); } } } elseif ((string)$request['request_type'] === 'create') { $this->pdo->prepare('UPDATE listings SET status = "archived", updated_at = NOW() WHERE id = :id')->execute(['id' => $listingId]); if (!empty($request['primary_place_id'])) { $this->pdo->prepare('UPDATE listing_places SET status = "archived", updated_at = NOW() WHERE id = :id')->execute(['id' => (int)$request['primary_place_id']]); } } $update = $this->pdo->prepare( 'UPDATE listing_moderation_requests SET request_status = :status, reviewed_by = :reviewedBy, review_note = :reviewNote, reviewed_at = NOW(), updated_at = NOW() WHERE id = :id' ); $update->execute([ 'status' => $decision, 'reviewedBy' => $reviewerUserId, 'reviewNote' => trim((string)$reviewNote) !== '' ? trim((string)$reviewNote) : null, 'id' => $requestId, ]); $this->pdo->commit(); } catch (\Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null, string $listingStatus = 'published', string $placeStatus = 'published'): int { $this->ensureSchema(); $entryType = (string)($data['entry_kind'] ?? 'editorial_event'); if (!in_array($entryType, ['place', 'editorial_event'], true)) { throw new \RuntimeException('Ungültiger Eintragstyp.'); } $title = trim((string)($data['title'] ?? '')); $teaser = trim((string)($data['teaser'] ?? '')); $description = trim((string)($data['description'] ?? '')); $openingHoursNote = trim((string)($data['opening_hours_note'] ?? '')); $openingHoursRows = $this->normalizeOpeningHoursRows($data); $visibility = (string)($data['visibility'] ?? 'public'); $categoryInput = trim((string)($data['category_input'] ?? $data['category_slug'] ?? '')); $placeKind = $categoryInput; $street = trim((string)($data['street'] ?? '')); $zip = trim((string)($data['zip'] ?? '')); $city = trim((string)($data['city'] ?? '')); $region = trim((string)($data['region'] ?? '')); $phone = trim((string)($data['phone'] ?? '')); $websiteUrl = trim((string)($data['website_url'] ?? '')); $lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null; $lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null; $startsAt = trim((string)($data['starts_at'] ?? '')); $recurrenceMode = (string)($data['recurrence_mode'] ?? 'single'); $recurrenceUntil = trim((string)($data['recurrence_until'] ?? '')); $weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? [])))); $specialConditionsNote = trim((string)($data['special_conditions_note'] ?? '')); $imagePath = trim((string)($data['image_path'] ?? '')); $capacityTotal = isset($data['capacity_total']) && $data['capacity_total'] !== '' ? (int)$data['capacity_total'] : null; if ($title === '' || $description === '') { throw new \RuntimeException('Bitte fülle Titel und Beschreibung aus.'); } if (!in_array($visibility, ['public', 'members'], true)) { $visibility = 'public'; } if ($entryType === 'editorial_event' && $startsAt === '') { throw new \RuntimeException('Bitte gib für sonstige Veranstaltungen ein Datum an.'); } if ($entryType === 'editorial_event' && $recurrenceUntil === '') { throw new \RuntimeException('Bitte gib für Veranstaltungen ein Gültig-bis-Datum an.'); } if (!in_array($listingStatus, ['draft', 'published', 'cancelled', 'archived'], true)) { $listingStatus = 'draft'; } if (!in_array($placeStatus, ['draft', 'published', 'archived'], true)) { $placeStatus = 'draft'; } $startsAt = $this->normalizeDateForStorage($startsAt, false); $recurrenceUntil = $this->normalizeDateForStorage($recurrenceUntil, true); $managesTransaction = !$this->pdo->inTransaction(); if ($managesTransaction) { $this->pdo->beginTransaction(); } try { $category = $this->ensureCategoryForInput($categoryInput, $entryType === 'place' ? 'place' : 'event'); $placeKind = (string)($category['slug'] ?? ''); $categoryId = isset($category['id']) ? (int)$category['id'] : null; if ($listingId !== null && $listingId > 0) { $existing = $this->getDashboardEntry($userId, $listingId); if (!$existing) { throw new \RuntimeException('Eintrag nicht gefunden.'); } $placeId = isset($existing['primary_place_id']) ? (int)$existing['primary_place_id'] : 0; if ($placeId > 0) { $placeStmt = $this->pdo->prepare( 'UPDATE listing_places SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, website_url = :websiteUrl, phone = :phone, place_kind = :placeKind, opening_hours_note = :openingHoursNote, opening_hours_json = :openingHoursJson, status = :placeStatus, updated_at = NOW() WHERE id = :id' ); $placeStmt->execute([ 'title' => $title, 'description' => $description, 'street' => $street !== '' ? $street : null, 'zip' => $zip !== '' ? $zip : null, 'city' => $city !== '' ? $city : null, 'region' => $region !== '' ? $region : null, 'lat' => $lat, 'lng' => $lng, 'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null, 'phone' => $phone !== '' ? $phone : null, 'placeKind' => $placeKind !== '' ? $placeKind : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, 'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, 'placeStatus' => $placeStatus, 'id' => $placeId, ]); } $listingStmt = $this->pdo->prepare( 'UPDATE listings SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, image_path = :imagePath, special_conditions_note = :specialConditionsNote, visibility = :visibility, supports_registration = 0, supports_capacity = :supportsCapacity, supports_pricing = :supportsPricing, is_recurring = :isRecurring, status = :listingStatus, updated_at = NOW() WHERE id = :id AND created_by = :uid' ); $listingStmt->execute([ 'listingType' => $entryType, 'title' => $title, 'teaser' => $teaser !== '' ? $teaser : $title, 'description' => $description, 'imagePath' => $imagePath !== '' ? $imagePath : null, 'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null, 'visibility' => $visibility, 'supportsCapacity' => $capacityTotal !== null ? 1 : 0, 'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0, 'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0, 'listingStatus' => $listingStatus, 'id' => $listingId, 'uid' => $userId, ]); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data); } else { $placeStmt = $this->pdo->prepare( 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, website_url, phone, place_kind, opening_hours_note, opening_hours_json, provider_hint, status, created_at, updated_at) VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :websiteUrl, :phone, :placeKind, :openingHoursNote, :openingHoursJson, "manual", :placeStatus, NOW(), NOW())' ); $placeStmt->execute([ 'uid' => $userId, 'title' => $title, 'description' => $description, 'street' => $street !== '' ? $street : null, 'zip' => $zip !== '' ? $zip : null, 'city' => $city !== '' ? $city : null, 'region' => $region !== '' ? $region : null, 'lat' => $lat, 'lng' => $lng, 'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null, 'phone' => $phone !== '' ? $phone : null, 'placeKind' => $placeKind !== '' ? $placeKind : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, 'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, 'placeStatus' => $placeStatus, ]); $placeId = (int)$this->pdo->lastInsertId(); $listingStmt = $this->pdo->prepare( 'INSERT INTO listings (created_by, owner_type, listing_type, primary_place_id, title, teaser_public, description, image_path, special_conditions_note, visibility, status, supports_registration, supports_capacity, supports_pricing, is_recurring, created_at, updated_at) VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :imagePath, :specialConditionsNote, :visibility, :listingStatus, 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())' ); $listingStmt->execute([ 'uid' => $userId, 'listingType' => $entryType, 'placeId' => $placeId, 'title' => $title, 'teaser' => $teaser !== '' ? $teaser : $title, 'description' => $description, 'imagePath' => $imagePath !== '' ? $imagePath : null, 'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null, 'visibility' => $visibility, 'listingStatus' => $listingStatus, 'supportsCapacity' => $capacityTotal !== null ? 1 : 0, 'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0, 'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0, ]); $listingId = (int)$this->pdo->lastInsertId(); $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data); } $this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); if ($categoryId !== null) { $mapStmt = $this->pdo->prepare('INSERT INTO listing_category_map (listing_id, category_id) VALUES (:listingId, :categoryId)'); $mapStmt->execute([ 'listingId' => $listingId, 'categoryId' => $categoryId, ]); } if ($managesTransaction) { $this->pdo->commit(); } return (int)$listingId; } catch (\Throwable $e) { if ($managesTransaction && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, ?int $capacityTotal, array $data): void { $occurrenceId = null; if ($entryType === 'editorial_event') { [$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues); $occStmt = $this->pdo->prepare( 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, capacity_total, status, created_at, updated_at) VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, :capacityTotal, "scheduled", NOW(), NOW())' ); $occStmt->execute([ 'listingId' => $listingId, 'occurrenceType' => $occurrenceType, 'startsAt' => $startsAt !== '' ? $startsAt : null, 'recurrenceRule' => $recurrenceRule, 'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null, 'capacityTotal' => $capacityTotal, ]); $occurrenceId = (int)$this->pdo->lastInsertId(); } $this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $priceRows = $this->normalizePriceRows($data); if ($priceRows === []) { return; } $insertPrice = $this->pdo->prepare( 'INSERT INTO listing_prices (listing_id, occurrence_id, label, audience, price_type, amount, amount_secondary, currency, note, created_at, updated_at) VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())' ); foreach ($priceRows as $row) { $insertPrice->execute([ 'listingId' => $listingId, 'occurrenceId' => $occurrenceId, 'label' => $row['label'], 'audience' => $row['audience'], 'priceType' => $row['price_type'], 'amount' => $row['amount'], 'amountSecondary' => $row['amount_secondary'], 'note' => $row['note'], ]); } } private function listPrices(int $listingId): array { $stmt = $this->pdo->prepare('SELECT * FROM listing_prices WHERE listing_id = :listingId ORDER BY id ASC'); $stmt->execute(['listingId' => $listingId]); return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } private function buildRecurrence(string $recurrenceMode, array $weekdayValues): array { $map = ['mo' => 'MO', 'di' => 'TU', 'mi' => 'WE', 'do' => 'TH', 'fr' => 'FR', 'sa' => 'SA', 'so' => 'SU']; if ($recurrenceMode === 'daily') { return ['series', 'FREQ=DAILY']; } if ($recurrenceMode === 'weekly') { return ['series', 'FREQ=WEEKLY']; } if ($recurrenceMode === 'weekdays') { return ['series', 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR']; } if ($recurrenceMode === 'custom_weekdays' && $weekdayValues !== []) { $days = []; foreach ($weekdayValues as $value) { if (isset($map[$value])) { $days[] = $map[$value]; } } if ($days !== []) { return ['series', 'FREQ=WEEKLY;BYDAY=' . implode(',', $days)]; } } return ['single', null]; } private function normalizeOpeningHoursRows(array $data): array { $labels = (array)($data['opening_day_label'] ?? []); $fromValues = (array)($data['opening_time_from'] ?? []); $toValues = (array)($data['opening_time_to'] ?? []); $notes = (array)($data['opening_note'] ?? []); $count = max(count($labels), count($fromValues), count($toValues), count($notes)); $rows = []; for ($index = 0; $index < $count; $index++) { $row = [ 'day_label' => trim((string)($labels[$index] ?? '')), 'time_from' => trim((string)($fromValues[$index] ?? '')), 'time_to' => trim((string)($toValues[$index] ?? '')), 'note' => trim((string)($notes[$index] ?? '')), ]; if ($row['day_label'] === '' && $row['time_from'] === '' && $row['time_to'] === '' && $row['note'] === '') { continue; } $rows[] = $row; } return $rows; } private function normalizePriceRows(array $data): array { $amounts = (array)($data['price_amount'] ?? []); $ageFroms = (array)($data['price_age_from'] ?? []); $ageTos = (array)($data['price_age_to'] ?? []); $count = max(count($amounts), count($ageFroms), count($ageTos)); $rows = []; for ($index = 0; $index < $count; $index++) { $amount = trim((string)($amounts[$index] ?? '')); $ageFrom = trim((string)($ageFroms[$index] ?? '')); $ageTo = trim((string)($ageTos[$index] ?? '')); if ($amount === '' && $ageFrom === '' && $ageTo === '') { continue; } $noteParts = []; if ($ageFrom !== '' || $ageTo !== '') { $ageText = []; if ($ageFrom !== '') { $ageText[] = 'ab ' . $ageFrom . ' Jahre'; } if ($ageTo !== '') { $ageText[] = 'bis ' . $ageTo . ' Jahre'; } $noteParts[] = implode(', ', $ageText); } $rows[] = [ 'label' => 'Preis', 'audience' => 'general', 'price_type' => $amount === '' ? 'request' : 'fixed', 'amount' => $amount !== '' ? (float)str_replace(',', '.', $amount) : null, 'amount_secondary' => null, 'note' => $noteParts !== [] ? implode(' | ', $noteParts) : null, ]; } return $rows; } private function hasPriceRows(array $data): bool { return $this->normalizePriceRows($data) !== []; } private function decodeJsonRows(string $json): array { if ($json === '') { return []; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } private function normalizeDateForStorage(string $value, bool $endOfDay): string { $value = trim($value); if ($value === '') { return ''; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); } return $value; } private function slugify(string $value): string { $value = mb_strtolower(trim($value)); $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: ''; return trim($value, '-'); } public function deleteDashboardEntry(int $userId, int $listingId): void { $this->ensureSchema(); $entry = $this->getDashboardEntry($userId, $listingId); if (!$entry) { throw new \RuntimeException('Eintrag nicht gefunden.'); } $this->pdo->beginTransaction(); try { $this->pdo->prepare('DELETE FROM listings WHERE id = :id AND created_by = :uid')->execute([ 'id' => $listingId, 'uid' => $userId, ]); if (!empty($entry['primary_place_id'])) { $this->pdo->prepare('DELETE FROM listing_places WHERE id = :id')->execute([ 'id' => (int)$entry['primary_place_id'], ]); } $this->pdo->commit(); } catch (\Throwable $e) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $e; } } private function createModerationRequest(int $listingId, string $requestType, int $requestedBy, ?string $reason, ?array $payload): int { $stmt = $this->pdo->prepare( 'INSERT INTO listing_moderation_requests (listing_id, request_type, request_status, requested_by, request_reason, payload_json) VALUES (:listingId, :requestType, "open", :requestedBy, :requestReason, :payloadJson)' ); $stmt->execute([ 'listingId' => $listingId, 'requestType' => $requestType, 'requestedBy' => $requestedBy, 'requestReason' => trim((string)$reason) !== '' ? trim((string)$reason) : null, 'payloadJson' => $payload !== null ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, ]); return (int)$this->pdo->lastInsertId(); } private function hasOpenModerationRequest(int $listingId, array $requestTypes): bool { $requestTypes = array_values(array_filter(array_map('strval', $requestTypes))); if ($requestTypes === []) { return false; } $placeholders = implode(',', array_fill(0, count($requestTypes), '?')); $params = array_merge([$listingId], $requestTypes); $stmt = $this->pdo->prepare( "SELECT id FROM listing_moderation_requests WHERE listing_id = ? AND request_status = 'open' AND request_type IN ($placeholders) LIMIT 1" ); $stmt->execute($params); return (bool)$stmt->fetchColumn(); } private function buildModerationPayload(array $data, array $existing): array { $payload = $data; $fieldMap = [ 'title' => 'title', 'description' => 'description', 'teaser' => 'teaser_public', 'street' => 'street', 'zip' => 'zip', 'city' => 'city', 'region' => 'region', 'lat' => 'lat', 'lng' => 'lng', 'visibility' => 'visibility', 'category_slug' => 'category_slug', 'image_path' => 'image_path', 'special_conditions_note' => 'special_conditions_note', 'phone' => 'phone', 'website_url' => 'website_url', 'opening_hours_note' => 'opening_hours_note', 'starts_at' => 'starts_at', 'recurrence_until' => 'recurrence_until', 'recurrence_rule' => 'recurrence_rule', 'entry_kind' => 'listing_type', ]; foreach ($fieldMap as $payloadKey => $existingKey) { if (!array_key_exists($payloadKey, $payload) || $payload[$payloadKey] === '') { $payload[$payloadKey] = $existing[$existingKey] ?? ''; } } if (!isset($payload['category_input']) || trim((string)$payload['category_input']) === '') { $payload['category_input'] = $existing['category_slug'] ?? ''; } if (!isset($payload['recurrence_mode']) || trim((string)$payload['recurrence_mode']) === '') { $payload['recurrence_mode'] = $this->recurrenceModeFromRule((string)($existing['recurrence_rule'] ?? '')); } if (!isset($payload['entry_kind']) || trim((string)$payload['entry_kind']) === '') { $payload['entry_kind'] = $existing['listing_type'] ?? 'editorial_event'; } if (!isset($payload['capacity_total']) && isset($existing['capacity_total'])) { $payload['capacity_total'] = $existing['capacity_total']; } return $payload; } private function recurrenceModeFromRule(string $rule): string { if (str_contains($rule, 'FREQ=DAILY')) { return 'daily'; } if (str_contains($rule, 'FREQ=WEEKLY')) { return 'weekly'; } return 'single'; } private function assertNoDuplicateEntry(array $data, ?int $excludeListingId = null): void { $title = mb_strtolower(trim((string)($data['title'] ?? ''))); $street = mb_strtolower(trim((string)($data['street'] ?? ''))); $zip = trim((string)($data['zip'] ?? '')); $city = mb_strtolower(trim((string)($data['city'] ?? ''))); if ($title === '' || ($street === '' && $zip === '' && $city === '')) { return; } $sql = 'SELECT l.id, l.title, lp.street, lp.zip, lp.city FROM listings l INNER JOIN listing_places lp ON lp.id = l.primary_place_id WHERE l.listing_type IN ("place","editorial_event") AND l.status IN ("draft","published") AND LOWER(TRIM(l.title)) = :title AND LOWER(TRIM(COALESCE(lp.street, ""))) = :street AND TRIM(COALESCE(lp.zip, "")) = :zip AND LOWER(TRIM(COALESCE(lp.city, ""))) = :city'; $params = [ 'title' => $title, 'street' => $street, 'zip' => $zip, 'city' => $city, ]; if ($excludeListingId !== null && $excludeListingId > 0) { $sql .= ' AND l.id <> :excludeId'; $params['excludeId'] = $excludeListingId; } $sql .= ' LIMIT 1'; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $duplicate = $stmt->fetch(\PDO::FETCH_ASSOC); if ($duplicate) { throw new \RuntimeException('Ein ähnlicher Ort oder eine ähnliche Veranstaltung mit gleichem Namen und gleicher Adresse ist bereits vorhanden.'); } } private function hasTable(string $table): bool { if (array_key_exists($table, $this->tableCache)) { return $this->tableCache[$table]; } try { $stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table LIMIT 1'); $stmt->execute(['table' => $table]); return $this->tableCache[$table] = (bool)$stmt->fetchColumn(); } catch (\Throwable) { return $this->tableCache[$table] = false; } } private function hasColumn(string $table, string $column): bool { $key = $table . '.' . $column; if (array_key_exists($key, $this->columnCache)) { return $this->columnCache[$key]; } try { $stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1'); $stmt->execute([ 'table' => $table, 'column' => $column, ]); return $this->columnCache[$key] = (bool)$stmt->fetchColumn(); } catch (\Throwable) { return $this->columnCache[$key] = false; } } private function defaultCategories(): array { return [ ['slug' => 'spielplatz', 'title' => 'Spielplatz', 'group' => 'place'], ['slug' => 'cafe', 'title' => 'Café', 'group' => 'food'], ['slug' => 'restaurant', 'title' => 'Restaurant', 'group' => 'food'], ['slug' => 'indoor-spielort', 'title' => 'Indoor-Spielort', 'group' => 'place'], ['slug' => 'zirkus', 'title' => 'Zirkus', 'group' => 'event'], ['slug' => 'huepfburgen', 'title' => 'Hüpfburgen', 'group' => 'event'], ['slug' => 'workshop', 'title' => 'Workshop', 'group' => 'event'], ['slug' => 'kurs', 'title' => 'Kurs', 'group' => 'event'], ['slug' => 'familienfest', 'title' => 'Familienfest', 'group' => 'family'], ]; } }