yxcyxc
All checks were successful
Deploy / deploy (push) Successful in 57s

This commit is contained in:
2026-08-24 00:44:26 +02:00
parent 577b5b742b
commit 774260afaa
9 changed files with 85 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)) {
<p class="muted small" style="margin:0;">Die Änderung wird erst nach Prüfung durch einen Admin freigegeben.</p>
</div>
<?php elseif ($editingSuggestionDirectly): ?>
<p class="muted small" style="margin:0;">Dieser Vorschlag ist noch nicht veröffentlicht und kann direkt aktualisiert werden.</p>
<p class="muted small" style="margin:0;"><?= !empty($canReviewListings) ? 'Dieser Eintrag kann mit deinen Admin-Rechten direkt aktualisiert werden.' : 'Dieser Vorschlag ist noch nicht veröffentlicht und kann direkt aktualisiert werden.' ?></p>
<?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; ?>

View File

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

View File

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

View File

@@ -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'];