diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 3e2b080..fbd8c51 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -78,6 +78,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - In der Admin-Freigabe werden aktivierte Ortsanbieter serverseitig parallel durchsucht: OpenStreetMap/Nominatim, Google Places und Azure Maps. Ein Admin wählt einen Treffer gezielt aus; je Ort können Verknüpfungen mehrerer Anbieter gespeichert werden. Google-Gesamtwertung und Bewertungsanzahl werden erst nach der ausgewählten Google-Verknüpfung geladen. - Die Admin-Ansicht für Orte und Veranstaltungen hat die Unterpunkte `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`. Letzterer filtert veröffentlichte Einträge nach Verknüpfungen, die bei mindestens einem aktuell aktivierten Anbieter fehlen. - Nach dem Übernehmen eines externen Treffers wird dessen temporäre Trefferliste entfernt. Der Bearbeiten-Button ist in jeder dieser Unteransichten zur Prüfung von Name und Adresse verfügbar. +- Berechtigte Admins können veröffentlichte Orte und Veranstaltungen direkt korrigieren. Wenn die vollständige OSM-Suchanfrage keinen Treffer liefert, werden zusätzlich vereinfachte Adress- und Name-Ort-Abfragen versucht. - Eine manuelle Admin-Suche überträgt Name und Adressdaten des offenen Eintrags an die aktivierten Ortsanbieter; Datenschutz- und Consent-Hinweise müssen vor dem produktiven Aktivieren geprüft werden. ## Vorgemerkte To-dos diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index 547ef59..44630fb 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -34,6 +34,7 @@ Stand: 2026-08-10 - Direktbearbeitung noch nicht veröffentlichter Orts- und Veranstaltungsvorschläge sowie Rechteauflösung für deren Freigabe: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php` - Server-seitige Ortsanbieter-Suche und sichere Trefferauswahl für die Admin-Freigabe: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php` - Admin-Listen für bestehende Ortsbearbeitung und fehlende Anbieter-Verknüpfungen: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php` +- Direkte Admin-Korrekturen veröffentlichter Orts- und Veranstaltungsdaten: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php` - Rollenvergabe per Benutzersuche im User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.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` diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index 4d32420..07117b6 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -78,6 +78,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events - In admin approvals, enabled place providers are searched server-side in parallel: OpenStreetMap/Nominatim, Google Places, and Azure Maps. An admin deliberately selects a match; links to multiple providers can be saved for one place. Google aggregate rating and rating count are loaded only after the selected Google link. - The admin view for places and events has the sub-items `Approvals`, `Existing place editing` with a search field, and `Missing information`. The latter filters published entries by links missing from at least one currently enabled provider. - After an external match is accepted, its temporary result list is removed. The Edit button is available in each of these subviews to check the name and address. +- Authorized admins can correct published places and events directly. If the full OSM query returns no match, simplified address and name-city queries are tried as well. - A manual admin lookup sends the open entry's name and address data to enabled place providers; privacy and consent notices must be reviewed before enabling this in production. ## Deferred To-dos diff --git a/Internal/en/PROJECT_STRUCTURE.md b/Internal/en/PROJECT_STRUCTURE.md index a94a0dc..811a400 100644 --- a/Internal/en/PROJECT_STRUCTURE.md +++ b/Internal/en/PROJECT_STRUCTURE.md @@ -34,6 +34,7 @@ Updated: 2026-08-10 - direct editing for not-yet-published place and event-style suggestions plus approval-right resolution: `src/App/CommunityAccess.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php` - server-side place-provider lookup and secure match selection for admin approvals: `src/App/PlaceProviderLookup.php`, `src/App/AccountPages.php`, `src/App/ListingCatalog.php`, `partials/landing/account/dashboard.php` - admin lists for existing place editing and missing provider links: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php` +- direct admin corrections of published place and event data: `src/App/ListingCatalog.php`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php` - role assignment via user search in User Management: `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`, `src/App/CommunityAccess.php` - legacy own events were extended in `src/App/AccountPages.php` and `Internal/db/schema.sql` with category and image fields diff --git a/README.md b/README.md index fbdae7d..eb203d9 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf: - In der Admin-Freigabe können berechtigte Nutzer offene Orte und Veranstaltungen serverseitig bei aktivierten Anbietern (OpenStreetMap, Google Places, Azure Maps) suchen und einen Treffer gezielt verknüpfen; Google-Gesamtwertung und Bewertungsanzahl werden erst nach einer bewussten Google-Verknüpfung geladen. - Die Admin-Ansicht für Orte und Veranstaltungen gliedert sich in `Freigaben`, `Bestehende Ortsbearbeitung` mit Suchfeld und `Fehlende Angaben`; Letztere listet veröffentlichte Einträge ohne Verknüpfung zu mindestens einem aktuell aktivierten Anbieter. - Nach dem Übernehmen eines externen Treffers wird dessen temporäre Trefferliste entfernt. Der Button `Bearbeiten` steht in allen drei Admin-Unteransichten zur Prüfung von Name und Adresse bereit. +- Berechtigte Admins speichern Korrekturen an veröffentlichten Orten und Veranstaltungen direkt; für eine erfolglose OSM-Suche werden zusätzlich vereinfachte Adress- und Name-Ort-Abfragen versucht. - 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/` diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index 9c7910c..b770286 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -13,7 +13,7 @@ $restoreEventDraftActive = !empty($restoreEventDraftActive); $editingEvent = isset($editEvent) && $editEvent !== null; $editingListing = isset($editListing) && $editListing !== null; $editing = $editingEvent || $editingListing; -$editingSuggestionDirectly = $editingListing && (string)($editListing['status'] ?? '') !== 'published'; +$editingSuggestionDirectly = $editingListing && ((string)($editListing['status'] ?? '') !== 'published' || !empty($canReviewListings)); $eventDraftValues = (!$editing && (($section ?? '') === 'events') && $restoreEventDraftActive && is_array($restoredEventDraft ?? null)) ? $restoredEventDraft : []; @@ -1744,7 +1744,7 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {

