This commit is contained in:
@@ -95,6 +95,8 @@ jobs:
|
||||
[ -f "$f" ] && cp "$f" .ci_config_deploy/
|
||||
done
|
||||
|
||||
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
|
||||
|
||||
cp -R ${CONFIG_ENV_DIR}/. .ci_config_deploy/
|
||||
|
||||
echo "🔁 config → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
|
||||
@@ -105,11 +107,11 @@ jobs:
|
||||
set ftp:ssl-protect-data true;
|
||||
set ssl:verify-certificate no;
|
||||
lcd .ci_config_deploy;
|
||||
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
bye
|
||||
" || exit 1
|
||||
else
|
||||
echo "⚠️ Config-Deploy übersprungen: ${CONFIG_BASE_DIR} oder ${CONFIG_ENV_DIR} fehlt"
|
||||
fi
|
||||
|
||||
echo "✅ Deploy abgeschlossen"
|
||||
echo "✅ Deploy abgeschlossen"
|
||||
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Local server secrets
|
||||
/config/secrets.local.php
|
||||
|
||||
# Local environment files
|
||||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
@@ -90,6 +90,7 @@ deploy:staging:
|
||||
cp "$f" .ci_config_deploy/
|
||||
fi
|
||||
done
|
||||
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
|
||||
fi
|
||||
|
||||
if [ -d "${CONFIG_ENV_DIR}" ]; then
|
||||
@@ -104,7 +105,7 @@ deploy:staging:
|
||||
set ftp:ssl-protect-data true;
|
||||
set ssl:verify-certificate no;
|
||||
lcd .ci_config_deploy;
|
||||
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
bye
|
||||
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
|
||||
|
||||
@@ -197,6 +198,7 @@ deploy:production:
|
||||
cp "$f" .ci_config_deploy/
|
||||
fi
|
||||
done
|
||||
[ -f "${CONFIG_BASE_DIR}/.htaccess" ] && cp "${CONFIG_BASE_DIR}/.htaccess" .ci_config_deploy/
|
||||
fi
|
||||
|
||||
if [ -d "${CONFIG_ENV_DIR}" ]; then
|
||||
@@ -211,7 +213,7 @@ deploy:production:
|
||||
set ftp:ssl-protect-data true;
|
||||
set ssl:verify-certificate no;
|
||||
lcd .ci_config_deploy;
|
||||
mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
mirror -R --delete --exclude secrets.local.php --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/;
|
||||
bye
|
||||
" || { echo "❌ Upload für gemischtes Config-Verzeichnis fehlgeschlagen."; exit 1; }
|
||||
|
||||
@@ -224,4 +226,3 @@ deploy:production:
|
||||
only:
|
||||
- main
|
||||
# when: manual
|
||||
|
||||
|
||||
@@ -154,9 +154,10 @@ CREATE TABLE listing_places (
|
||||
place_kind VARCHAR(80) NULL,
|
||||
opening_hours_note TEXT NULL,
|
||||
opening_hours_json LONGTEXT NULL,
|
||||
provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual',
|
||||
provider_hint ENUM('manual','osm','google','azure') NOT NULL DEFAULT 'manual',
|
||||
external_place_id VARCHAR(190) NULL,
|
||||
google_place_id VARCHAR(190) NULL,
|
||||
provider_links_json LONGTEXT NULL,
|
||||
rating_value DECIMAL(3,2) NULL,
|
||||
rating_count INT UNSIGNED NULL,
|
||||
status ENUM('draft','published','archived') NOT NULL DEFAULT 'published',
|
||||
|
||||
@@ -66,6 +66,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Bestehende gespeicherte Community-Level erhalten diese beiden Rechte im Fallback automatisch ab 750 Punkten, solange sie nicht explizit anders gespeichert wurden.
|
||||
|
||||
## Technischer Rahmen
|
||||
- Externe API-Schlüssel werden ausschließlich über Server-Umgebungsvariablen oder die lokale, nicht versionierte Datei `config/secrets.local.php` außerhalb des Webroots bereitgestellt.
|
||||
- eigener Front-Controller in `public/index.php`
|
||||
- Templates unter `partials/`
|
||||
- Geschäftslogik unter `src/App/`
|
||||
@@ -74,7 +75,9 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
## Neue Ausbaustufe: Termin-/Ort-System
|
||||
- Neue Tabellenbasis: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
|
||||
- Ziel: spätere Unterscheidung zwischen eigenem Event, Partner-Angebot, allgemeinem Ort und redaktionellem Termin
|
||||
- Google ist noch nicht aktiv integriert; das System wird nur vorbereitend über Settings und Datenfelder darauf ausgelegt
|
||||
- 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.
|
||||
- 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
|
||||
- Kalender-Integration großer Anbieter soll später zusätzlich zum ICS-Feed kommen, zuerst vor allem für Google und Microsoft/Outlook.
|
||||
|
||||
@@ -4,6 +4,8 @@ Stand: 2026-08-10
|
||||
|
||||
## Projektordner
|
||||
- `config/`: Konfigurationen, inklusive `prod/` und `staging/`
|
||||
- `config/secrets.local.php`: lokale, nicht versionierte Server-Geheimnisse außerhalb des Webroots; Vorlage: `config/secrets.local.php.example`
|
||||
- `config/.htaccess`: zusätzliche Webserver-Sperre für den gesamten Konfigurationsordner; beide Deployment-Workflows schließen `secrets.local.php` explizit vom synchronisierenden Löschen aus.
|
||||
- `partials/`: Templates, aufgeteilt in `landing/` und `structure/`
|
||||
- `public/`: Webroot mit Assets und Seiten-Entry-Points
|
||||
- `src/`: Backend-/Business-Logik und Kernklassen
|
||||
@@ -30,6 +32,8 @@ Stand: 2026-08-10
|
||||
- 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`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
|
||||
- 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`
|
||||
- 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`
|
||||
|
||||
@@ -51,6 +51,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
- neue Basisklassen: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
|
||||
|
||||
## Datenschutz und Sicherheit
|
||||
- Server-Geheimnisse können lokal über `config/secrets.local.php` außerhalb des Webroots geladen werden; die Datei wird nicht versioniert und bei Deployments nicht gelöscht.
|
||||
- Passwort-Hashes über Argon2id
|
||||
- Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash
|
||||
- persönliche Kalender-Feed-Tokens verschlüsselt plus separater SHA-256-Lookup-Hash
|
||||
|
||||
@@ -66,6 +66,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- Existing stored community levels receive these two rights automatically in the fallback from 750 points upward, unless they were explicitly saved differently.
|
||||
|
||||
## Technical Frame
|
||||
- External API keys are provided only through server environment variables or the local, unversioned `config/secrets.local.php` file outside the webroot.
|
||||
- custom front controller in `public/index.php`
|
||||
- templates under `partials/`
|
||||
- business logic under `src/App/`
|
||||
@@ -74,7 +75,9 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
## New Expansion Stage: Listings and Places
|
||||
- New table base: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
|
||||
- Goal: later distinguish between own event, partner offer, general place, and editorial event entry
|
||||
- Google is not integrated productively yet; the system is only being prepared for it via settings and data fields
|
||||
- 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.
|
||||
- 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
|
||||
- Large calendar-provider integrations should be added later in addition to the ICS feed, starting primarily with Google and Microsoft/Outlook.
|
||||
|
||||
@@ -4,6 +4,8 @@ Updated: 2026-08-10
|
||||
|
||||
## Main Directories
|
||||
- `config/`: configuration, including `prod/` and `staging/`
|
||||
- `config/secrets.local.php`: local, unversioned server secrets outside the webroot; template: `config/secrets.local.php.example`
|
||||
- `config/.htaccess`: additional web-server protection for the entire configuration directory; both deployment workflows explicitly exclude `secrets.local.php` from synchronizing deletion.
|
||||
- `partials/`: templates split into `landing/` and `structure/`
|
||||
- `public/`: webroot, assets, and page entry points
|
||||
- `src/`: backend and business logic
|
||||
@@ -30,6 +32,8 @@ Updated: 2026-08-10
|
||||
- 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`, `src/App/AccountPages.php`, `partials/landing/account/dashboard.php`
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
- new base classes: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
|
||||
|
||||
## Privacy and Security
|
||||
- Server secrets can be loaded locally through `config/secrets.local.php` outside the webroot; the file is not versioned and is not deleted by deployments.
|
||||
- password hashes via Argon2id
|
||||
- account emails are encrypted and additionally addressed through a separate HMAC lookup hash
|
||||
- personal calendar-feed tokens are stored encrypted and additionally resolved through a separate SHA-256 lookup hash
|
||||
|
||||
@@ -67,10 +67,15 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
|
||||
- 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`, `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
|
||||
- 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.
|
||||
- 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/`
|
||||
|
||||
## Datenschutz und Sicherheit
|
||||
- Server-Geheimnisse werden optional aus `config/secrets.local.php` außerhalb des Webroots geladen; die Datei ist lokal, wird nicht versioniert und bei Deployments nicht gelöscht.
|
||||
- Schlüssel für Ortsanbieter bleiben ausschließlich serverseitig. Die Auswahl von Suchtreffern erfolgt über kurzzeitig in der Server-Session abgelegte Treffer, nicht über vom Browser übermittelte Provider-IDs.
|
||||
- Das Aktivieren von Ortsanbietern muss mit den Datenschutz- und Consent-Hinweisen abgeglichen werden, weil eine manuelle Admin-Suche Name und Adressdaten eines offenen Eintrags an den gewählten Anbieter überträgt.
|
||||
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert
|
||||
- Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert
|
||||
- persönliche Kalender-Feed-Tokens werden verschlüsselt gespeichert und zusätzlich über einen separaten SHA-256-Lookup-Hash adressiert
|
||||
|
||||
7
config/.htaccess
Normal file
7
config/.htaccess
Normal file
@@ -0,0 +1,7 @@
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_authz_core.c>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Local server secrets live outside the public webroot and are never committed.
|
||||
$localSecrets = __DIR__ . '/secrets.local.php';
|
||||
if (is_file($localSecrets)) {
|
||||
require_once $localSecrets;
|
||||
}
|
||||
|
||||
// Basic error reporting (keep strict in dev)
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
|
||||
13
config/secrets.local.php.example
Normal file
13
config/secrets.local.php.example
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Copy this file to config/secrets.local.php and replace the placeholders.
|
||||
// Never commit config/secrets.local.php or place it inside public/.
|
||||
|
||||
if (getenv('GOOGLE_MAPS_API_KEY') === false) {
|
||||
putenv('GOOGLE_MAPS_API_KEY=replace-with-your-google-server-key');
|
||||
}
|
||||
|
||||
if (getenv('AZURE_MAPS_SUBSCRIPTION_KEY') === false) {
|
||||
putenv('AZURE_MAPS_SUBSCRIPTION_KEY=replace-with-your-azure-maps-key');
|
||||
}
|
||||
@@ -264,22 +264,60 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {
|
||||
<?php endif; ?>
|
||||
<?php if ($section === 'listing-review' && !empty($canReviewListings)): ?>
|
||||
<section class="account-panel">
|
||||
<div class="account-panel__head"><h2>Orte & Veranstaltungen freigeben</h2><p class="muted">Nur noch nicht freigegebene Orte und Veranstaltungen prüfen und bei Bedarf vor der Freigabe bearbeiten.</p></div>
|
||||
<div class="account-panel__head">
|
||||
<h2>Orte & Veranstaltungen</h2>
|
||||
<p class="muted">Freigaben bearbeiten sowie externe Ortsdaten für bestehende Einträge ergänzen oder aktualisieren.</p>
|
||||
<div class="flex gap-12" style="flex-wrap:wrap; margin-top:14px;">
|
||||
<a class="btn <?= ($listingReviewTab ?? 'approvals') === 'approvals' ? '' : 'ghost' ?>" href="/dashboard?area=admin&section=listing-review&listing_tab=approvals">Freigaben</a>
|
||||
<a class="btn <?= ($listingReviewTab ?? '') === 'existing' ? '' : 'ghost' ?>" href="/dashboard?area=admin&section=listing-review&listing_tab=existing">Bestehende Ortsbearbeitung</a>
|
||||
<a class="btn <?= ($listingReviewTab ?? '') === 'missing' ? '' : 'ghost' ?>" href="/dashboard?area=admin&section=listing-review&listing_tab=missing">Fehlende Angaben</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-panel__body">
|
||||
<?php if (!$listingReviewRequests): ?><p class="muted">Keine offenen Orte oder Veranstaltungen zur Freigabe.</p><?php endif; ?>
|
||||
<?php if (($listingReviewTab ?? 'approvals') !== 'approvals'): ?>
|
||||
<form method="get" class="flex gap-12" style="align-items:end; flex-wrap:wrap; margin-bottom:18px;">
|
||||
<input type="hidden" name="area" value="admin"><input type="hidden" name="section" value="listing-review"><input type="hidden" name="listing_tab" value="<?= htmlspecialchars((string)$listingReviewTab, ENT_QUOTES) ?>">
|
||||
<label class="stack gap-6" style="min-width:280px; flex:1;"><span class="label">Ort oder Veranstaltung suchen</span><input class="input" name="listing_query" value="<?= htmlspecialchars((string)($listingManagementSearchQuery ?? ''), ENT_QUOTES) ?>" placeholder="Name, Straße, Ort oder PLZ"></label>
|
||||
<button class="btn" type="submit">Suchen</button>
|
||||
</form>
|
||||
<?php if (($listingReviewTab ?? '') === 'missing'): ?><p class="muted small">Gezeigt werden veröffentlichte Einträge, denen mindestens einer der aktuell aktivierten Anbieter fehlt: <?= $activePlaceProviders ? htmlspecialchars(implode(', ', array_map('strtoupper', $activePlaceProviders)), ENT_QUOTES) : 'keine aktiven Anbieter' ?>.</p><?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php if (($listingReviewTab ?? 'approvals') === 'approvals' && !$listingReviewRequests): ?><p class="muted">Keine offenen Orte oder Veranstaltungen zur Freigabe.</p><?php endif; ?>
|
||||
<?php if (($listingReviewTab ?? 'approvals') !== 'approvals' && !$listingManagementEntries): ?><p class="muted">Keine passenden bestehenden Orte oder Veranstaltungen gefunden.</p><?php endif; ?>
|
||||
<div class="stack gap-12">
|
||||
<?php foreach ($listingReviewRequests as $request): ?>
|
||||
<?php foreach (($listingReviewTab ?? 'approvals') === 'approvals' ? $listingReviewRequests : $listingManagementEntries as $request): ?>
|
||||
<article class="card" style="padding:18px;">
|
||||
<strong><?= htmlspecialchars((string)$request['title'], ENT_QUOTES) ?></strong>
|
||||
<span class="badge"><?= (string)($request['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung' ?></span>
|
||||
<p class="muted small"><?= htmlspecialchars(trim(implode(', ', array_filter([(string)($request['street'] ?? ''), trim((string)($request['zip'] ?? '') . ' ' . (string)($request['city'] ?? '')), (string)($request['region'] ?? '')]))), ENT_QUOTES) ?></p>
|
||||
<p class="muted small">Eingereicht von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?>.</p>
|
||||
<div class="flex gap-12" style="flex-wrap:wrap; margin:14px 0;"><a class="btn ghost" href="/dashboard?area=admin&section=listing-review&edit_listing=<?= (int)$request['listing_id'] ?>">Bearbeiten</a></div>
|
||||
<form method="post" class="stack gap-8">
|
||||
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><p class="muted small">Eingereicht von <?= htmlspecialchars((string)($request['requested_by_name'] ?: 'Mitglied'), ENT_QUOTES) ?>.</p><?php endif; ?>
|
||||
<?php if (!empty($request['provider_links'])): ?><p class="muted small">Verknüpft: <?= htmlspecialchars(implode(', ', array_map('strtoupper', array_keys((array)$request['provider_links']))), ENT_QUOTES) ?><?= $request['rating_value'] !== null ? ' · Google: ' . htmlspecialchars((string)$request['rating_value'], ENT_QUOTES) . ' / 5 (' . (int)$request['rating_count'] . ')' : '' ?></p><?php endif; ?>
|
||||
<?php if (!empty($request['missing_providers'])): ?><p class="muted small">Fehlt: <?= htmlspecialchars(implode(', ', array_map('strtoupper', (array)$request['missing_providers'])), ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<div class="flex gap-12" style="flex-wrap:wrap; margin:14px 0;">
|
||||
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><a class="btn ghost" href="/dashboard?area=admin&section=listing-review&listing_tab=approvals&edit_listing=<?= (int)$request['listing_id'] ?>">Bearbeiten</a><?php endif; ?>
|
||||
<form method="post"><input type="hidden" name="action" value="place_provider_search"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>"><button class="btn ghost" type="submit">Externe Ortsdaten suchen</button></form>
|
||||
</div>
|
||||
<?php $providerSearch = $placeProviderSearches[(int)$request['listing_id']] ?? null; ?>
|
||||
<?php if (is_array($providerSearch)): ?>
|
||||
<div class="card stack gap-10" style="padding:14px; margin:14px 0;">
|
||||
<strong>Treffer für <?= htmlspecialchars((string)($providerSearch['query'] ?? ''), ENT_QUOTES) ?></strong>
|
||||
<?php foreach ((array)($providerSearch['notices'] ?? []) as $notice): ?><p class="muted small" style="margin:0;"><?= htmlspecialchars((string)$notice, ENT_QUOTES) ?></p><?php endforeach; ?>
|
||||
<?php if (empty($providerSearch['results'])): ?><p class="muted small" style="margin:0;">Keine Treffer. Adresse oder Name bitte über „Bearbeiten“ prüfen.</p><?php endif; ?>
|
||||
<?php foreach ((array)($providerSearch['results'] ?? []) as $resultIndex => $result): ?>
|
||||
<div class="card" style="padding:12px;">
|
||||
<div class="flex gap-12" style="align-items:flex-start; justify-content:space-between; flex-wrap:wrap;">
|
||||
<div><strong><?= htmlspecialchars((string)($result['name'] ?? ''), ENT_QUOTES) ?></strong> <span class="badge"><?= htmlspecialchars(strtoupper((string)($result['provider'] ?? '')), ENT_QUOTES) ?></span><p class="muted small" style="margin:6px 0 0;"><?= htmlspecialchars((string)($result['address'] ?? ''), ENT_QUOTES) ?><?= isset($result['distance_m']) && $result['distance_m'] !== null ? ' · ca. ' . (int)$result['distance_m'] . ' m entfernt' : '' ?><?= isset($result['rating']) && $result['rating'] !== null ? ' · Google: ' . htmlspecialchars((string)$result['rating'], ENT_QUOTES) . ' / 5 (' . (int)($result['rating_count'] ?? 0) . ')' : '' ?></p></div>
|
||||
<form method="post"><input type="hidden" name="action" value="place_provider_link"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>"><input type="hidden" name="result_index" value="<?= (int)$resultIndex ?>"><button class="btn ghost" type="submit">Diesen Treffer verwenden</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (($listingReviewTab ?? 'approvals') === 'approvals'): ?><form method="post" class="stack gap-8">
|
||||
<input type="hidden" name="action" value="pending_listing_decide"><input type="hidden" name="listing_id" value="<?= (int)$request['listing_id'] ?>">
|
||||
<textarea name="review_note" class="textarea" rows="2" 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>
|
||||
</form><?php endif; ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -1000,20 +1038,35 @@ if ($isAdminArea && !empty($canManageSystemSettings)) {
|
||||
<div class="card">
|
||||
<strong>Orts- und Bewertungsdienste</strong>
|
||||
<div class="stack gap-10" style="margin-top:12px;">
|
||||
<label class="chip" style="cursor:pointer; align-items:flex-start;">
|
||||
<input type="checkbox" name="osm_places_enabled" value="1" <?= !isset($systemSettingsValues['osm_places_enabled']) || $systemSettingsValues['osm_places_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
|
||||
<span>OpenStreetMap / Nominatim aktivieren
|
||||
<span class="muted small" style="display:block;">Kostenlose Orts- und Adresssuche. Bei Deaktivierung werden keine OSM-Anfragen für neue Ortsverknüpfungen ausgelöst.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="chip" style="cursor:pointer; align-items:flex-start;">
|
||||
<input type="checkbox" name="google_places_enabled" value="1" <?= !empty($systemSettingsValues['google_places_enabled']) && $systemSettingsValues['google_places_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
|
||||
<span>Google Places vorbereitend aktivieren
|
||||
<span class="muted small" style="display:block;">Aktuell noch ohne produktive Anbindung. Der Schalter ist als technische Vorbereitung für spätere Place-ID-, Bewertungs- und Review-Anreicherung gedacht.</span>
|
||||
<span>Google Places aktivieren
|
||||
<span class="muted small" style="display:block;">Erlaubt die Google-Verknüpfung und aktuelle Gesamtwertung mit Anzahl der Bewertungen.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="chip" style="cursor:pointer; align-items:flex-start;">
|
||||
<input type="checkbox" name="azure_maps_enabled" value="1" <?= !empty($systemSettingsValues['azure_maps_enabled']) && $systemSettingsValues['azure_maps_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
|
||||
<span>Azure Maps aktivieren
|
||||
<span class="muted small" style="display:block;">Erlaubt die Microsoft-POI-Suche für Ortsverknüpfungen.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="placeDataProvider">Standardanbieter für Ortsdaten</label>
|
||||
<select id="placeDataProvider" name="place_data_provider" class="select">
|
||||
<option value="osm" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm') ? 'selected' : '' ?>>OpenStreetMap / Nominatim</option>
|
||||
<option value="osm_google_optional" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm_google_optional') ? 'selected' : '' ?>>OpenStreetMap primär, Google optional</option>
|
||||
<option value="google" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'google') ? 'selected' : '' ?>>Google Places primär</option>
|
||||
<option value="azure" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'azure') ? 'selected' : '' ?>>Azure Maps primär</option>
|
||||
<option value="all_enabled" <?= (($systemSettingsValues['place_data_provider'] ?? '') === 'all_enabled') ? 'selected' : '' ?>>Alle aktivierten Anbieter durchsuchen</option>
|
||||
</select>
|
||||
<p class="muted small">So bleibt eine kostenfreie Basis aktiv, während Google später zusätzlich vorbereitet werden kann.</p>
|
||||
</div>
|
||||
<p class="muted small" style="margin:0;">Bei einer manuellen Admin-Suche werden Name und Adressdaten des offenen Eintrags an die hier aktivierten Anbieter übertragen. Datenschutzhinweise und gegebenenfalls Consent-Text bitte vor dem produktiven Aktivieren prüfen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -348,11 +348,13 @@ final class AccountPages
|
||||
$siteMaintenanceMessage = 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.';
|
||||
}
|
||||
$placeDataProvider = (string)($_POST['place_data_provider'] ?? 'osm');
|
||||
if (!in_array($placeDataProvider, ['osm', 'osm_google_optional'], true)) {
|
||||
if (!in_array($placeDataProvider, ['osm', 'google', 'azure', 'all_enabled'], true)) {
|
||||
$placeDataProvider = 'osm';
|
||||
}
|
||||
$systemSettings->updateMany([
|
||||
'google_places_enabled' => isset($_POST['google_places_enabled']) ? '1' : '0',
|
||||
'azure_maps_enabled' => isset($_POST['azure_maps_enabled']) ? '1' : '0',
|
||||
'osm_places_enabled' => isset($_POST['osm_places_enabled']) ? '1' : '0',
|
||||
'forum_maintenance_mode' => isset($_POST['forum_maintenance_mode']) ? '1' : '0',
|
||||
'site_maintenance_mode' => isset($_POST['site_maintenance_mode']) ? '1' : '0',
|
||||
'site_maintenance_message' => $siteMaintenanceMessage,
|
||||
@@ -728,6 +730,47 @@ final class AccountPages
|
||||
}
|
||||
$listingCatalog->decidePendingApproval($userId, (int)($_POST['listing_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['review_note'] ?? ''));
|
||||
$info = 'Ort oder Veranstaltung wurde bearbeitet.';
|
||||
} elseif ($action === 'place_provider_search') {
|
||||
if (!$canReviewListings || !$listingCatalog || !$systemSettings) {
|
||||
throw new \RuntimeException('Keine Berechtigung für die externe Ortssuche.');
|
||||
}
|
||||
$listingId = (int)($_POST['listing_id'] ?? 0);
|
||||
$entry = $listingCatalog->getMemberEntry($listingId);
|
||||
if (!$entry || !in_array((string)($entry['status'] ?? ''), ['draft', 'published'], true)) {
|
||||
throw new \RuntimeException('Der Ort oder die Veranstaltung ist nicht verfügbar.');
|
||||
}
|
||||
$search = (new PlaceProviderLookup())->search($entry, $systemSettings->getAll());
|
||||
$_SESSION['place_provider_search_results'][$listingId] = $search;
|
||||
$info = $search['results'] === []
|
||||
? 'Es wurden keine passenden externen Orte gefunden.'
|
||||
: count($search['results']) . ' externe Ortstreffer gefunden.';
|
||||
} elseif ($action === 'place_provider_link') {
|
||||
if (!$canReviewListings || !$listingCatalog) {
|
||||
throw new \RuntimeException('Keine Berechtigung für externe Ortsverknüpfungen.');
|
||||
}
|
||||
$listingId = (int)($_POST['listing_id'] ?? 0);
|
||||
$resultIndex = (int)($_POST['result_index'] ?? -1);
|
||||
$storedSearch = $_SESSION['place_provider_search_results'][$listingId] ?? null;
|
||||
if (!is_array($storedSearch) || (int)($storedSearch['searched_at'] ?? 0) < time() - 900) {
|
||||
unset($_SESSION['place_provider_search_results'][$listingId]);
|
||||
throw new \RuntimeException('Die Ortstreffer sind abgelaufen. Bitte suche erneut.');
|
||||
}
|
||||
$match = is_array($storedSearch) ? ($storedSearch['results'][$resultIndex] ?? null) : null;
|
||||
if (!is_array($match)) {
|
||||
throw new \RuntimeException('Der ausgewählte Ortstreffer ist nicht mehr verfügbar. Bitte suche erneut.');
|
||||
}
|
||||
$googleDetails = (string)($match['provider'] ?? '') === 'google'
|
||||
? (new PlaceProviderLookup())->googleDetails((string)$match['id'])
|
||||
: null;
|
||||
$listingCatalog->saveProviderLink($listingId, $match, $googleDetails);
|
||||
if ($googleDetails !== null) {
|
||||
$_SESSION['place_provider_search_results'][$listingId]['results'][$resultIndex] = array_merge($match, [
|
||||
'rating' => $googleDetails['rating'],
|
||||
'rating_count' => $googleDetails['rating_count'],
|
||||
'linked' => true,
|
||||
]);
|
||||
}
|
||||
$info = 'Externer Ortstreffer wurde verknüpft.';
|
||||
} elseif ($action === 'application_decide') {
|
||||
if (!$canManageSystemSettings || !$communityAccess) {
|
||||
throw new \RuntimeException('Keine Berechtigung für Bewerbungen.');
|
||||
@@ -1067,6 +1110,30 @@ final class AccountPages
|
||||
$listingReviewRequests = $canReviewListings && $listingCatalog
|
||||
? $listingCatalog->listPendingApprovalEntries()
|
||||
: [];
|
||||
$listingReviewTab = (string)($_GET['listing_tab'] ?? 'approvals');
|
||||
if (!in_array($listingReviewTab, ['approvals', 'existing', 'missing'], true)) {
|
||||
$listingReviewTab = 'approvals';
|
||||
}
|
||||
$listingManagementSearchQuery = trim((string)($_GET['listing_query'] ?? ''));
|
||||
$systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
|
||||
$activePlaceProviders = [];
|
||||
foreach (['osm' => 'osm_places_enabled', 'google' => 'google_places_enabled', 'azure' => 'azure_maps_enabled'] as $provider => $settingKey) {
|
||||
if (($systemSettingsValues[$settingKey] ?? '0') === '1') {
|
||||
$activePlaceProviders[] = $provider;
|
||||
}
|
||||
}
|
||||
$listingManagementEntries = $canReviewListings && $listingCatalog && $listingReviewTab !== 'approvals'
|
||||
&& ($listingReviewTab !== 'missing' || $activePlaceProviders !== [])
|
||||
? $listingCatalog->listAdminExternalLinkEntries($listingManagementSearchQuery, $listingReviewTab === 'missing' ? $activePlaceProviders : [])
|
||||
: [];
|
||||
$placeProviderSearches = is_array($_SESSION['place_provider_search_results'] ?? null)
|
||||
? $_SESSION['place_provider_search_results']
|
||||
: [];
|
||||
foreach ($placeProviderSearches as $listingId => $search) {
|
||||
if (!is_array($search) || (int)($search['searched_at'] ?? 0) < time() - 900) {
|
||||
unset($placeProviderSearches[$listingId], $_SESSION['place_provider_search_results'][$listingId]);
|
||||
}
|
||||
}
|
||||
$communityApplications = $canManageSystemSettings && $communityAccess ? $communityAccess->listApplications('open') : [];
|
||||
$communityReports = $canModerateForum && $communityAccess && $communityAccess->supportsReports() ? $communityAccess->listOpenReports() : [];
|
||||
$userManagementSearchQuery = trim((string)($_GET['user_management_query'] ?? ''));
|
||||
@@ -1141,7 +1208,6 @@ final class AccountPages
|
||||
],
|
||||
],
|
||||
];
|
||||
$systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
|
||||
$listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []];
|
||||
if (!in_array($section, $allowedSections, true)) {
|
||||
$section = 'profile';
|
||||
@@ -1191,6 +1257,11 @@ final class AccountPages
|
||||
'canManageUserManagement',
|
||||
'systemRoleAssignments',
|
||||
'listingReviewRequests',
|
||||
'listingReviewTab',
|
||||
'listingManagementSearchQuery',
|
||||
'listingManagementEntries',
|
||||
'activePlaceProviders',
|
||||
'placeProviderSearches',
|
||||
'communityApplications',
|
||||
'communityReports',
|
||||
'userManagementSearchQuery',
|
||||
|
||||
@@ -7,6 +7,7 @@ final class ListingCatalog
|
||||
{
|
||||
private array $tableCache = [];
|
||||
private array $columnCache = [];
|
||||
private bool $schemaEnsured = false;
|
||||
|
||||
public function __construct(private \PDO $pdo)
|
||||
{
|
||||
@@ -14,6 +15,10 @@ final class ListingCatalog
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
if ($this->schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statements = [
|
||||
'CREATE TABLE IF NOT EXISTS listing_categories (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -48,9 +53,10 @@ final class ListingCatalog
|
||||
place_kind VARCHAR(80) NULL,
|
||||
opening_hours_note TEXT NULL,
|
||||
opening_hours_json LONGTEXT NULL,
|
||||
provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual",
|
||||
provider_hint ENUM("manual","osm","google","azure") NOT NULL DEFAULT "manual",
|
||||
external_place_id VARCHAR(190) NULL,
|
||||
google_place_id VARCHAR(190) NULL,
|
||||
provider_links_json LONGTEXT NULL,
|
||||
rating_value DECIMAL(3,2) NULL,
|
||||
rating_count INT UNSIGNED NULL,
|
||||
status ENUM("draft","published","archived") NOT NULL DEFAULT "published",
|
||||
@@ -177,6 +183,7 @@ final class ListingCatalog
|
||||
$columnStatements = [
|
||||
'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind',
|
||||
'listing_places.opening_hours_json' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_json LONGTEXT NULL AFTER opening_hours_note',
|
||||
'listing_places.provider_links_json' => 'ALTER TABLE listing_places ADD COLUMN provider_links_json LONGTEXT NULL AFTER google_place_id',
|
||||
'listings.image_path' => 'ALTER TABLE listings ADD COLUMN image_path VARCHAR(255) NULL AFTER description',
|
||||
'listings.special_conditions_note' => 'ALTER TABLE listings ADD COLUMN special_conditions_note TEXT NULL AFTER image_path',
|
||||
];
|
||||
@@ -188,6 +195,12 @@ final class ListingCatalog
|
||||
}
|
||||
}
|
||||
|
||||
$providerStatement = $this->pdo->query('SHOW COLUMNS FROM listing_places LIKE "provider_hint"');
|
||||
$providerColumn = $providerStatement ? $providerStatement->fetch(\PDO::FETCH_ASSOC) : null;
|
||||
if (is_array($providerColumn) && !str_contains((string)($providerColumn['Type'] ?? ''), "'azure'")) {
|
||||
$this->pdo->exec('ALTER TABLE listing_places MODIFY provider_hint ENUM("manual","osm","google","azure") NOT NULL DEFAULT "manual"');
|
||||
}
|
||||
|
||||
$seed = $this->pdo->prepare(
|
||||
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
|
||||
VALUES (:slug, :title, :groupName, :sortOrder)
|
||||
@@ -209,6 +222,8 @@ final class ListingCatalog
|
||||
'sortOrder' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->schemaEnsured = true;
|
||||
}
|
||||
|
||||
public function status(): array
|
||||
@@ -649,7 +664,7 @@ final class ListingCatalog
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT l.*, lp.status AS place_status, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json,
|
||||
'SELECT l.*, lp.id AS place_id, lp.status AS place_status, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json, lp.provider_hint, lp.external_place_id, lp.google_place_id, lp.provider_links_json, lp.rating_value, lp.rating_count,
|
||||
lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type,
|
||||
lo.recurrence_rule, lo.recurrence_until, lo.capacity_total,
|
||||
lc.slug AS category_slug
|
||||
@@ -671,6 +686,42 @@ final class ListingCatalog
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function saveProviderLink(int $listingId, array $match, ?array $googleDetails = null): void
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$entry = $this->getMemberEntry($listingId);
|
||||
if (!$entry || (int)($entry['place_id'] ?? 0) <= 0) {
|
||||
throw new \RuntimeException('Eintrag oder Ortsdaten nicht gefunden.');
|
||||
}
|
||||
$provider = (string)($match['provider'] ?? '');
|
||||
if (!in_array($provider, ['osm', 'google', 'azure'], true) || trim((string)($match['id'] ?? '')) === '') {
|
||||
throw new \RuntimeException('Ungültiger externer Ortstreffer.');
|
||||
}
|
||||
$links = $this->decodeJsonRows((string)($entry['provider_links_json'] ?? ''));
|
||||
$link = ['id' => (string)$match['id'], 'name' => (string)($match['name'] ?? ''), 'address' => (string)($match['address'] ?? ''), 'url' => (string)($match['url'] ?? ''), 'linked_at' => gmdate('c')];
|
||||
if ($provider === 'google' && $googleDetails !== null) {
|
||||
$link = array_merge($link, $googleDetails, ['fetched_at' => gmdate('c')]);
|
||||
}
|
||||
$links[$provider] = $link;
|
||||
$rating = $provider === 'google' ? ($googleDetails['rating'] ?? null) : ($entry['rating_value'] ?? null);
|
||||
$ratingCount = $provider === 'google' ? ($googleDetails['rating_count'] ?? null) : ($entry['rating_count'] ?? null);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE listing_places
|
||||
SET provider_hint = :provider, external_place_id = :externalId, google_place_id = :googleId,
|
||||
provider_links_json = :links, rating_value = :rating, rating_count = :ratingCount, updated_at = NOW()
|
||||
WHERE id = :placeId'
|
||||
);
|
||||
$stmt->execute([
|
||||
'provider' => $provider,
|
||||
'externalId' => $provider === 'google' ? (string)($entry['external_place_id'] ?? '') : ($provider === 'azure' ? 'azure:' : '') . (string)$match['id'],
|
||||
'googleId' => $provider === 'google' ? (string)$match['id'] : (string)($entry['google_place_id'] ?? ''),
|
||||
'links' => json_encode($links, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'rating' => $rating,
|
||||
'ratingCount' => $ratingCount,
|
||||
'placeId' => (int)$entry['place_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getDashboardEntry(int $userId, int $listingId): ?array
|
||||
{
|
||||
$row = $this->getMemberEntry($listingId);
|
||||
@@ -898,7 +949,7 @@ final class ListingCatalog
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT l.id AS listing_id, l.title, l.listing_type, l.status AS listing_status,
|
||||
lp.street, lp.zip, lp.city, lp.region,
|
||||
lp.street, lp.zip, lp.city, lp.region, lp.provider_hint, lp.provider_links_json, lp.rating_value, lp.rating_count,
|
||||
up.display_name AS requested_by_name,
|
||||
mr.id AS moderation_request_id
|
||||
FROM listings l
|
||||
@@ -919,7 +970,55 @@ final class ListingCatalog
|
||||
ORDER BY l.created_at ASC, l.id ASC'
|
||||
);
|
||||
|
||||
return $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : [];
|
||||
$rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : [];
|
||||
foreach ($rows as &$row) {
|
||||
$row['provider_links'] = $this->decodeJsonRows((string)($row['provider_links_json'] ?? ''));
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function listAdminExternalLinkEntries(string $search = '', array $requiredProviders = []): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$search = trim($search);
|
||||
$sql = 'SELECT l.id AS listing_id, l.title, l.listing_type, lp.street, lp.zip, lp.city, lp.region,
|
||||
lp.provider_hint, lp.external_place_id, lp.google_place_id, lp.provider_links_json, lp.rating_value, lp.rating_count
|
||||
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 = "published"
|
||||
AND lp.status = "published"';
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$sql .= ' AND (l.title LIKE :search OR lp.street LIKE :search OR lp.city LIKE :search OR lp.zip LIKE :search)';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
}
|
||||
$sql .= ' ORDER BY l.title ASC LIMIT 150';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||
$requiredProviders = array_values(array_intersect($requiredProviders, ['osm', 'google', 'azure']));
|
||||
foreach ($rows as &$row) {
|
||||
$row['provider_links'] = $this->decodeJsonRows((string)($row['provider_links_json'] ?? ''));
|
||||
$row['missing_providers'] = array_values(array_filter($requiredProviders, static function (string $provider) use ($row): bool {
|
||||
if (!empty($row['provider_links'][$provider]['id'])) {
|
||||
return false;
|
||||
}
|
||||
if ($provider === 'google') {
|
||||
return empty($row['google_place_id']);
|
||||
}
|
||||
if ($provider === 'osm') {
|
||||
return !((string)($row['provider_hint'] ?? '') === 'osm' && !empty($row['external_place_id']));
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
unset($row);
|
||||
if ($requiredProviders !== []) {
|
||||
$rows = array_values(array_filter($rows, static fn (array $row): bool => $row['missing_providers'] !== []));
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function decidePendingApproval(int $reviewerUserId, int $listingId, string $decision, ?string $reviewNote = null): void
|
||||
|
||||
248
src/App/PlaceProviderLookup.php
Normal file
248
src/App/PlaceProviderLookup.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/** Server-side lookup for external place providers. API keys never reach the browser. */
|
||||
final class PlaceProviderLookup
|
||||
{
|
||||
public function search(array $entry, array $settings): array
|
||||
{
|
||||
$query = $this->buildQuery($entry);
|
||||
if ($query === '') {
|
||||
throw new \RuntimeException('Für die externe Ortssuche werden mindestens Name oder Adressdaten benötigt.');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$notices = [];
|
||||
if (($settings['osm_places_enabled'] ?? '1') === '1') {
|
||||
try {
|
||||
$results = array_merge($results, $this->searchOsm($query, $entry));
|
||||
} catch (\Throwable) {
|
||||
$notices[] = 'OpenStreetMap konnte gerade nicht durchsucht werden.';
|
||||
}
|
||||
}
|
||||
if (($settings['google_places_enabled'] ?? '0') === '1') {
|
||||
if ($this->googleKey() === '') {
|
||||
$notices[] = 'Google Places ist aktiviert, aber der Server-Schlüssel fehlt.';
|
||||
} else {
|
||||
try {
|
||||
$results = array_merge($results, $this->searchGoogle($query, $entry));
|
||||
} catch (\Throwable) {
|
||||
$notices[] = 'Google Places konnte gerade nicht durchsucht werden.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (($settings['azure_maps_enabled'] ?? '0') === '1') {
|
||||
if ($this->azureKey() === '') {
|
||||
$notices[] = 'Azure Maps ist aktiviert, aber der Server-Schlüssel fehlt.';
|
||||
} else {
|
||||
try {
|
||||
$results = array_merge($results, $this->searchAzure($query, $entry));
|
||||
} catch (\Throwable) {
|
||||
$notices[] = 'Azure Maps konnte gerade nicht durchsucht werden.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($results, static function (array $a, array $b): int {
|
||||
$aDistance = $a['distance_m'] ?? PHP_INT_MAX;
|
||||
$bDistance = $b['distance_m'] ?? PHP_INT_MAX;
|
||||
return $aDistance <=> $bDistance ?: strcmp((string)$a['name'], (string)$b['name']);
|
||||
});
|
||||
|
||||
return ['query' => $query, 'results' => $results, 'notices' => $notices, 'searched_at' => time()];
|
||||
}
|
||||
|
||||
public function googleDetails(string $placeResource): ?array
|
||||
{
|
||||
$key = $this->googleKey();
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
$resource = ltrim($placeResource, '/');
|
||||
if (!str_starts_with($resource, 'places/')) {
|
||||
return null;
|
||||
}
|
||||
$data = $this->requestJson(
|
||||
'GET',
|
||||
'https://places.googleapis.com/v1/' . implode('/', array_map(rawurlencode(...), explode('/', $resource))),
|
||||
null,
|
||||
[
|
||||
'X-Goog-Api-Key: ' . $key,
|
||||
'X-Goog-FieldMask: id,displayName,formattedAddress,googleMapsUri,rating,userRatingCount',
|
||||
]
|
||||
);
|
||||
if (!is_array($data) || empty($data['id'])) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'id' => (string)$data['id'],
|
||||
'name' => (string)($data['displayName']['text'] ?? ''),
|
||||
'address' => (string)($data['formattedAddress'] ?? ''),
|
||||
'url' => (string)($data['googleMapsUri'] ?? ''),
|
||||
'rating' => isset($data['rating']) ? (float)$data['rating'] : null,
|
||||
'rating_count' => isset($data['userRatingCount']) ? (int)$data['userRatingCount'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
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'])) {
|
||||
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));
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function searchGoogle(string $query, array $entry): array
|
||||
{
|
||||
$payload = ['textQuery' => $query, 'languageCode' => 'de', 'regionCode' => 'DE'];
|
||||
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
|
||||
$payload['locationBias'] = ['circle' => ['center' => ['latitude' => (float)$entry['lat'], 'longitude' => (float)$entry['lng']], 'radius' => 5000.0]];
|
||||
}
|
||||
$data = $this->requestJson('POST', 'https://places.googleapis.com/v1/places:searchText', $payload, [
|
||||
'X-Goog-Api-Key: ' . $this->googleKey(),
|
||||
'X-Goog-FieldMask: places.id,places.displayName,places.formattedAddress,places.location',
|
||||
]);
|
||||
$results = [];
|
||||
foreach ((array)($data['places'] ?? []) as $row) {
|
||||
if (!is_array($row) || empty($row['id'])) {
|
||||
continue;
|
||||
}
|
||||
$location = (array)($row['location'] ?? []);
|
||||
$results[] = $this->result('google', (string)$row['id'], (string)($row['displayName']['text'] ?? ''), (string)($row['formattedAddress'] ?? ''), isset($location['latitude']) ? (float)$location['latitude'] : null, isset($location['longitude']) ? (float)$location['longitude'] : null, $entry, null);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function searchAzure(string $query, array $entry): array
|
||||
{
|
||||
$params = ['api-version' => '1.0', 'subscription-key' => $this->azureKey(), 'query' => $query, 'limit' => 3, 'countrySet' => 'DE', 'language' => 'de-DE'];
|
||||
if (isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null) {
|
||||
$params['lat'] = (float)$entry['lat'];
|
||||
$params['lon'] = (float)$entry['lng'];
|
||||
$params['radius'] = 5000;
|
||||
}
|
||||
$data = $this->requestJson('GET', 'https://eu.atlas.microsoft.com/search/poi/json?' . http_build_query($params));
|
||||
$results = [];
|
||||
foreach ((array)($data['results'] ?? []) as $row) {
|
||||
if (!is_array($row) || empty($row['id'])) {
|
||||
continue;
|
||||
}
|
||||
$poi = (array)($row['poi'] ?? []);
|
||||
$address = (array)($row['address'] ?? []);
|
||||
$results[] = $this->result('azure', (string)$row['id'], (string)($poi['name'] ?? ''), (string)($address['freeformAddress'] ?? ''), isset($row['position']['lat']) ? (float)$row['position']['lat'] : null, isset($row['position']['lon']) ? (float)$row['position']['lon'] : null, $entry, null);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function result(string $provider, string $id, string $name, string $address, ?float $lat, ?float $lng, array $entry, ?string $url): array
|
||||
{
|
||||
return ['provider' => $provider, 'id' => $id, 'name' => $name !== '' ? $name : $address, 'address' => $address, 'lat' => $lat, 'lng' => $lng, 'url' => $url, 'distance_m' => $lat !== null && $lng !== null && isset($entry['lat'], $entry['lng']) && $entry['lat'] !== null && $entry['lng'] !== null ? $this->distance((float)$entry['lat'], (float)$entry['lng'], $lat, $lng) : null];
|
||||
}
|
||||
|
||||
private function buildQuery(array $entry): string
|
||||
{
|
||||
return trim(implode(', ', array_filter([(string)($entry['title'] ?? $entry['place_title'] ?? ''), (string)($entry['street'] ?? ''), trim((string)($entry['zip'] ?? '') . ' ' . (string)($entry['city'] ?? '')), (string)($entry['region'] ?? ''), 'Deutschland'])));
|
||||
}
|
||||
|
||||
private function googleKey(): string { return trim((string)getenv('GOOGLE_MAPS_API_KEY')); }
|
||||
private function azureKey(): string { return trim((string)getenv('AZURE_MAPS_SUBSCRIPTION_KEY')); }
|
||||
|
||||
private function requestJson(string $method, string $url, ?array $payload = null, array $headers = []): array
|
||||
{
|
||||
$headers[] = 'Accept: application/json';
|
||||
$headers[] = 'User-Agent: Papa-Kind-Treff/1.0 (+https://papa-kind-treff.de/)';
|
||||
$body = $payload === null ? null : json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$handle = curl_init($url);
|
||||
curl_setopt_array($handle, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5]);
|
||||
if ($body !== null) { curl_setopt($handle, CURLOPT_POSTFIELDS, $body); }
|
||||
$response = curl_exec($handle);
|
||||
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($handle);
|
||||
if (!is_string($response) || $status < 200 || $status >= 300) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
|
||||
} else {
|
||||
$response = @file_get_contents($url, false, stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", $headers), 'content' => $body ?? '', 'timeout' => 10, 'ignore_errors' => true]]));
|
||||
if (!is_string($response)) { throw new \RuntimeException('Externer Dienst nicht erreichbar.'); }
|
||||
}
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded)) { throw new \RuntimeException('Ungültige Antwort des externen Dienstes.'); }
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/** Nominatim requires cached requests and a global maximum of one request per second. */
|
||||
private function cachedOsmRequest(string $url): array
|
||||
{
|
||||
$directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'papa-kind-treff-osm';
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
|
||||
}
|
||||
$cacheFile = $directory . DIRECTORY_SEPARATOR . hash('sha256', $url) . '.json';
|
||||
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
|
||||
$cached = json_decode((string)file_get_contents($cacheFile), true);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
$lock = fopen($directory . DIRECTORY_SEPARATOR . 'request.lock', 'c+');
|
||||
if ($lock === false) {
|
||||
return $this->requestJson('GET', $url, null, ['Accept-Language: de']);
|
||||
}
|
||||
try {
|
||||
flock($lock, LOCK_EX);
|
||||
clearstatcache(true, $cacheFile);
|
||||
if (is_file($cacheFile) && filemtime($cacheFile) >= time() - 900) {
|
||||
$cached = json_decode((string)file_get_contents($cacheFile), true);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
rewind($lock);
|
||||
$lastRequestAt = (float)trim((string)stream_get_contents($lock));
|
||||
$waitMicros = (int)max(0, (1.0 - (microtime(true) - $lastRequestAt)) * 1000000);
|
||||
if ($waitMicros > 0) {
|
||||
usleep($waitMicros);
|
||||
}
|
||||
$rows = $this->requestJson('GET', $url, null, ['Accept-Language: de']);
|
||||
file_put_contents($cacheFile, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
ftruncate($lock, 0);
|
||||
rewind($lock);
|
||||
fwrite($lock, (string)microtime(true));
|
||||
fflush($lock);
|
||||
return $rows;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
private function distance(float $lat1, float $lng1, float $lat2, float $lng2): int
|
||||
{
|
||||
$earthRadius = 6371000.0;
|
||||
$dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1);
|
||||
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
|
||||
return (int)round($earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a)));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ final class SystemSettings
|
||||
|
||||
private const DEFAULTS = [
|
||||
'google_places_enabled' => '0',
|
||||
'azure_maps_enabled' => '0',
|
||||
'osm_places_enabled' => '1',
|
||||
'forum_maintenance_mode' => '0',
|
||||
'site_maintenance_mode' => '0',
|
||||
'site_maintenance_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',
|
||||
|
||||
Reference in New Issue
Block a user