rebuild events
All checks were successful
Deploy / deploy (push) Successful in 59s

This commit is contained in:
2026-08-04 20:59:40 +02:00
parent 1a58ec91de
commit 9cfa26a031
11 changed files with 489 additions and 50 deletions

View File

@@ -377,9 +377,40 @@ final class AccountPages
if (!$listingCatalog) {
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
}
$payload = $_POST;
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if ($hasAddress) {
$resolvedAddress = null;
if ($lat !== null && $lng !== null) {
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
}
if ($resolvedAddress === null) {
$searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5);
if (!$searchResults) {
throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.');
}
$resolvedAddress = $searchResults[0];
}
$payload['street'] = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street;
$payload['zip'] = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip;
$payload['city'] = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city;
$payload['region'] = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
$payload['lat'] = $resolvedAddress['lat'];
$payload['lng'] = $resolvedAddress['lng'];
}
$payload['teaser'] = self::generateTeaser(
trim((string)($payload['description'] ?? '')),
trim((string)($payload['title'] ?? ''))
);
$listingCatalog->saveDashboardEntry(
$userId,
$_POST,
$payload,
$action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null
);
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
@@ -403,7 +434,7 @@ final class AccountPages
$stmt?->execute([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
@@ -432,7 +463,7 @@ final class AccountPages
'id' => $eventId,
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
@@ -605,6 +636,7 @@ final class AccountPages
$otherListings = [];
$editListing = null;
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
@@ -671,6 +703,7 @@ final class AccountPages
'otherListings',
'editListing',
'listingCategories',
'placeSuggestions',
'communityPoints',
'communityLevel',
'communityRoles',
@@ -702,6 +735,22 @@ final class AccountPages
return $birthDateValue->diff($today)->y;
}
private static function generateTeaser(string $description, string $title = ''): string
{
$source = trim($description) !== '' ? trim($description) : trim($title);
if ($source === '') {
return '';
}
$collapsed = preg_replace('/\s+/u', ' ', $source) ?: $source;
$collapsed = trim($collapsed);
if (mb_strlen($collapsed) <= 140) {
return $collapsed;
}
return rtrim(mb_substr($collapsed, 0, 137)) . '...';
}
private static function requireCrypto(?Crypto $crypto, string $context): Crypto
{
if ($crypto instanceof Crypto) {

View File

@@ -6,6 +6,7 @@ namespace App;
final class ListingCatalog
{
private array $tableCache = [];
private array $columnCache = [];
public function __construct(private \PDO $pdo)
{
@@ -142,6 +143,17 @@ final class ListingCatalog
$this->pdo->exec($sql);
}
$columnStatements = [
'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind',
];
foreach ($columnStatements as $key => $sql) {
[$table, $column] = explode('.', $key, 2);
if (!$this->hasColumn($table, $column)) {
$this->pdo->exec($sql);
$this->columnCache[$key] = true;
}
}
$seed = $this->pdo->prepare(
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
VALUES (:slug, :title, :groupName, :sortOrder)
@@ -229,8 +241,9 @@ final class ListingCatalog
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'SELECT l.*, 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,
'SELECT l.*, 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.opening_hours_note,
lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type,
lo.recurrence_rule, lo.recurrence_until,
lc.slug AS category_slug
FROM listings l
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
@@ -245,7 +258,26 @@ final class ListingCatalog
'uid' => $userId,
]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
if (!$row) {
return null;
}
$row['prices'] = $this->listPrices((int)$row['id']);
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
LIMIT :limit'
);
$stmt->bindValue(':limit', max(1, min(200, $limit)), \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null): int
@@ -259,6 +291,7 @@ final class ListingCatalog
$title = trim((string)($data['title'] ?? ''));
$teaser = trim((string)($data['teaser'] ?? ''));
$description = trim((string)($data['description'] ?? ''));
$openingHoursNote = trim((string)($data['opening_hours_note'] ?? ''));
$visibility = (string)($data['visibility'] ?? 'public');
$placeKind = trim((string)($data['category_slug'] ?? ''));
$street = trim((string)($data['street'] ?? ''));
@@ -268,6 +301,10 @@ final class ListingCatalog
$lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null;
$lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null;
$startsAt = trim((string)($data['starts_at'] ?? ''));
$recurrenceMode = (string)($data['recurrence_mode'] ?? 'single');
$recurrenceUntil = trim((string)($data['recurrence_until'] ?? ''));
$weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? []))));
$priceMode = (string)($data['price_mode'] ?? 'none');
if ($title === '' || $teaser === '' || $description === '') {
throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.');
@@ -298,7 +335,7 @@ final class ListingCatalog
if ($placeId > 0) {
$placeStmt = $this->pdo->prepare(
'UPDATE listing_places
SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, updated_at = NOW()
SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, opening_hours_note = :openingHoursNote, updated_at = NOW()
WHERE id = :id'
);
$placeStmt->execute([
@@ -311,6 +348,7 @@ final class ListingCatalog
'lat' => $lat,
'lng' => $lng,
'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'id' => $placeId,
]);
}
@@ -331,21 +369,11 @@ final class ListingCatalog
]);
$this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
if ($entryType === 'editorial_event') {
$occStmt = $this->pdo->prepare(
'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, status, created_at, updated_at)
VALUES (:listingId, :occurrenceType, :startsAt, "scheduled", NOW(), NOW())'
);
$occStmt->execute([
'listingId' => $listingId,
'occurrenceType' => 'single',
'startsAt' => $startsAt !== '' ? $startsAt : null,
]);
}
$this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data);
} else {
$placeStmt = $this->pdo->prepare(
'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, provider_hint, status, created_at, updated_at)
VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, "manual", "published", NOW(), NOW())'
'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, opening_hours_note, provider_hint, status, created_at, updated_at)
VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())'
);
$placeStmt->execute([
'uid' => $userId,
@@ -358,6 +386,7 @@ final class ListingCatalog
'lat' => $lat,
'lng' => $lng,
'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
]);
$placeId = (int)$this->pdo->lastInsertId();
@@ -376,17 +405,7 @@ final class ListingCatalog
]);
$listingId = (int)$this->pdo->lastInsertId();
if ($entryType === 'editorial_event') {
$occStmt = $this->pdo->prepare(
'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, status, created_at, updated_at)
VALUES (:listingId, :occurrenceType, :startsAt, "scheduled", NOW(), NOW())'
);
$occStmt->execute([
'listingId' => $listingId,
'occurrenceType' => 'single',
'startsAt' => $startsAt !== '' ? $startsAt : null,
]);
}
$this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data);
}
$this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
@@ -408,6 +427,118 @@ final class ListingCatalog
}
}
private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, string $priceMode, array $data): void
{
$occurrenceId = null;
if ($entryType === 'editorial_event') {
[$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues);
$occStmt = $this->pdo->prepare(
'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, status, created_at, updated_at)
VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, "scheduled", NOW(), NOW())'
);
$occStmt->execute([
'listingId' => $listingId,
'occurrenceType' => $occurrenceType,
'startsAt' => $startsAt !== '' ? $startsAt : null,
'recurrenceRule' => $recurrenceRule,
'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null,
]);
$occurrenceId = (int)$this->pdo->lastInsertId();
}
$this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
if ($priceMode === 'none') {
return;
}
$insertPrice = $this->pdo->prepare(
'INSERT INTO listing_prices (listing_id, occurrence_id, label, audience, price_type, amount, amount_secondary, currency, note, created_at, updated_at)
VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())'
);
if ($priceMode === 'free_child_rule') {
$freeUntil = trim((string)($data['free_child_until_age'] ?? ''));
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Eintritt frei',
'audience' => 'child',
'priceType' => 'free',
'amount' => null,
'amountSecondary' => null,
'note' => $freeUntil !== '' ? 'Kinder bis ' . $freeUntil . ' Jahre frei' : 'Kinder frei',
]);
}
$childAmount = trim((string)($data['price_child_amount'] ?? ''));
if ($childAmount !== '') {
$childFrom = trim((string)($data['price_child_age_from'] ?? ''));
$childTo = trim((string)($data['price_child_age_to'] ?? ''));
$note = 'Preis pro Kind';
if ($childFrom !== '' || $childTo !== '') {
$note .= ' (' . trim(($childFrom !== '' ? 'ab ' . $childFrom : '') . ($childTo !== '' ? ' bis ' . $childTo : '') . ' Jahre') . ')';
}
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Kinder',
'audience' => 'child',
'priceType' => 'fixed',
'amount' => (float)str_replace(',', '.', $childAmount),
'amountSecondary' => null,
'note' => $note,
]);
}
$adultAmount = trim((string)($data['price_adult_amount'] ?? ''));
if ($adultAmount !== '') {
$adultFrom = trim((string)($data['price_adult_age_from'] ?? ''));
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Erwachsene',
'audience' => 'adult',
'priceType' => 'fixed',
'amount' => (float)str_replace(',', '.', $adultAmount),
'amountSecondary' => null,
'note' => $adultFrom !== '' ? 'Preis pro Erwachsener ab ' . $adultFrom . ' Jahre' : 'Preis pro Erwachsener',
]);
}
}
private function listPrices(int $listingId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM listing_prices WHERE listing_id = :listingId ORDER BY id ASC');
$stmt->execute(['listingId' => $listingId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
private function buildRecurrence(string $recurrenceMode, array $weekdayValues): array
{
$map = ['mo' => 'MO', 'di' => 'TU', 'mi' => 'WE', 'do' => 'TH', 'fr' => 'FR', 'sa' => 'SA', 'so' => 'SU'];
if ($recurrenceMode === 'daily') {
return ['series', 'FREQ=DAILY'];
}
if ($recurrenceMode === 'weekly') {
return ['series', 'FREQ=WEEKLY'];
}
if ($recurrenceMode === 'weekdays') {
return ['series', 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR'];
}
if ($recurrenceMode === 'custom_weekdays' && $weekdayValues !== []) {
$days = [];
foreach ($weekdayValues as $value) {
if (isset($map[$value])) {
$days[] = $map[$value];
}
}
if ($days !== []) {
return ['series', 'FREQ=WEEKLY;BYDAY=' . implode(',', $days)];
}
}
return ['single', null];
}
public function deleteDashboardEntry(int $userId, int $listingId): void
{
$this->ensureSchema();
@@ -451,6 +582,24 @@ final class ListingCatalog
}
}
private function hasColumn(string $table, string $column): bool
{
$key = $table . '.' . $column;
if (array_key_exists($key, $this->columnCache)) {
return $this->columnCache[$key];
}
try {
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1');
$stmt->execute([
'table' => $table,
'column' => $column,
]);
return $this->columnCache[$key] = (bool)$stmt->fetchColumn();
} catch (\Throwable) {
return $this->columnCache[$key] = false;
}
}
private function defaultCategories(): array
{
return [