This commit is contained in:
@@ -22,6 +22,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
|
||||
- Profiladresse kann per Suche oder Browser-Standort übernommen werden.
|
||||
- Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt.
|
||||
- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen.
|
||||
- Orte und Veranstaltungen als Ausflugsziele sind vorerst in einen separaten Mitgliederbereichspunkt `Ausflugsziele` verschoben.
|
||||
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder.
|
||||
- Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.
|
||||
|
||||
@@ -18,6 +18,7 @@ Stand: 2026-08-05
|
||||
- Consent- und rechtliche Texte: `public/page/datenschutz.php`
|
||||
- System-Einstellungen: `src/App/SystemSettings.php`
|
||||
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
|
||||
- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php`
|
||||
- UI für getrennte Bereiche `Events` und `Ausflugsziele` im Mitgliederbereich: `partials/landing/account/dashboard.php`
|
||||
- Preis-, Bild- und Ortseingabe für Ausflugsziele sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
|
||||
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder
|
||||
|
||||
@@ -27,6 +27,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Ausflugsziele` verschoben
|
||||
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
|
||||
- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden
|
||||
- Im Bereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
|
||||
|
||||
## Wording-Regel
|
||||
- Primärbegriff im Produkt: `Events`
|
||||
@@ -44,6 +45,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
## Datenschutz und Sicherheit
|
||||
- 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
|
||||
- Profilfelder wie Vorname, Nachname, Straße/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung verschlüsselt
|
||||
- Standortkoordinaten (`lat`, `lng`) bleiben für Distanz- und Umkreissuchen technisch im Klartext
|
||||
- nicht notwendige Analyse- und Drittanbieterfunktionen erst nach Einwilligung aktiv
|
||||
|
||||
@@ -22,6 +22,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
|
||||
- Profile address can be completed via address search or browser-based location import.
|
||||
- The `Events` area is limited again to real own events and event participations.
|
||||
- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations.
|
||||
- Places and event-like outing targets were moved for now into a separate member-area section `Outings`.
|
||||
- The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events.
|
||||
- Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed.
|
||||
|
||||
@@ -18,6 +18,7 @@ Updated: 2026-08-05
|
||||
- consent and legal texts: `public/page/datenschutz.php`
|
||||
- system settings: `src/App/SystemSettings.php`
|
||||
- new listing/place base: `src/App/ListingCatalog.php`
|
||||
- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php`
|
||||
- separated `Events` and `Outings` UI in member area: `partials/landing/account/dashboard.php`
|
||||
- pricing, image upload, and place/address input for outings plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
|
||||
- legacy own events were extended in `src/App/AccountPages.php` and `schema.sql` with category and image fields
|
||||
|
||||
@@ -27,6 +27,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
- places and other events were moved for now into a separate member-area section `Outings`
|
||||
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
|
||||
- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area
|
||||
- the `Events` area now also includes an ICS download and a personal subscribable calendar feed for all own events and event participations
|
||||
|
||||
## Core Wording Rule
|
||||
- Primary product term: `Events`
|
||||
@@ -43,6 +44,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
## Privacy and Security
|
||||
- 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
|
||||
- profile fields such as first name, last name, street/house number, phone number, profession, languages, and short bio are encrypted
|
||||
- location coordinates (`lat`, `lng`) remain plaintext where technically required for distance and radius search
|
||||
- non-essential analytics and third-party services are only active after consent
|
||||
|
||||
@@ -41,10 +41,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
|
||||
- Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung
|
||||
- Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen
|
||||
- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar
|
||||
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
|
||||
|
||||
## Datenschutz und Sicherheit
|
||||
- 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
|
||||
- Standortkoordinaten bleiben technisch im Klartext, soweit sie für Distanz- und Umkreissuchen erforderlich sind
|
||||
- Änderungen an Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern erfordern immer auch die Prüfung und Aktualisierung von Consent und rechtlichen Hinweisen
|
||||
|
||||
|
||||
@@ -284,7 +284,22 @@ if (!empty($canManageSystemSettings)) {
|
||||
<div class="account-panel__body">
|
||||
<div class="flex gap-12" style="margin:0 0 16px 0; flex-wrap: wrap;">
|
||||
<button class="btn" type="button" data-modal-open="modalEvent">Eigenes Event anlegen</button>
|
||||
<?php if (!empty($calendarExportUrl)): ?>
|
||||
<a class="btn ghost" href="<?= htmlspecialchars((string)$calendarExportUrl, ENT_QUOTES) ?>">ICS herunterladen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($calendarExportUrl) && !empty($calendarFeedUrl)): ?>
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<strong>Kalender-Synchronisierung</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Exportiert werden immer alle eigenen Events und alle Events, an denen du teilnimmst.</p>
|
||||
<p class="muted small" style="margin:8px 0 0;">„ICS herunterladen“ ist nur eine Momentaufnahme für den manuellen Import. Änderungen, Absagen oder Löschungen in Papa-Kind-Treff werden dadurch später nicht automatisch aus deinem privaten Kalender entfernt.</p>
|
||||
<p class="muted small" style="margin:8px 0 0;">Für automatische Aktualisierungen nutze den persönlichen Kalender-Feed. Dann können unterstützte Kalender gelöschte oder abgesagte Einträge bei der nächsten Synchronisierung übernehmen.</p>
|
||||
<div class="stack gap-6" style="margin-top:12px;">
|
||||
<label class="label" for="calendarFeedUrl">Persönlicher Kalender-Feed</label>
|
||||
<input id="calendarFeedUrl" class="input" type="text" readonly value="<?= htmlspecialchars((string)$calendarFeedUrl, ENT_QUOTES) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<strong>Eigene Events</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Von dir organisierte Treffen oder Events mit eigener Teilnahme-Logik.</p>
|
||||
|
||||
@@ -71,6 +71,9 @@ if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/calendar'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Ausgabe
|
||||
|
||||
26
public/page/calendar/export.php
Normal file
26
public/page/calendar/export.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\App;
|
||||
use App\CalendarSync;
|
||||
|
||||
$app = App::get();
|
||||
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Login erforderlich.';
|
||||
return;
|
||||
}
|
||||
|
||||
$calendarSync = new CalendarSync($app);
|
||||
$calendarSync->ensureSchema();
|
||||
|
||||
$userId = (int)$_SESSION['user_id'];
|
||||
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
|
||||
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="papa-kind-treff-events.ics"');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
|
||||
echo $ics;
|
||||
35
public/page/calendar/feed.php
Normal file
35
public/page/calendar/feed.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\App;
|
||||
use App\CalendarSync;
|
||||
|
||||
$app = App::get();
|
||||
$token = trim((string)($_GET['token'] ?? ''));
|
||||
|
||||
if ($token === '') {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Token fehlt.';
|
||||
return;
|
||||
}
|
||||
|
||||
$calendarSync = new CalendarSync($app);
|
||||
$calendarSync->ensureSchema();
|
||||
|
||||
$userId = $calendarSync->findUserIdByFeedToken($token);
|
||||
if (!$userId) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Kalender-Feed nicht gefunden.';
|
||||
return;
|
||||
}
|
||||
|
||||
$calendarSync->touchFeedAccess($userId);
|
||||
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
|
||||
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('Content-Disposition: inline; filename="papa-kind-treff-events-feed.ics"');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
|
||||
echo $ics;
|
||||
@@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
|
||||
<main class="container section legal-page">
|
||||
<div class="content-card content-card--narrow page-copy">
|
||||
<h1>Datenschutz & Cookies</h1>
|
||||
<p class="muted small">Stand: 4. August 2026</p>
|
||||
<p class="muted small">Stand: 5. August 2026</p>
|
||||
|
||||
<p>
|
||||
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff
|
||||
@@ -62,6 +62,12 @@ $clientCookie = $config->cookiePrefix() . 'client';
|
||||
Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der
|
||||
Anwendung verschlüsselt gespeichert und verarbeitet.
|
||||
</p>
|
||||
<p>
|
||||
Wenn du deine Events in einen privaten Kalender exportierst oder einen persönlichen Kalender-Feed abonnierst,
|
||||
werden dabei ausschließlich deine eigenen Events und deine Event-Teilnahmen als Kalenderdaten bereitgestellt.
|
||||
Der abonnierbare Feed ist über eine persönliche, nicht öffentliche URL abgesichert. Diese URL sollte vertraulich
|
||||
behandelt und nicht an Dritte weitergegeben werden.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des
|
||||
Nutzungsverhältnisses erforderlich ist.
|
||||
|
||||
11
schema.sql
11
schema.sql
@@ -424,6 +424,17 @@ CREATE TABLE user_tokens (
|
||||
INDEX idx_ut_type (type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE user_calendar_feeds (
|
||||
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
token_encrypted TEXT NOT NULL,
|
||||
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at DATETIME NULL,
|
||||
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Audit-Log für wichtige Aktionen
|
||||
CREATE TABLE audit_log (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -176,6 +176,7 @@ final class AccountPages
|
||||
$profileSettings = $pdo ? new ProfileSettings($pdo) : null;
|
||||
$systemSettings = $pdo ? new SystemSettings($pdo) : null;
|
||||
$listingCatalog = $pdo ? new ListingCatalog($pdo) : null;
|
||||
$calendarSync = $pdo ? new CalendarSync($app) : null;
|
||||
$section = (string)($_GET['section'] ?? 'profile');
|
||||
$canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false;
|
||||
$allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings'];
|
||||
@@ -189,6 +190,9 @@ final class AccountPages
|
||||
if ($listingCatalog) {
|
||||
$listingCatalog->ensureSchema();
|
||||
}
|
||||
if ($calendarSync) {
|
||||
$calendarSync->ensureSchema();
|
||||
}
|
||||
if ($pdo) {
|
||||
self::ensureLegacyEventSchema($pdo);
|
||||
}
|
||||
@@ -785,6 +789,13 @@ final class AccountPages
|
||||
$section = 'profile';
|
||||
}
|
||||
$avatarBuilder = AvatarManager::builderStyles($profile);
|
||||
$calendarExportUrl = null;
|
||||
$calendarFeedUrl = null;
|
||||
if ($calendarSync) {
|
||||
$calendarToken = $calendarSync->getOrCreateFeedToken($userId);
|
||||
$calendarExportUrl = CalendarSync::buildAbsoluteUrl('/calendar/export');
|
||||
$calendarFeedUrl = CalendarSync::buildAbsoluteUrl('/calendar/feed?token=' . rawurlencode($calendarToken));
|
||||
}
|
||||
|
||||
return compact(
|
||||
'flash',
|
||||
@@ -813,6 +824,8 @@ final class AccountPages
|
||||
'systemSettingsValues',
|
||||
'listingCatalogStatus',
|
||||
'avatarBuilder',
|
||||
'calendarExportUrl',
|
||||
'calendarFeedUrl',
|
||||
'section',
|
||||
'allowedSections'
|
||||
);
|
||||
|
||||
366
src/App/CalendarSync.php
Normal file
366
src/App/CalendarSync.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class CalendarSync
|
||||
{
|
||||
public function __construct(private App $app)
|
||||
{
|
||||
}
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
$this->pdo()->exec(
|
||||
'CREATE TABLE IF NOT EXISTS user_calendar_feeds (
|
||||
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
token_encrypted TEXT NOT NULL,
|
||||
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at DATETIME NULL,
|
||||
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOrCreateFeedToken(int $userId): string
|
||||
{
|
||||
$existing = $this->getFeedToken($userId);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
$crypto = $this->crypto();
|
||||
$stmt = $this->pdo()->prepare(
|
||||
'INSERT INTO user_calendar_feeds (user_id, token_encrypted, token_lookup_hash, created_at, rotated_at)
|
||||
VALUES (:userId, :tokenEncrypted, :tokenLookupHash, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
token_encrypted = VALUES(token_encrypted),
|
||||
token_lookup_hash = VALUES(token_lookup_hash),
|
||||
rotated_at = NOW()'
|
||||
);
|
||||
$stmt->execute([
|
||||
'userId' => $userId,
|
||||
'tokenEncrypted' => $crypto->encrypt($token),
|
||||
'tokenLookupHash' => hash('sha256', $token),
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function getFeedToken(int $userId): ?string
|
||||
{
|
||||
$stmt = $this->pdo()->prepare('SELECT token_encrypted FROM user_calendar_feeds WHERE user_id = :userId LIMIT 1');
|
||||
$stmt->execute(['userId' => $userId]);
|
||||
$encrypted = $stmt->fetchColumn();
|
||||
if (!is_string($encrypted) || trim($encrypted) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $this->crypto()->decrypt($encrypted);
|
||||
return is_string($token) && trim($token) !== '' ? $token : null;
|
||||
}
|
||||
|
||||
public function findUserIdByFeedToken(string $token): ?int
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo()->prepare(
|
||||
'SELECT user_id
|
||||
FROM user_calendar_feeds
|
||||
WHERE token_lookup_hash = :tokenLookupHash
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['tokenLookupHash' => hash('sha256', $token)]);
|
||||
$userId = $stmt->fetchColumn();
|
||||
|
||||
return $userId !== false ? (int)$userId : null;
|
||||
}
|
||||
|
||||
public function touchFeedAccess(int $userId): void
|
||||
{
|
||||
$stmt = $this->pdo()->prepare(
|
||||
'UPDATE user_calendar_feeds
|
||||
SET last_accessed_at = NOW()
|
||||
WHERE user_id = :userId'
|
||||
);
|
||||
$stmt->execute(['userId' => $userId]);
|
||||
}
|
||||
|
||||
public function listCalendarEventsForUser(int $userId): array
|
||||
{
|
||||
$stmt = $this->pdo()->prepare(
|
||||
'SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
|
||||
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
|
||||
"owner" AS relation_type,
|
||||
NULL AS participation_status,
|
||||
NULL AS host_name
|
||||
FROM events e
|
||||
WHERE e.created_by = :userId
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
|
||||
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
|
||||
"participant" AS relation_type,
|
||||
ep.status AS participation_status,
|
||||
COALESCE(up.display_name, "Mitglied") AS host_name
|
||||
FROM event_participants ep
|
||||
INNER JOIN events e ON e.id = ep.event_id
|
||||
INNER JOIN users u ON u.id = e.created_by
|
||||
LEFT JOIN user_profiles up ON up.user_id = u.id
|
||||
WHERE ep.user_id = :participantId
|
||||
AND e.created_by <> :ownerId
|
||||
AND ep.status <> "cancelled"
|
||||
|
||||
ORDER BY starts_at ASC, id ASC'
|
||||
);
|
||||
$stmt->execute([
|
||||
'userId' => $userId,
|
||||
'participantId' => $userId,
|
||||
'ownerId' => $userId,
|
||||
]);
|
||||
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function renderUserCalendarIcs(int $userId, string $calendarName = 'Papa-Kind-Treff Events'): string
|
||||
{
|
||||
return $this->renderCalendarIcs($calendarName, $this->listCalendarEventsForUser($userId));
|
||||
}
|
||||
|
||||
public function renderCalendarIcs(string $calendarName, array $events): string
|
||||
{
|
||||
$lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Papa-Kind-Treff//Kalender//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
||||
'X-WR-TIMEZONE:Europe/Berlin',
|
||||
];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$lines = array_merge($lines, $this->buildEventLines($event));
|
||||
}
|
||||
|
||||
$lines[] = 'END:VCALENDAR';
|
||||
|
||||
return $this->foldLines($lines);
|
||||
}
|
||||
|
||||
public static function buildAbsoluteUrl(string $path): string
|
||||
{
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
|
||||
return $scheme . '://' . $host . $path;
|
||||
}
|
||||
|
||||
private function buildEventLines(array $event): array
|
||||
{
|
||||
$lines = [
|
||||
'BEGIN:VEVENT',
|
||||
'UID:' . $this->buildUid((int)($event['id'] ?? 0), (string)($event['relation_type'] ?? 'event')),
|
||||
'DTSTAMP:' . gmdate('Ymd\THis\Z'),
|
||||
'SUMMARY:' . $this->escapeText((string)($event['title'] ?? 'Papa-Kind-Treff Event')),
|
||||
];
|
||||
|
||||
$startAt = trim((string)($event['starts_at'] ?? ''));
|
||||
$endAt = trim((string)($event['ends_at'] ?? ''));
|
||||
$isAllDay = $this->isAllDayEvent($startAt, $endAt);
|
||||
if ($isAllDay) {
|
||||
$startDate = $this->toDateString($startAt);
|
||||
if ($startDate !== null) {
|
||||
$lines[] = 'DTSTART;VALUE=DATE:' . $startDate;
|
||||
$lines[] = 'DTEND;VALUE=DATE:' . $this->incrementDateString($startDate);
|
||||
}
|
||||
} else {
|
||||
$startDateTime = $this->toDateTimeString($startAt);
|
||||
if ($startDateTime !== null) {
|
||||
$lines[] = 'DTSTART;TZID=Europe/Berlin:' . $startDateTime;
|
||||
}
|
||||
|
||||
$endDateTime = $this->toDateTimeString($endAt);
|
||||
if ($endDateTime !== null) {
|
||||
$lines[] = 'DTEND;TZID=Europe/Berlin:' . $endDateTime;
|
||||
}
|
||||
}
|
||||
|
||||
$description = $this->buildDescription($event);
|
||||
if ($description !== '') {
|
||||
$lines[] = 'DESCRIPTION:' . $this->escapeText($description);
|
||||
}
|
||||
|
||||
$location = $this->buildLocation($event);
|
||||
if ($location !== '') {
|
||||
$lines[] = 'LOCATION:' . $this->escapeText($location);
|
||||
}
|
||||
|
||||
if (($event['status'] ?? '') === 'cancelled') {
|
||||
$lines[] = 'STATUS:CANCELLED';
|
||||
} else {
|
||||
$lines[] = 'STATUS:CONFIRMED';
|
||||
}
|
||||
|
||||
$lines[] = 'END:VEVENT';
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function buildDescription(array $event): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$description = trim((string)($event['description'] ?? ''));
|
||||
if ($description !== '') {
|
||||
$parts[] = $description;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
if (($event['relation_type'] ?? '') === 'owner') {
|
||||
$meta[] = 'Typ: Eigenes Event';
|
||||
} elseif (($event['relation_type'] ?? '') === 'participant') {
|
||||
$meta[] = 'Typ: Teilnahme an fremdem Event';
|
||||
}
|
||||
|
||||
if (!empty($event['host_name'])) {
|
||||
$meta[] = 'Veranstalter: ' . trim((string)$event['host_name']);
|
||||
}
|
||||
if (!empty($event['participation_status'])) {
|
||||
$meta[] = 'Teilnahmestatus: ' . trim((string)$event['participation_status']);
|
||||
}
|
||||
if (!empty($event['category_slug'])) {
|
||||
$meta[] = 'Kategorie: ' . trim((string)$event['category_slug']);
|
||||
}
|
||||
$meta[] = !empty($event['allow_kids']) ? 'Mit Kindern: Ja' : 'Mit Kindern: Nein';
|
||||
$meta[] = 'Sichtbarkeit: ' . (($event['visibility'] ?? 'public') === 'members' ? 'Nur Mitglieder' : 'Öffentlich');
|
||||
if (($event['status'] ?? '') === 'cancelled') {
|
||||
$meta[] = 'Status: Abgesagt';
|
||||
}
|
||||
|
||||
if ($meta !== []) {
|
||||
$parts[] = implode("\n", $meta);
|
||||
}
|
||||
|
||||
return trim(implode("\n\n", $parts));
|
||||
}
|
||||
|
||||
private function buildLocation(array $event): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
trim((string)($event['street'] ?? '')),
|
||||
trim((string)implode(' ', array_filter([
|
||||
(string)($event['zip'] ?? ''),
|
||||
(string)($event['city'] ?? ''),
|
||||
]))),
|
||||
trim((string)($event['region'] ?? '')),
|
||||
]);
|
||||
|
||||
return trim(implode(', ', $parts));
|
||||
}
|
||||
|
||||
private function buildUid(int $eventId, string $relationType): string
|
||||
{
|
||||
$host = preg_replace('/[^a-z0-9.-]+/i', '-', (string)($_SERVER['HTTP_HOST'] ?? 'papa-kind-treff.local')) ?: 'papa-kind-treff.local';
|
||||
|
||||
return sprintf('event-%d-%s@%s', $eventId, $relationType, $host);
|
||||
}
|
||||
|
||||
private function isAllDayEvent(string $startAt, string $endAt): bool
|
||||
{
|
||||
if ($startAt === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$startTime = substr($startAt, 11, 8);
|
||||
$endTime = $endAt !== '' ? substr($endAt, 11, 8) : '';
|
||||
|
||||
return $startTime === '' || $startTime === '00:00:00' || $startTime === '23:59:59'
|
||||
? ($endAt === '' || $endTime === '00:00:00' || $endTime === '23:59:59')
|
||||
: false;
|
||||
}
|
||||
|
||||
private function toDateString(string $value): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value))->format('Ymd');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function incrementDateString(string $dateString): string
|
||||
{
|
||||
$date = \DateTimeImmutable::createFromFormat('Ymd', $dateString);
|
||||
if (!$date instanceof \DateTimeImmutable) {
|
||||
return $dateString;
|
||||
}
|
||||
|
||||
return $date->modify('+1 day')->format('Ymd');
|
||||
}
|
||||
|
||||
private function toDateTimeString(string $value): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, new \DateTimeZone('Europe/Berlin')))->format('Ymd\THis');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function escapeText(string $value): string
|
||||
{
|
||||
$value = str_replace(["\r\n", "\r"], "\n", trim($value));
|
||||
$value = str_replace('\\', '\\\\', $value);
|
||||
$value = str_replace(';', '\;', $value);
|
||||
$value = str_replace(',', '\,', $value);
|
||||
|
||||
return str_replace("\n", '\n', $value);
|
||||
}
|
||||
|
||||
private function foldLines(array $lines): string
|
||||
{
|
||||
$output = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = (string)$line;
|
||||
while (strlen($line) > 75) {
|
||||
$output[] = substr($line, 0, 75);
|
||||
$line = ' ' . substr($line, 75);
|
||||
}
|
||||
$output[] = $line;
|
||||
}
|
||||
|
||||
return implode("\r\n", $output) . "\r\n";
|
||||
}
|
||||
|
||||
private function pdo(): \PDO
|
||||
{
|
||||
$pdo = $this->app->pdo();
|
||||
if (!$pdo instanceof \PDO) {
|
||||
throw new \RuntimeException('Datenbankverbindung nicht verfügbar.');
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
private function crypto(): Crypto
|
||||
{
|
||||
return new Crypto($this->app->config());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user