change event
All checks were successful
Deploy / deploy (push) Successful in 54s

This commit is contained in:
2026-08-10 21:00:54 +02:00
parent d442b888a5
commit e0cc5989cc
13 changed files with 648 additions and 76 deletions

View File

@@ -141,6 +141,26 @@ final class ListingCatalog
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) {
@@ -186,6 +206,7 @@ final class ListingCatalog
'listing_occurrences',
'listing_prices',
'listing_benefits',
'listing_moderation_requests',
];
$missing = [];
foreach ($tables as $table) {
@@ -347,12 +368,12 @@ final class ListingCatalog
}
}
public function listDashboardEntries(int $userId): array
public function listPublishedDirectoryEntries(): 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,
$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
@@ -360,6 +381,46 @@ final class ListingCatalog
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
@@ -374,7 +435,12 @@ final class ListingCatalog
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function getDashboardEntry(int $userId, int $listingId): ?array
public function listDashboardEntries(int $userId): array
{
return $this->listUserSubmittedEntries($userId);
}
public function getMemberEntry(int $listingId): ?array
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
@@ -387,13 +453,10 @@ final class ListingCatalog
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.created_by = :uid
WHERE l.id = :id
LIMIT 1'
);
$stmt->execute([
'id' => $listingId,
'uid' => $userId,
]);
$stmt->execute(['id' => $listingId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
return null;
@@ -403,14 +466,26 @@ final class ListingCatalog
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 id, title, street, zip, city, region, place_kind
FROM listing_places
WHERE status = "published"
ORDER BY updated_at DESC
'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);
@@ -418,7 +493,170 @@ final class ListingCatalog
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null): int
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');
@@ -462,10 +700,19 @@ final class ListingCatalog
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);
$this->pdo->beginTransaction();
$managesTransaction = !$this->pdo->inTransaction();
if ($managesTransaction) {
$this->pdo->beginTransaction();
}
try {
$category = $this->ensureCategoryForInput($categoryInput, $entryType === 'place' ? 'place' : 'event');
$placeKind = (string)($category['slug'] ?? '');
@@ -482,7 +729,7 @@ final class ListingCatalog
'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, updated_at = NOW()
opening_hours_note = :openingHoursNote, opening_hours_json = :openingHoursJson, status = :placeStatus, updated_at = NOW()
WHERE id = :id'
);
$placeStmt->execute([
@@ -499,6 +746,7 @@ final class ListingCatalog
'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'placeStatus' => $placeStatus,
'id' => $placeId,
]);
}
@@ -507,7 +755,7 @@ final class ListingCatalog
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 = "published", updated_at = NOW()
is_recurring = :isRecurring, status = :listingStatus, updated_at = NOW()
WHERE id = :id AND created_by = :uid'
);
$listingStmt->execute([
@@ -521,6 +769,7 @@ final class ListingCatalog
'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,
]);
@@ -530,7 +779,7 @@ final class ListingCatalog
} 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", "published", NOW(), NOW())'
VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :websiteUrl, :phone, :placeKind, :openingHoursNote, :openingHoursJson, "manual", :placeStatus, NOW(), NOW())'
);
$placeStmt->execute([
'uid' => $userId,
@@ -547,12 +796,13 @@ final class ListingCatalog
'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, "published", 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())'
VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :imagePath, :specialConditionsNote, :visibility, :listingStatus, 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())'
);
$listingStmt->execute([
'uid' => $userId,
@@ -564,6 +814,7 @@ final class ListingCatalog
'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,
@@ -582,10 +833,12 @@ final class ListingCatalog
]);
}
$this->pdo->commit();
if ($managesTransaction) {
$this->pdo->commit();
}
return (int)$listingId;
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
if ($managesTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
@@ -792,6 +1045,131 @@ final class ListingCatalog
}
}
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)) {