This commit is contained in:
@@ -258,6 +258,27 @@ CREATE TABLE listing_benefits (
|
||||
INDEX idx_listing_benefits_listing (listing_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE 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;
|
||||
|
||||
-- Community / Forum
|
||||
CREATE TABLE forum_categories (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -16,16 +16,21 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Seiten-Admins erhalten eine eigene System-Sektion für globale Betriebs- und Diensteschalter.
|
||||
|
||||
## Konto / Mitgliederbereich
|
||||
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Ausflugsziele`, `Community`, `Einstellungen`, `Abmelden`
|
||||
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden`
|
||||
- Kinder können angelegt, bearbeitet und gelöscht werden.
|
||||
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert.
|
||||
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
|
||||
- Profiladresse kann per Suche oder Browser-Standort übernommen werden.
|
||||
- Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt.
|
||||
- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen.
|
||||
- Der Bereich `Events` ist im UI in `Meine Events`, `Synchronisation` und `Abgelaufene Events` getrennt, damit aktive Events, Kalender-Themen und Vergangenes sauber getrennt bleiben.
|
||||
- Der Bereich `Events` ist im UI in `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` getrennt, damit aktive Events, Kalender-Themen und Vergangenes sauber getrennt bleiben.
|
||||
- In `Meine Events` sitzt der Button `Neues Event anlegen` direkt in der Box `Eigene Events`; die bisherige Teilnahme-Box heißt jetzt `Angemeldete Events` und enthält zusätzlich einen direkten Link zur `Event Suche`.
|
||||
- Orte und Veranstaltungen als Ausflugsziele sind vorerst in einen separaten Mitgliederbereichspunkt `Ausflugsziele` verschoben.
|
||||
- Orte und Veranstaltungen sind vorerst in einen separaten Mitgliederbereichspunkt `Orte & Veranstaltungen` verschoben.
|
||||
- In `Orte & Veranstaltungen` können Nutzer dauerhafte Orte und zeitlich begrenzte Veranstaltungen neu anlegen.
|
||||
- Neue Orte und Veranstaltungen bleiben bis zur Freigabe im Status `wartet auf Freigabe` und werden erst danach systemweit veröffentlicht.
|
||||
- Änderungs- und Löschwünsche für veröffentlichte Orte und Veranstaltungen laufen immer über eine begründete Moderationsanfrage.
|
||||
- Vorschläge für neue Orte und Veranstaltungen dürfen schon vor der Freigabe als Ortsvorschlag für Events genutzt werden, solange sie noch offen geprüft werden.
|
||||
- Dubletten bei Orten und Veranstaltungen sollen serverseitig mindestens über Namen und Adresse abgefangen werden.
|
||||
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder.
|
||||
- Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.
|
||||
- Eigene Events unterstützen Kategorie, optionales Bild, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung.
|
||||
|
||||
@@ -21,8 +21,9 @@ Stand: 2026-08-10
|
||||
- System-Einstellungen: `src/App/SystemSettings.php`
|
||||
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
|
||||
- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php`
|
||||
- UI für getrennte Bereiche `Events` und `Ausflugsziele` im Mitgliederbereich: `partials/landing/account/dashboard.php`
|
||||
- Preis-, Bild- und Ortseingabe für Ausflugsziele sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
|
||||
- UI für getrennte Bereiche `Events` und `Orte & Veranstaltungen` im Mitgliederbereich: `partials/landing/account/dashboard.php`
|
||||
- Preis-, Bild- und Ortseingabe für Orte und Veranstaltungen sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
|
||||
- Freigaben sowie Änderungs- und Löschanfragen für Orte und Veranstaltungen: `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`, `partials/landing/account/community-admin.php`
|
||||
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `Internal/db/schema.sql` um Kategorie- und Bildfelder
|
||||
- Kategorien-Prüfung und Zusammenführung für Seiten-Admins liegen ebenfalls in `partials/landing/account/dashboard.php` mit Logik in `src/App/ListingCatalog.php`
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
- Community-Admin-Bereich für Bewerbungen, Meldungen, Rollen und Migration
|
||||
- standortbasierte Sortierung für die neuesten Events
|
||||
- Mitgliederbereich mit linker Bereichsnavigation
|
||||
- Profil-Menü mit `Profil`, `Kinder`, `Events`, `Ausflugsziele`, `Community`, `Einstellungen`
|
||||
- Profil-Menü mit `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`
|
||||
- Konto-E-Mails sowie sensible Profilfelder werden app-seitig verschlüsselt gespeichert
|
||||
- Kinder können angelegt, bearbeitet und gelöscht werden
|
||||
- Profiladresse mit verschlüsselter Straße/Hausnummer, Adresssuche, Browser-Übernahme und Validierung
|
||||
@@ -24,7 +24,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
|
||||
- Mitgliederbereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen fokussiert
|
||||
- Eigene Events haben jetzt Kategorie, optionales Bild, klare Kinderangabe, optionale Platzzahl und eine direktere Karten-/Adress-Synchronisierung
|
||||
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Ausflugsziele` verschoben
|
||||
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Orte & Veranstaltungen` verschoben
|
||||
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls nur als begründete Anfrage
|
||||
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
|
||||
- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden
|
||||
- Im Bereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
|
||||
|
||||
@@ -16,16 +16,21 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- Site admins get a dedicated system section for global operating and service flags.
|
||||
|
||||
## Account / Member Area
|
||||
- Profile menu currently contains `Profile`, `Children`, `Events`, `Outings`, `Community`, `Settings`, `Logout`.
|
||||
- Profile menu currently contains `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings`, `Logout`.
|
||||
- Children can be created, edited, and deleted.
|
||||
- If a birth date is set, age is calculated automatically and updated later when needed.
|
||||
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
|
||||
- Profile address can be completed via address search or browser-based location import.
|
||||
- The `Events` area is limited again to real own events and event participations.
|
||||
- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations.
|
||||
- The `Events` area is now separated in the UI into `My Events`, `Synchronization`, and `Expired Events` so that active events, calendar tasks, and past items stay clearly separated.
|
||||
- The `Events` area is now separated in the UI into `My Events`, `Calendar Sync`, and `Expired Events` so that active events, calendar tasks, and past items stay clearly separated.
|
||||
- Inside `My Events`, the `Create New Event` button now lives directly in the `Own Events` box; the previous participation box is now labeled `Registered Events` and also includes a direct link to `Event Search`.
|
||||
- Places and event-like outing targets were moved for now into a separate member-area section `Outings`.
|
||||
- Places and event-like entries were moved for now into a separate member-area section `Places & Events`.
|
||||
- Inside `Places & Events`, users can create permanent places and time-limited event-style entries.
|
||||
- New places and event-style entries stay in a pending state until an admin approves them for publication.
|
||||
- Change and deletion wishes for published places and event-style entries always run through a reasoned moderation request.
|
||||
- Pending new places and event-style entries can already be used as location suggestions for events while they are still under review.
|
||||
- Duplicate submissions for places and event-style entries should be blocked server-side at least by name and address.
|
||||
- The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events.
|
||||
- Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed.
|
||||
- Own events support category, optional image, a clear `with children` yes/no field, optional capacity, and direct map/address synchronization.
|
||||
|
||||
@@ -21,8 +21,9 @@ Updated: 2026-08-10
|
||||
- system settings: `src/App/SystemSettings.php`
|
||||
- new listing/place base: `src/App/ListingCatalog.php`
|
||||
- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php`
|
||||
- separated `Events` and `Outings` UI in member area: `partials/landing/account/dashboard.php`
|
||||
- pricing, image upload, and place/address input for outings plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
|
||||
- separated `Events` and `Places & Events` UI in member area: `partials/landing/account/dashboard.php`
|
||||
- pricing, image upload, and place/address input for places and event-style entries plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
|
||||
- approvals as well as change and deletion requests for places and event-style entries: `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php`, `partials/landing/account/community-admin.php`
|
||||
- legacy own events were extended in `src/App/AccountPages.php` and `Internal/db/schema.sql` with category and image fields
|
||||
|
||||
## Documentation Rule
|
||||
|
||||
@@ -16,7 +16,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
- community admin area for applications, reports, roles, and migrations
|
||||
- location-based ordering for the newest events
|
||||
- member area with left-side section navigation
|
||||
- profile menu with `Profile`, `Children`, `Events`, `Outings`, `Community`, `Settings`
|
||||
- profile menu with `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings`
|
||||
- account emails and sensitive profile fields are stored encrypted application-side
|
||||
- children can be created, edited, and deleted
|
||||
- profile address supports encrypted street/house number, address search, browser import, and validation
|
||||
@@ -24,7 +24,8 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
- new data foundation prepared for a broader event, place, and listing system
|
||||
- member area `Events` is now limited again to real own events and event participations
|
||||
- own events now include category, optional image, explicit child suitability, optional capacity, and more direct map/address synchronization
|
||||
- places and other events were moved for now into a separate member-area section `Outings`
|
||||
- places and other event-style entries were moved for now into a separate member-area section `Places & Events`
|
||||
- new places and event-style entries must be approved by an admin before publication; change and deletion wishes also run as reasoned requests
|
||||
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
|
||||
- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area
|
||||
- the `Events` area now also includes an ICS download and a personal subscribable calendar feed for all own events and event participations
|
||||
|
||||
@@ -36,16 +36,18 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
|
||||
- Primärbegriff im Produkt: `Events`
|
||||
- `Termine` und `Treffen` ergänzend in SEO- und Erklärungstexten
|
||||
- Hauptnavigation aktuell: `Home`, `Event Suche`, `Community`
|
||||
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Ausflugsziele`, `Community`, `Einstellungen`, `Abmelden`
|
||||
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden`
|
||||
- neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`
|
||||
- Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter
|
||||
- Im Mitgliederbereich ist `Events` jetzt wieder auf echte eigene Events und Event-Teilnahmen beschränkt
|
||||
- Orte und sonstige Veranstaltungen laufen jetzt separat unter `Ausflugsziele`
|
||||
- Orte und sonstige Veranstaltungen laufen jetzt separat unter `Orte & Veranstaltungen`
|
||||
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls als begründete Anfrage
|
||||
- Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung
|
||||
- Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen
|
||||
- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar
|
||||
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
|
||||
- Der Bereich `Events` ist im Mitgliederbereich jetzt zusätzlich in die Tabs `Meine Events`, `Synchronisation` und `Abgelaufene Events` gegliedert
|
||||
- Der Bereich `Events` ist im Mitgliederbereich jetzt zusätzlich in die Tabs `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` gegliedert
|
||||
- Vorschläge für neue Orte und Veranstaltungen werden schon vor der Freigabe als mögliche Ortsauswahl für Events berücksichtigt, solange sie nicht abgelehnt oder archiviert wurden
|
||||
- Für später vorgemerkt: direkte Kalender-Anbindung großer Anbieter wie Google und Microsoft/Outlook per OAuth, zusätzlich zum bestehenden ICS-Feed
|
||||
- Interne Projektdateien und das Datenbankschema liegen nicht mehr im Root, sondern unter `Internal/`
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ if (!$userId) {
|
||||
$communityCfg = require __DIR__ . '/../../../config/community.php';
|
||||
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
|
||||
$migration = $pdo ? new \App\CommunityMigration($pdo) : null;
|
||||
$listingCatalog = $pdo ? new \App\ListingCatalog($pdo) : null;
|
||||
|
||||
if (!$access || !$access->canModerateForum((int)$userId)) {
|
||||
http_response_code(403);
|
||||
@@ -23,11 +24,18 @@ $error = '';
|
||||
$info = '';
|
||||
$canManageApplications = $access->canManageApplications((int)$userId) && $access->supportsApplications();
|
||||
$canManageRoles = $access->canManageRoles((int)$userId);
|
||||
$canReviewListings = $listingCatalog !== null && !$access->hasRole((int)$userId, 'owner');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
try {
|
||||
if ($action === 'application_decide') {
|
||||
if ($action === 'listing_request_decide') {
|
||||
if (!$canReviewListings || !$listingCatalog) {
|
||||
throw new \RuntimeException('Keine Berechtigung für Listing-Freigaben.');
|
||||
}
|
||||
$listingCatalog->decideModerationRequest((int)$userId, (int)($_POST['request_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['review_note'] ?? ''));
|
||||
$info = 'Listing-Anfrage wurde bearbeitet.';
|
||||
} elseif ($action === 'application_decide') {
|
||||
$access->decideApplication((int)$userId, (int)($_POST['application_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['decision_reason'] ?? ''));
|
||||
$info = 'Bewerbung wurde bearbeitet.';
|
||||
} elseif ($action === 'report_resolve') {
|
||||
@@ -51,6 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
|
||||
$listingRequests = $canReviewListings && $listingCatalog ? $listingCatalog->listOpenModerationRequests() : [];
|
||||
$applications = $canManageApplications ? $access->listApplications('open') : [];
|
||||
$reports = $access->supportsReports() ? $access->listOpenReports() : [];
|
||||
$roleAssignments = $canManageRoles ? $access->listRoleAssignments() : [];
|
||||
@@ -84,6 +93,70 @@ $migrationStatus = ($migration && $canManageApplications) ? $migration->status()
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="forum-admin-grid">
|
||||
<?php if ($canReviewListings): ?>
|
||||
<section class="forum-board">
|
||||
<div class="forum-board__head">
|
||||
<div>
|
||||
<h2>Offene Orts- und Veranstaltungsanfragen</h2>
|
||||
<p class="muted">Neue Einträge sowie Änderungs- und Löschwünsche prüfen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="forum-admin-list">
|
||||
<?php foreach ($listingRequests as $request): ?>
|
||||
<?php
|
||||
$payload = is_array($request['payload'] ?? null) ? $request['payload'] : [];
|
||||
$requestTypeLabel = match ((string)($request['request_type'] ?? '')) {
|
||||
'create' => 'Neu',
|
||||
'update' => 'Änderung',
|
||||
'delete' => 'Löschung',
|
||||
default => 'Anfrage',
|
||||
};
|
||||
?>
|
||||
<article class="forum-admin-item">
|
||||
<div>
|
||||
<strong><?= htmlspecialchars((string)$requestTypeLabel, ENT_QUOTES) ?> · <?= htmlspecialchars((string)$request['title'], ENT_QUOTES) ?></strong>
|
||||
<p class="muted small">Typ: <?= htmlspecialchars((string)(($request['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung'), ENT_QUOTES) ?> · Von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?> · am <?= htmlspecialchars((string)$request['created_at'], ENT_QUOTES) ?></p>
|
||||
<?php if (!empty($request['request_reason'])): ?>
|
||||
<p><strong>Begründung:</strong><br><?= nl2br(htmlspecialchars((string)$request['request_reason'], ENT_QUOTES)) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="muted small">
|
||||
<?= htmlspecialchars(trim(implode(' · ', array_filter([
|
||||
trim(implode(', ', array_filter([
|
||||
(string)($request['street'] ?? ''),
|
||||
trim((string)($request['zip'] ?? '') . ' ' . (string)($request['city'] ?? '')),
|
||||
]))),
|
||||
(string)($request['region'] ?? ''),
|
||||
]))), ENT_QUOTES) ?>
|
||||
</p>
|
||||
<?php if ($payload !== []): ?>
|
||||
<div class="muted small" style="margin-top:8px;">
|
||||
Vorgeschlagener Titel: <?= htmlspecialchars((string)($payload['title'] ?? $request['title']), ENT_QUOTES) ?><br>
|
||||
Vorgeschlagene Adresse: <?= htmlspecialchars(trim(implode(', ', array_filter([
|
||||
(string)($payload['street'] ?? ''),
|
||||
trim((string)($payload['zip'] ?? '') . ' ' . (string)($payload['city'] ?? '')),
|
||||
(string)($payload['region'] ?? ''),
|
||||
]))), ENT_QUOTES) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="post" class="forum-admin-item__actions">
|
||||
<input type="hidden" name="action" value="listing_request_decide">
|
||||
<input type="hidden" name="request_id" value="<?= (int)$request['id'] ?>">
|
||||
<textarea name="review_note" class="textarea" rows="3" placeholder="Hinweis für die Entscheidung"></textarea>
|
||||
<div class="flex gap-12">
|
||||
<button class="btn" type="submit" name="decision" value="approved">Freigeben</button>
|
||||
<button class="btn ghost" type="submit" name="decision" value="rejected">Ablehnen</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$listingRequests): ?>
|
||||
<div class="forum-empty">Keine offenen Orts- oder Veranstaltungsanfragen.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canManageApplications): ?>
|
||||
<section class="forum-board">
|
||||
<div class="forum-board__head">
|
||||
|
||||
@@ -18,8 +18,8 @@ if ($editingEvent && !empty($editEvent['starts_at'])) {
|
||||
$startVal = date('Y-m-d', strtotime((string)$editListing['starts_at']));
|
||||
}
|
||||
$withChildrenValue = $editingEvent ? (((int)($editEvent['allow_kids'] ?? 1)) === 1 ? 'yes' : 'no') : (($editingListing && $currentEntryKind === 'editorial_event' && !empty($editListing['supports_registration'])) ? 'yes' : 'yes');
|
||||
$eventTitle = $editingEvent ? 'Event bearbeiten' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort bearbeiten' : 'Veranstaltung bearbeiten') : ((($section ?? 'events') === 'places') ? 'Ausflugsziel anlegen' : 'Neues Event anlegen'));
|
||||
$submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort speichern' : 'Veranstaltung speichern') : ((($section ?? 'events') === 'places') ? 'Ausflugsziel anlegen' : 'Event anlegen'));
|
||||
$eventTitle = $editingEvent ? 'Event bearbeiten' : ($editingListing ? ($currentEntryKind === 'place' ? 'Änderung für Ort anfragen' : 'Änderung für Veranstaltung anfragen') : ((($section ?? 'events') === 'places') ? 'Ort oder Veranstaltung anlegen' : 'Neues Event anlegen'));
|
||||
$submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? 'Änderungsanfrage senden' : ((($section ?? 'events') === 'places') ? 'Eintrag zur Freigabe einreichen' : 'Event anlegen'));
|
||||
$titleValue = $editingEvent ? (string)($editEvent['title'] ?? '') : (string)($editListing['title'] ?? '');
|
||||
$teaserValue = $editingEvent ? (string)($editEvent['teaser_public'] ?? '') : (string)($editListing['teaser_public'] ?? '');
|
||||
$descriptionValue = $editingEvent ? (string)($editEvent['description'] ?? '') : (string)($editListing['description'] ?? '');
|
||||
@@ -87,7 +87,7 @@ $sectionLinks = [
|
||||
'profile' => 'Profil',
|
||||
'children' => 'Kinder',
|
||||
'events' => 'Events',
|
||||
'places' => 'Ausflugsziele',
|
||||
'places' => 'Orte & Veranstaltungen',
|
||||
'community' => 'Community',
|
||||
'settings' => 'Einstellungen',
|
||||
];
|
||||
@@ -279,7 +279,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
<div class="account-panel__body">
|
||||
<div class="account-inline-tabs" role="tablist" aria-label="Event-Bereiche">
|
||||
<button class="account-inline-tabs__button is-active" type="button" role="tab" aria-selected="true" data-event-tab-trigger="my-events">Meine Events</button>
|
||||
<button class="account-inline-tabs__button" type="button" role="tab" aria-selected="false" data-event-tab-trigger="sync">Synchronisation</button>
|
||||
<button class="account-inline-tabs__button" type="button" role="tab" aria-selected="false" data-event-tab-trigger="sync">Kalendersynchronisation</button>
|
||||
<button class="account-inline-tabs__button" type="button" role="tab" aria-selected="false" data-event-tab-trigger="past-events">Abgelaufene Events</button>
|
||||
</div>
|
||||
|
||||
@@ -463,40 +463,94 @@ if (!empty($canManageSystemSettings)) {
|
||||
<?php if ($section === 'places'): ?>
|
||||
<section class="account-panel" id="places">
|
||||
<div class="account-panel__head">
|
||||
<h2>Ausflugsziele</h2>
|
||||
<p class="muted">Hier pflegst du Orte und sonstige Veranstaltungen als mögliche Ausflugsziele.</p>
|
||||
<h2>Orte & Veranstaltungen</h2>
|
||||
<p class="muted">Hier legst du dauerhafte Orte wie Cafés oder Spielplätze sowie zeitlich begrenzte Veranstaltungen an.</p>
|
||||
</div>
|
||||
<div class="account-panel__body">
|
||||
<div class="flex gap-12" style="margin:0 0 16px 0; flex-wrap: wrap;">
|
||||
<button class="btn" type="button" data-modal-open="modalEvent">Ausflugsziel anlegen</button>
|
||||
<button class="btn" type="button" data-modal-open="modalEvent">Ort oder Veranstaltung anlegen</button>
|
||||
</div>
|
||||
<?php if (!$otherListings): ?>
|
||||
<p class="muted small">Noch keine Orte oder Veranstaltungen angelegt.</p>
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<strong>So ist dieser Bereich gedacht</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Orte sind feste, grundsätzlich verfügbare Ziele. Veranstaltungen sind zeitlich begrenzte Hinweise wie Kirmes, Aktionstage oder Pop-up-Angebote.</p>
|
||||
<p class="muted small" style="margin:8px 0 0;">Neue Einträge werden erst nach Freigabe durch einen Admin sichtbar. Änderungs- und Löschwünsche laufen ebenfalls immer über eine begründete Anfrage.</p>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<strong>Deine eingereichten Vorschläge</strong>
|
||||
<?php
|
||||
$pendingListings = array_values(array_filter($userSubmittedListings ?? [], static function (array $entry): bool {
|
||||
return (string)($entry['status'] ?? '') !== 'published';
|
||||
}));
|
||||
?>
|
||||
<?php if (!$pendingListings): ?>
|
||||
<p class="muted small" style="margin:8px 0 0;">Aktuell warten keine eigenen Orte oder Veranstaltungen auf Prüfung.</p>
|
||||
<?php else: ?>
|
||||
<ul class="dash-list" style="margin-top:12px;">
|
||||
<?php foreach ($pendingListings as $entry): ?>
|
||||
<li>
|
||||
<strong><?= htmlspecialchars((string)$entry['title'], ENT_QUOTES) ?></strong>
|
||||
<span class="badge"><?= ($entry['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung' ?></span>
|
||||
<span class="badge" style="background:#fff7e6; color:#8a5a00;">
|
||||
<?= (string)($entry['moderation_request_status'] ?? '') === 'rejected' ? 'Abgelehnt' : 'Wartet auf Freigabe' ?>
|
||||
</span>
|
||||
<?php if (!empty($entry['moderation_review_note'])): ?>
|
||||
<div class="muted small" style="margin-top:6px;">Admin-Hinweis: <?= htmlspecialchars((string)$entry['moderation_review_note'], ENT_QUOTES) ?></div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!$directoryListings): ?>
|
||||
<p class="muted small">Noch keine freigegebenen Orte oder Veranstaltungen vorhanden.</p>
|
||||
<?php else: ?>
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<strong>Freigegebene Orte & Veranstaltungen</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Hier siehst du die systemweit verfügbaren Einträge. Für Änderungen oder Löschungen ist immer eine begründete Anfrage nötig.</p>
|
||||
</div>
|
||||
<ul class="dash-list" style="margin-top:10px;">
|
||||
<?php foreach ($otherListings as $entry): ?>
|
||||
<?php foreach ($directoryListings as $entry): ?>
|
||||
<?php $openRequestsForEntry = $listingRequestMap[(int)$entry['id']] ?? []; ?>
|
||||
<li>
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; flex-wrap: wrap;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div>
|
||||
<strong><?= htmlspecialchars((string)$entry['title'], ENT_QUOTES) ?></strong>
|
||||
<span class="badge"><?= ($entry['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung' ?></span>
|
||||
<?php if (!empty($entry['category_title'])): ?>
|
||||
<span class="badge"><?= htmlspecialchars((string)$entry['category_title'], ENT_QUOTES) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array('update', $openRequestsForEntry, true)): ?>
|
||||
<span class="badge" style="background:#fff7e6; color:#8a5a00;">Änderung angefragt</span>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array('delete', $openRequestsForEntry, true)): ?>
|
||||
<span class="badge" style="background:#fee2e2; color:#991b1b;">Löschung angefragt</span>
|
||||
<?php endif; ?>
|
||||
<div class="muted small" style="margin-top:4px;">
|
||||
<?= htmlspecialchars(trim(implode(' · ', array_filter([
|
||||
(string)($entry['city'] ?? ''),
|
||||
trim(implode(', ', array_filter([
|
||||
(string)($entry['street'] ?? ''),
|
||||
trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')),
|
||||
]))),
|
||||
(string)($entry['region'] ?? ''),
|
||||
!empty($entry['starts_at']) ? (string)$entry['starts_at'] : null,
|
||||
]))), ENT_QUOTES) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-8" style="flex-wrap: wrap;">
|
||||
<a class="btn ghost" href="/dashboard?section=places&edit_listing=<?= (int)$entry['id'] ?>#places">Bearbeiten</a>
|
||||
<form method="post" action="/dashboard?section=places#places" onsubmit="return confirm('Eintrag wirklich löschen?');">
|
||||
<input type="hidden" name="action" value="listing_delete">
|
||||
<div class="stack gap-8" style="min-width:min(100%, 320px);">
|
||||
<div class="flex gap-8" style="flex-wrap: wrap;">
|
||||
<?php if (!in_array('update', $openRequestsForEntry, true)): ?>
|
||||
<a class="btn ghost" href="/dashboard?section=places&edit_listing=<?= (int)$entry['id'] ?>#places">Änderung anfragen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!in_array('delete', $openRequestsForEntry, true)): ?>
|
||||
<form method="post" action="/dashboard?section=places#places" class="stack gap-6">
|
||||
<input type="hidden" name="action" value="listing_delete_request">
|
||||
<input type="hidden" name="listing_id" value="<?= (int)$entry['id'] ?>">
|
||||
<button class="btn ghost" type="submit">Löschen</button>
|
||||
<label class="label" for="deleteReason<?= (int)$entry['id'] ?>">Löschanfrage begründen</label>
|
||||
<textarea id="deleteReason<?= (int)$entry['id'] ?>" name="moderation_reason" class="textarea" rows="2" placeholder="Warum sollte dieser Eintrag entfernt werden?" required></textarea>
|
||||
<button class="btn ghost" type="submit">Löschung anfragen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1131,6 +1185,15 @@ if (!empty($canManageSystemSettings)) {
|
||||
<option value="weekly" <?= $recurrenceModeValue === 'weekly' ? 'selected' : '' ?>>Wöchentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php if ($editingListing): ?>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="listingModerationReason">Begründung für die Änderungsanfrage</label>
|
||||
<textarea id="listingModerationReason" name="moderation_reason" class="textarea" rows="3" placeholder="Was stimmt nicht oder was soll angepasst werden?" required></textarea>
|
||||
<p class="muted small" style="margin:0;">Die Änderung wird erst nach Prüfung durch einen Admin freigegeben.</p>
|
||||
</div>
|
||||
<?php elseif (($section ?? '') === 'places'): ?>
|
||||
<p class="muted small" style="margin:0;">Neue Orte und Veranstaltungen werden erst nach Prüfung durch einen Admin freigegeben.</p>
|
||||
<?php endif; ?>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="evZip">PLZ</label>
|
||||
|
||||
@@ -62,6 +62,7 @@ if ($isLoggedIn) {
|
||||
<a href="/dashboard?section=profile" role="menuitem">Profil</a>
|
||||
<a href="/dashboard?section=children" role="menuitem">Kinder</a>
|
||||
<a href="/dashboard?section=events" role="menuitem">Events</a>
|
||||
<a href="/dashboard?section=places" role="menuitem">Orte & Veranstaltungen</a>
|
||||
<a href="/dashboard?section=community" role="menuitem">Community</a>
|
||||
<a href="/dashboard?section=settings" role="menuitem">Einstellungen</a>
|
||||
<?php if ($showSystemLink): ?>
|
||||
@@ -88,6 +89,7 @@ if ($isLoggedIn) {
|
||||
<a class="btn ghost" href="/dashboard?section=profile">Profil</a>
|
||||
<a class="btn ghost" href="/dashboard?section=children">Kinder</a>
|
||||
<a class="btn ghost" href="/dashboard?section=events">Events</a>
|
||||
<a class="btn ghost" href="/dashboard?section=places">Orte & Veranstaltungen</a>
|
||||
<a class="btn ghost" href="/dashboard?section=community">Community</a>
|
||||
<a class="btn ghost" href="/dashboard?section=settings">Einstellungen</a>
|
||||
<?php if ($showSystemLink): ?>
|
||||
|
||||
@@ -393,8 +393,14 @@ final class AccountPages
|
||||
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
|
||||
}
|
||||
$payload = $_POST;
|
||||
$existingListingId = $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : 0;
|
||||
$existingListing = $existingListingId > 0 ? $listingCatalog->getDashboardEntry($userId, $existingListingId) : null;
|
||||
$existingListing = null;
|
||||
if ($action === 'event_update') {
|
||||
$existingListingId = (int)($_POST['listing_id'] ?? 0);
|
||||
$existingListing = $existingListingId > 0 ? $listingCatalog->getMemberEntry($existingListingId) : null;
|
||||
if (!is_array($existingListing) || (string)($existingListing['status'] ?? '') !== 'published') {
|
||||
throw new \RuntimeException('Eintrag nicht gefunden.');
|
||||
}
|
||||
}
|
||||
$street = trim((string)($_POST['street'] ?? ''));
|
||||
$zip = trim((string)($_POST['zip'] ?? ''));
|
||||
$city = trim((string)($_POST['city'] ?? ''));
|
||||
@@ -426,12 +432,18 @@ final class AccountPages
|
||||
trim((string)($payload['description'] ?? '')),
|
||||
trim((string)($payload['title'] ?? ''))
|
||||
);
|
||||
$listingCatalog->saveDashboardEntry(
|
||||
$userId,
|
||||
$payload,
|
||||
$action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null
|
||||
);
|
||||
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
|
||||
if ($action === 'event_update') {
|
||||
$listingCatalog->submitUpdateRequest(
|
||||
$userId,
|
||||
(int)($_POST['listing_id'] ?? 0),
|
||||
$payload,
|
||||
(string)($_POST['moderation_reason'] ?? '')
|
||||
);
|
||||
$info = $entryKind === 'place' ? 'Änderungsanfrage für den Ort eingereicht.' : 'Änderungsanfrage für die Veranstaltung eingereicht.';
|
||||
} else {
|
||||
$listingCatalog->submitCreateSuggestion($userId, $payload);
|
||||
$info = $entryKind === 'place' ? 'Ort zur Freigabe eingereicht.' : 'Veranstaltung zur Freigabe eingereicht.';
|
||||
}
|
||||
} else {
|
||||
$existingEvent = null;
|
||||
if ($action === 'event_update') {
|
||||
@@ -563,17 +575,13 @@ final class AccountPages
|
||||
'id' => $eventId,
|
||||
]);
|
||||
$info = 'Event wurde abgesagt.';
|
||||
} elseif ($action === 'listing_delete') {
|
||||
} elseif ($action === 'listing_delete_request') {
|
||||
$listingId = (int)($_POST['listing_id'] ?? 0);
|
||||
if (!$listingCatalog) {
|
||||
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
|
||||
}
|
||||
$entry = $listingCatalog->getDashboardEntry($userId, $listingId);
|
||||
if (is_array($entry)) {
|
||||
self::deleteStoredImage((string)($entry['image_path'] ?? ''));
|
||||
}
|
||||
$listingCatalog->deleteDashboardEntry($userId, $listingId);
|
||||
$info = 'Eintrag gelöscht.';
|
||||
$listingCatalog->submitDeleteRequest($userId, $listingId, (string)($_POST['moderation_reason'] ?? ''));
|
||||
$info = 'Löschanfrage wurde eingereicht.';
|
||||
} elseif ($action === 'community_admin_apply') {
|
||||
if (!$community || !$communityAccess) {
|
||||
throw new \RuntimeException('Community-Funktionen sind aktuell nicht verfügbar.');
|
||||
@@ -697,7 +705,9 @@ final class AccountPages
|
||||
$eventsJoinedUpcoming = [];
|
||||
$eventsJoinedPast = [];
|
||||
$editEvent = null;
|
||||
$otherListings = [];
|
||||
$directoryListings = [];
|
||||
$userSubmittedListings = [];
|
||||
$listingRequestMap = [];
|
||||
$editListing = null;
|
||||
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : [];
|
||||
$categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : [];
|
||||
@@ -763,11 +773,18 @@ final class AccountPages
|
||||
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
if ($listingCatalog) {
|
||||
$otherListings = $listingCatalog->listDashboardEntries($userId);
|
||||
$directoryListings = $listingCatalog->listPublishedDirectoryEntries();
|
||||
$userSubmittedListings = $listingCatalog->listUserSubmittedEntries($userId);
|
||||
$listingRequestMap = $listingCatalog->listUserOpenModerationRequestMap($userId);
|
||||
if (isset($_GET['edit_listing'])) {
|
||||
$editListingId = (int)$_GET['edit_listing'];
|
||||
if ($editListingId > 0) {
|
||||
$editListing = $listingCatalog->getDashboardEntry($userId, $editListingId);
|
||||
$candidate = $listingCatalog->getMemberEntry($editListingId);
|
||||
if (is_array($candidate) && (string)($candidate['status'] ?? '') === 'published') {
|
||||
$editListing = $candidate;
|
||||
} else {
|
||||
$error = 'Der gewünschte Eintrag ist für eine Änderungsanfrage nicht verfügbar.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,7 +825,9 @@ final class AccountPages
|
||||
'eventsJoinedUpcoming',
|
||||
'eventsJoinedPast',
|
||||
'editEvent',
|
||||
'otherListings',
|
||||
'directoryListings',
|
||||
'userSubmittedListings',
|
||||
'listingRequestMap',
|
||||
'editListing',
|
||||
'listingCategories',
|
||||
'categoryReviewItems',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user