Die Änderung wird erst nach Prüfung durch einen Admin freigegeben.

-

Dieser Vorschlag ist noch nicht veröffentlicht und kann direkt aktualisiert werden.

+

Neue Orte und Veranstaltungen werden erst nach Prüfung durch einen Admin freigegeben.

diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 40f8293..defa70c 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -503,18 +503,20 @@ final class AccountPages trim((string)($payload['description'] ?? '')), trim((string)($payload['title'] ?? '')) ); - $canDirectListingEdit = is_array($existingListing) - && (string)($existingListing['status'] ?? '') !== 'published' - && ( - (int)($existingListing['created_by'] ?? 0) === $userId - || $canReviewListings - ); + $canDirectListingEdit = is_array($existingListing) && ( + $canReviewListings + || ( + (string)($existingListing['status'] ?? '') !== 'published' + && (int)($existingListing['created_by'] ?? 0) === $userId + ) + ); if ($action === 'event_update') { if ($canDirectListingEdit) { - $listingCatalog->updateEditableSuggestion( - (int)($_POST['listing_id'] ?? 0), - $payload - ); + if ($canReviewListings) { + $listingCatalog->updateAdminEntry((int)($_POST['listing_id'] ?? 0), $payload); + } else { + $listingCatalog->updateEditableSuggestion((int)($_POST['listing_id'] ?? 0), $payload); + } $info = $entryKind === 'place' ? 'Ort aktualisiert.' : 'Veranstaltung aktualisiert.'; } else { $listingCatalog->submitUpdateRequest( diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index 135ae89..e1a1886 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -922,6 +922,24 @@ final class ListingCatalog $this->saveDashboardEntry((int)($entry['created_by'] ?? 0), $data, $listingId, $normalizedListingStatus, $normalizedPlaceStatus); } + public function updateAdminEntry(int $listingId, array $data): void + { + $this->ensureSchema(); + $entry = $this->getMemberEntry($listingId); + if (!$entry || !in_array((string)($entry['status'] ?? ''), ['draft', 'published'], true)) { + throw new \RuntimeException('Eintrag nicht gefunden.'); + } + $this->assertNoDuplicateEntry($data, $listingId); + $this->saveDashboardEntry( + (int)($entry['created_by'] ?? 0), + $data, + $listingId, + (string)($entry['status'] ?? 'published'), + (string)($entry['place_status'] ?? 'published'), + true + ); + } + public function listOpenModerationRequests(): array { $this->ensureSchema(); @@ -1122,7 +1140,7 @@ final class ListingCatalog } } - public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null, string $listingStatus = 'published', string $placeStatus = 'published'): int + public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null, string $listingStatus = 'published', string $placeStatus = 'published', bool $allowForeignListing = false): int { $this->ensureSchema(); $entryType = (string)($data['entry_kind'] ?? 'editorial_event'); @@ -1185,7 +1203,7 @@ final class ListingCatalog $categoryId = isset($category['id']) ? (int)$category['id'] : null; if ($listingId !== null && $listingId > 0) { - $existing = $this->getDashboardEntry($userId, $listingId); + $existing = $allowForeignListing ? $this->getMemberEntry($listingId) : $this->getDashboardEntry($userId, $listingId); if (!$existing) { throw new \RuntimeException('Eintrag nicht gefunden.'); } @@ -1216,15 +1234,15 @@ final class ListingCatalog 'id' => $placeId, ]); } - $listingStmt = $this->pdo->prepare( + $listingSql = 'UPDATE listings SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, image_path = :imagePath, special_conditions_note = :specialConditionsNote, visibility = :visibility, supports_registration = 0, supports_capacity = :supportsCapacity, supports_pricing = :supportsPricing, is_recurring = :isRecurring, status = :listingStatus, updated_at = NOW() - WHERE id = :id AND created_by = :uid' - ); - $listingStmt->execute([ + WHERE id = :id' . ($allowForeignListing ? '' : ' AND created_by = :uid'); + $listingStmt = $this->pdo->prepare($listingSql); + $listingParams = [ 'listingType' => $entryType, 'title' => $title, 'teaser' => $teaser !== '' ? $teaser : $title, @@ -1237,8 +1255,11 @@ final class ListingCatalog 'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0, 'listingStatus' => $listingStatus, 'id' => $listingId, - 'uid' => $userId, - ]); + ]; + if (!$allowForeignListing) { + $listingParams['uid'] = $userId; + } + $listingStmt->execute($listingParams); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data); diff --git a/src/App/PlaceProviderLookup.php b/src/App/PlaceProviderLookup.php index 9014f12..7d61324 100644 --- a/src/App/PlaceProviderLookup.php +++ b/src/App/PlaceProviderLookup.php @@ -88,30 +88,50 @@ final class PlaceProviderLookup private function searchOsm(string $query, array $entry): array { - $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ - 'format' => 'jsonv2', - 'addressdetails' => 1, - 'limit' => 3, - 'q' => $query, - ]); - $rows = $this->cachedOsmRequest($url); - if (!is_array($rows)) { - return []; - } $results = []; - foreach ($rows as $row) { - if (!is_array($row) || empty($row['osm_type']) || empty($row['osm_id'])) { + foreach ($this->osmQueries($query, $entry) as $osmQuery) { + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'format' => 'jsonv2', + 'addressdetails' => 1, + 'limit' => 3, + 'q' => $osmQuery, + ]); + $rows = $this->cachedOsmRequest($url); + if (!is_array($rows)) { continue; } - $lat = isset($row['lat']) ? (float)$row['lat'] : null; - $lng = isset($row['lon']) ? (float)$row['lon'] : null; - $type = (string)$row['osm_type']; - $id = (string)$row['osm_id']; - $results[] = $this->result('osm', $type . ':' . $id, (string)($row['name'] ?? $row['display_name'] ?? ''), (string)($row['display_name'] ?? ''), $lat, $lng, $entry, 'https://www.openstreetmap.org/' . $type . '/' . rawurlencode($id)); + foreach ($rows as $row) { + if (!is_array($row) || empty($row['osm_type']) || empty($row['osm_id'])) { + continue; + } + $lat = isset($row['lat']) ? (float)$row['lat'] : null; + $lng = isset($row['lon']) ? (float)$row['lon'] : null; + $type = (string)$row['osm_type']; + $id = (string)$row['osm_id']; + $results[] = $this->result('osm', $type . ':' . $id, (string)($row['name'] ?? $row['display_name'] ?? ''), (string)($row['display_name'] ?? ''), $lat, $lng, $entry, 'https://www.openstreetmap.org/' . $type . '/' . rawurlencode($id)); + } + if ($results !== []) { + break; + } } return $results; } + private function osmQueries(string $primaryQuery, array $entry): array + { + $addressQuery = trim(implode(', ', array_filter([ + (string)($entry['street'] ?? ''), + trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')), + 'Deutschland', + ]))); + $nameCityQuery = trim(implode(', ', array_filter([ + (string)($entry['title'] ?? $entry['place_title'] ?? ''), + (string)($entry['city'] ?? ''), + 'Deutschland', + ]))); + return array_values(array_unique(array_filter([$primaryQuery, $addressQuery, $nameCityQuery]))); + } + private function searchGoogle(string $query, array $entry): array { $payload = ['textQuery' => $query, 'languageCode' => 'de', 'regionCode' => 'DE'];