1359 lines
63 KiB
PHP
1359 lines
63 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 confirmCategory(string $sourceSlug): void
|
|
{
|
|
$this->ensureSchema();
|
|
$sourceSlug = trim($sourceSlug);
|
|
if ($sourceSlug === '') {
|
|
throw new \RuntimeException('Kategorie nicht gefunden.');
|
|
}
|
|
|
|
$lookup = $this->pdo->prepare('SELECT id, sort_order FROM listing_categories WHERE slug = :slug LIMIT 1');
|
|
$lookup->execute(['slug' => $sourceSlug]);
|
|
$source = $lookup->fetch(\PDO::FETCH_ASSOC);
|
|
if (!$source) {
|
|
throw new \RuntimeException('Kategorie nicht gefunden.');
|
|
}
|
|
if ((int)($source['sort_order'] ?? 0) < 900) {
|
|
return;
|
|
}
|
|
|
|
$maxStmt = $this->pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM listing_categories WHERE sort_order < 900');
|
|
$maxSortOrder = (int)($maxStmt?->fetchColumn() ?: 0);
|
|
$newSortOrder = max(1, $maxSortOrder + 1);
|
|
|
|
$update = $this->pdo->prepare('UPDATE listing_categories SET sort_order = :sortOrder, updated_at = NOW() WHERE id = :id');
|
|
$update->execute([
|
|
'sortOrder' => $newSortOrder,
|
|
'id' => (int)$source['id'],
|
|
]);
|
|
}
|
|
|
|
public function resolveExistingCategorySlug(string $input): ?string
|
|
{
|
|
$this->ensureSchema();
|
|
$input = trim($input);
|
|
if ($input === '') {
|
|
return null;
|
|
}
|
|
|
|
$stmt = $this->pdo->prepare(
|
|
'SELECT slug
|
|
FROM listing_categories
|
|
WHERE slug = :slug OR LOWER(title) = LOWER(:title)
|
|
ORDER BY sort_order ASC, id ASC
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([
|
|
'slug' => $input,
|
|
'title' => $input,
|
|
]);
|
|
$slug = $stmt->fetchColumn();
|
|
return $slug !== false ? (string)$slug : null;
|
|
}
|
|
|
|
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'],
|
|
];
|
|
}
|
|
}
|