Files
papa-kind-treff.info/src/App/ListingCatalog.php
Lars Gebhardt-Kusche 016cf17971
All checks were successful
Deploy / deploy (push) Successful in 55s
dsad
2026-08-11 21:58:47 +02:00

1307 lines
61 KiB
PHP

<?php
declare(strict_types=1);
namespace App;
final class ListingCatalog
{
private array $tableCache = [];
private array $columnCache = [];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
$statements = [
'CREATE TABLE IF NOT EXISTS listing_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(160) NOT NULL,
category_group ENUM("general","event","place","food","family","partner") NOT NULL DEFAULT "general",
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_places (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
source_type ENUM("user","partner","admin","system") NOT NULL DEFAULT "user",
title VARCHAR(180) NOT NULL,
description TEXT NULL,
street VARCHAR(180) NULL,
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
website_url VARCHAR(255) NULL,
phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL,
opening_hours_json LONGTEXT NULL,
provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual",
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
rating_value DECIMAL(3,2) NULL,
rating_count INT UNSIGNED NULL,
status ENUM("draft","published","archived") NOT NULL DEFAULT "published",
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_places_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_listing_places_city (city),
INDEX idx_listing_places_region (region),
INDEX idx_listing_places_latlng (lat, lng),
INDEX idx_listing_places_kind (place_kind)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
owner_type ENUM("user","partner","admin","system") NOT NULL DEFAULT "user",
listing_type ENUM("event","partner_offer","place","editorial_event") NOT NULL DEFAULT "event",
primary_place_id BIGINT UNSIGNED NULL,
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM("public","members") NOT NULL DEFAULT "public",
status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft",
supports_registration TINYINT(1) NOT NULL DEFAULT 0,
supports_capacity TINYINT(1) NOT NULL DEFAULT 0,
supports_pricing TINYINT(1) NOT NULL DEFAULT 0,
is_recurring TINYINT(1) NOT NULL DEFAULT 0,
legacy_event_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listings_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_place FOREIGN KEY (primary_place_id) REFERENCES listing_places(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_legacy_event FOREIGN KEY (legacy_event_id) REFERENCES events(id) ON DELETE SET NULL,
INDEX idx_listings_type (listing_type),
INDEX idx_listings_status (status),
INDEX idx_listings_owner (owner_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_category_map (
listing_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (listing_id, category_id),
CONSTRAINT fk_lcm_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_lcm_category FOREIGN KEY (category_id) REFERENCES listing_categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_occurrences (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_type ENUM("single","series","range","open_ended") NOT NULL DEFAULT "single",
starts_at DATETIME NULL,
ends_at DATETIME NULL,
recurrence_rule VARCHAR(255) NULL,
recurrence_until DATETIME NULL,
capacity_total SMALLINT UNSIGNED NULL,
booking_url VARCHAR(255) NULL,
status ENUM("scheduled","cancelled","sold_out") NOT NULL DEFAULT "scheduled",
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_occurrences_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
INDEX idx_listing_occurrences_start (starts_at),
INDEX idx_listing_occurrences_type (occurrence_type),
INDEX idx_listing_occurrences_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
label VARCHAR(120) NOT NULL,
audience ENUM("general","adult","child","family","group") NOT NULL DEFAULT "general",
price_type ENUM("free","fixed","from","up_to","range","request") NOT NULL DEFAULT "fixed",
amount DECIMAL(10,2) NULL,
amount_secondary DECIMAL(10,2) NULL,
currency CHAR(3) NOT NULL DEFAULT "EUR",
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_prices_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_prices_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_prices_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_benefits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
benefit_type ENUM("voucher_code","voucher_hint","discount_text") NOT NULL DEFAULT "voucher_hint",
title VARCHAR(160) NOT NULL,
code VARCHAR(120) NULL,
description TEXT NULL,
valid_from DATETIME NULL,
valid_until DATETIME NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_benefits_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_benefits_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_benefits_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_moderation_requests (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
request_type ENUM("create","update","delete") NOT NULL,
request_status ENUM("open","approved","rejected") NOT NULL DEFAULT "open",
requested_by BIGINT UNSIGNED NOT NULL,
reviewed_by BIGINT UNSIGNED NULL,
request_reason TEXT NULL,
review_note TEXT NULL,
payload_json LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_moderation_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_moderation_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_moderation_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_listing_moderation_status (request_status),
INDEX idx_listing_moderation_type (request_type),
INDEX idx_listing_moderation_user (requested_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
];
foreach ($statements as $sql) {
$this->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'],
];
}
}