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

@@ -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,

View File

@@ -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.

View File

@@ -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`

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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/`

View File

@@ -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">

View File

@@ -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 &amp; 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 &amp; 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>

View File

@@ -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 &amp; 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 &amp; 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): ?>

View File

@@ -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',

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)) {