From 066b722c5e1e7b048762b15608ffd2241ea5cdd2 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 18 Apr 2026 14:14:31 +0200 Subject: [PATCH] Feature: Walk-Einladungen und RSVP-System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gassi-Treffen bekommen ein vollständiges Einladungs- und RSVP-System: - Neue Tabelle walk_invitations (walk_id, user_id, status, Zeitstempel) - Backend: /invite-candidates, /invite, /rsvp, /participants Endpoints - Push-Notification beim Einladen - Frontend: RSVP-Buttons (Zusagen/Vielleicht/Absagen), Teilnehmerliste mit Avatar-Initialen und farbkodierten RSVP-Badges, Einladen-Modal - SW by-v205, APP_VER 173 --- backend/database.py | 18 +++ backend/routes/walks.py | 176 +++++++++++++++++++++++++++ backend/static/css/components.css | 106 ++++++++++++++++ backend/static/js/api.js | 10 +- backend/static/js/app.js | 2 +- backend/static/js/pages/walks.js | 193 ++++++++++++++++++++++++++++-- backend/static/sw.js | 2 +- 7 files changed, 489 insertions(+), 18 deletions(-) diff --git a/backend/database.py b/backend/database.py index ed3ef16..6136174 100644 --- a/backend/database.py +++ b/backend/database.py @@ -464,6 +464,8 @@ def _migrate(conn_factory): ("dogs", "foto_zoom", "REAL NOT NULL DEFAULT 1.0"), ("dogs", "foto_offset_x", "REAL NOT NULL DEFAULT 0.0"), ("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"), + # Tagebuch: Ortsname (POI/Adresse) + ("diary", "location_name", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -736,3 +738,19 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv); CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type); """) + + # Walk-Einladungen (RSVP) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS walk_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'invited', + invited_at TEXT NOT NULL DEFAULT (datetime('now')), + responded_at TEXT, + UNIQUE(walk_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_walk_inv_walk ON walk_invitations(walk_id); + CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status); + """) + logger.info("Migration: walk_invitations Tabelle bereit.") diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 5210b0d..0c19987 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user +from routes.push import send_push_to_user router = APIRouter() @@ -51,6 +52,12 @@ class WalkUpdate(BaseModel): class JoinRequest(BaseModel): dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) +class InviteRequest(BaseModel): + friend_id: int + +class RsvpRequest(BaseModel): + status: str # 'yes' | 'maybe' | 'no' + # ------------------------------------------------------------------ # GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis) @@ -181,6 +188,161 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): return unique[:20] +# ------------------------------------------------------------------ +# GET /api/walks/{id}/invite-candidates — einladbare Freunde +# WICHTIG: Muss VOR /{walk_id} stehen +# ------------------------------------------------------------------ +@router.get("/{walk_id}/invite-candidates") +async def invite_candidates(walk_id: int, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann diese Liste abrufen.") + + # Freunde die noch nicht eingeladen wurden + already = {r[0] for r in conn.execute( + "SELECT user_id FROM walk_invitations WHERE walk_id=?", (walk_id,) + ).fetchall()} + + friends = _get_accepted_friends(user['id']) + return [f for f in friends if f['friend_id'] not in already] + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/invite — Freund einladen (nur Veranstalter) +# WICHTIG: Muss VOR /{walk_id} stehen +# ------------------------------------------------------------------ +@router.post("/{walk_id}/invite", status_code=201) +async def invite_friend(walk_id: int, data: InviteRequest, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + if walk['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann einladen.") + + # Freundschaft prüfen + friendship = conn.execute(""" + SELECT 1 FROM friendships + WHERE status='accepted' + AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)) + """, (user['id'], data.friend_id, data.friend_id, user['id'])).fetchone() + if not friendship: + raise HTTPException(400, "Dieser Nutzer ist nicht in deiner Freundesliste.") + + # Bereits eingeladen? + existing = conn.execute( + "SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?", + (walk_id, data.friend_id) + ).fetchone() + if existing: + raise HTTPException(409, f"Nutzer wurde bereits eingeladen (Status: {existing['status']}).") + + conn.execute( + "INSERT INTO walk_invitations (walk_id, user_id, status) VALUES (?, ?, 'invited')", + (walk_id, data.friend_id) + ) + + friend = conn.execute("SELECT name FROM users WHERE id=?", (data.friend_id,)).fetchone() + + # Push-Notification + send_push_to_user(data.friend_id, { + "type": "walk_invite", + "title": f"Einladung: {walk['titel']}", + "body": f"{user['name']} lädt dich zu einem Gassi-Treffen ein ({walk['datum']} {walk['uhrzeit']})", + "walk_id": walk_id, + }) + + return {"status": "invited", "friend_name": friend['name'] if friend else ""} + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/rsvp — RSVP setzen (yes / maybe / no) +# WICHTIG: Muss VOR /{walk_id} stehen +# ------------------------------------------------------------------ +@router.post("/{walk_id}/rsvp") +async def rsvp_walk(walk_id: int, data: RsvpRequest, user=Depends(get_current_user)): + if data.status not in ('yes', 'maybe', 'no'): + raise HTTPException(400, "Ungültiger RSVP-Status. Erlaubt: yes, maybe, no") + + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + + inv = conn.execute( + "SELECT * FROM walk_invitations WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone() + if not inv: + raise HTTPException(403, "Du wurdest nicht zu diesem Treffen eingeladen.") + + conn.execute( + """UPDATE walk_invitations + SET status=?, responded_at=datetime('now') + WHERE walk_id=? AND user_id=?""", + (data.status, walk_id, user['id']) + ) + + return {"status": data.status} + + +# ------------------------------------------------------------------ +# GET /api/walks/{id}/participants — Teilnehmerliste mit RSVP +# WICHTIG: Muss VOR /{walk_id} stehen +# ------------------------------------------------------------------ +@router.get("/{walk_id}/participants") +async def get_participants(walk_id: int, user=Depends(get_current_user)): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + + is_organizer = walk['user_id'] == user['id'] + + if is_organizer: + # Veranstalter sieht alle Einladungen + invitations = conn.execute(""" + SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at, + u.name AS user_name, + GROUP_CONCAT(d.name, ', ') AS hunde + FROM walk_invitations wi + JOIN users u ON u.id = wi.user_id + LEFT JOIN walk_participant_dogs wpd + ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id + LEFT JOIN dogs d ON d.id = wpd.dog_id + WHERE wi.walk_id = ? + GROUP BY wi.user_id + """, (walk_id,)).fetchall() + else: + # Eingeladene sehen nur Zugesagte + sich selbst + invitations = conn.execute(""" + SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at, + u.name AS user_name, + GROUP_CONCAT(d.name, ', ') AS hunde + FROM walk_invitations wi + JOIN users u ON u.id = wi.user_id + LEFT JOIN walk_participant_dogs wpd + ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id + LEFT JOIN dogs d ON d.id = wpd.dog_id + WHERE wi.walk_id = ? AND (wi.status = 'yes' OR wi.user_id = ?) + GROUP BY wi.user_id + """, (walk_id, user['id'])).fetchall() + + my_invitation = conn.execute( + "SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone() + + return { + "invitations": [dict(r) for r in invitations], + "my_rsvp": my_invitation['status'] if my_invitation else None, + "is_organizer": is_organizer, + } + + # ------------------------------------------------------------------ # GET /api/walks/{id} — Detail mit Teilnehmerliste # ------------------------------------------------------------------ @@ -214,6 +376,20 @@ async def get_walk(walk_id: int): return result +# Helper: accepted friends list for invite-modal (reused from friends route) +def _get_accepted_friends(user_id: int): + with db() as conn: + rows = conn.execute(""" + SELECT CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id, + u.name AS friend_name + FROM friendships f + JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END + WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted' + ORDER BY u.name + """, (user_id, user_id, user_id, user_id)).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ # PATCH /api/walks/{id} # ------------------------------------------------------------------ diff --git a/backend/static/css/components.css b/backend/static/css/components.css index e0e00f6..648b1b6 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -2602,6 +2602,112 @@ html.modal-open { .walks-participant-name { font-weight: var(--weight-semibold); } .walks-participant-dogs { color: var(--c-text-secondary); } +/* Walk-Einladungen + RSVP */ +.walks-invitation-row, +.walks-invite-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--c-border); + font-size: var(--text-sm); +} +.walks-invitation-row:last-child, +.walks-invite-row:last-child { border-bottom: none; } + +.walks-inv-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + flex-shrink: 0; +} +.walks-inv-avatar--sm { + width: 28px; + height: 28px; +} +.walks-inv-info { flex: 1; min-width: 0; } +.walks-inv-name { + font-weight: var(--weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.walks-inv-hunde { + color: var(--c-text-secondary); + font-size: var(--text-xs); +} +.walks-inv-badge { flex-shrink: 0; } + +/* RSVP-Badges */ +.walks-rsvp-badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} +.walks-rsvp--yes { background: #d1fae5; color: #065f46; } +.walks-rsvp--maybe { background: #fef3c7; color: #92400e; } +.walks-rsvp--no { background: #fee2e2; color: #991b1b; } +.walks-rsvp--invited { background: var(--c-surface-2); color: var(--c-text-secondary); } + +/* RSVP-Section im Detail-Modal */ +.walks-rsvp-section { + margin: var(--space-3) 0; + padding: var(--space-3); + background: var(--c-surface-2); + border-radius: var(--radius-md); +} +.walks-rsvp-buttons { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + margin-top: var(--space-2); +} +.walks-rsvp-btn { + flex: 1; + min-width: 80px; + padding: var(--space-2) var(--space-2); + border: 2px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + color: var(--c-text); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-1); +} +.walks-rsvp-btn:hover { + border-color: var(--c-primary); + background: var(--c-primary-bg, rgba(196,132,58,0.08)); +} +.walks-rsvp-btn.active[data-rsvp="yes"] { + border-color: #10b981; + background: #d1fae5; + color: #065f46; +} +.walks-rsvp-btn.active[data-rsvp="maybe"] { + border-color: #f59e0b; + background: #fef3c7; + color: #92400e; +} +.walks-rsvp-btn.active[data-rsvp="no"] { + border-color: #ef4444; + background: #fee2e2; + color: #991b1b; +} + /* ------------------------------------------------------------ EVENTS (events.js) ------------------------------------------------------------ */ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 6e568a5..a43b408 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -227,9 +227,13 @@ const API = (() => { create(data) { return post('/walks', data); }, update(id, data) { return patch(`/walks/${id}`, data); }, cancel(id) { return del(`/walks/${id}`); }, - join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, - leave(id) { return del(`/walks/${id}/join`); }, - nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); }, + join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, + leave(id) { return del(`/walks/${id}/join`); }, + nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); }, + inviteCandidates(id) { return get(`/walks/${id}/invite-candidates`); }, + invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, + rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, + participants(id) { return get(`/walks/${id}/participants`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 19da325..48c3e58 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '169'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '173'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 172513e..c041668 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -269,13 +269,46 @@ window.Page_walks = (() => { }); } + // ---------------------------------------------------------- + // RSVP-Status → Label + Farbe + // ---------------------------------------------------------- + function _rsvpBadge(status) { + if (status === 'yes') return `Zusage`; + if (status === 'maybe') return `Vielleicht`; + if (status === 'no') return `Absage`; + return `Eingeladen`; + } + + function _avatarInitials(name) { + const parts = (name || '?').trim().split(/\s+/); + const initials = parts.length >= 2 + ? parts[0][0] + parts[parts.length - 1][0] + : parts[0].slice(0, 2); + return initials.toUpperCase(); + } + + function _invitationRowHTML(inv) { + return ` +
+
${_avatarInitials(inv.user_name)}
+
+
${_esc(inv.user_name)}
+ ${inv.hunde ? `
${UI.icon('dog')} ${_esc(inv.hunde)}
` : ''} +
+
${_rsvpBadge(inv.status)}
+
`; + } + // ---------------------------------------------------------- // Detail-Modal // ---------------------------------------------------------- async function _openDetail(walkId) { - let walk; + let walk, participantData; try { walk = await API.walks.get(walkId); + if (_appState.user) { + try { participantData = await API.walks.participants(walkId); } catch {} + } } catch (err) { UI.toast.error(err.message); return; @@ -286,15 +319,42 @@ window.Page_walks = (() => { const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer; const isPast = _isPast(walk.datum); const spots = walk.max_teilnehmer - walk.teilnehmer_count; + const myRsvp = participantData?.my_rsvp ?? null; + const isInvited = !!myRsvp; + const invitations = participantData?.invitations ?? []; - // Teilnehmerliste + // Teilnehmerliste (join-Teilnehmer, klassisch) const teilnehmerHTML = walk.teilnehmer?.length ? walk.teilnehmer.map(t => `
- ${UI.icon('user')} ${_esc(t.user_name)} +
${_avatarInitials(t.user_name)}
+ ${_esc(t.user_name)} ${t.hunde ? `${UI.icon('dog')} ${_esc(t.hunde)}` : ''}
`).join('') - : `

Noch keine Teilnehmer.

`; + : ''; + + // Einladungsliste + const invListHTML = invitations.length + ? invitations.map(inv => _invitationRowHTML(inv)).join('') + : `

Noch keine Einladungen.

`; + + // RSVP-Section für eingeladene Nutzer + const rsvpSectionHTML = (isInvited && !isOwn) ? ` +
+ +
+ + + +
+
+ ` : ''; const body = `
@@ -316,11 +376,23 @@ window.Page_walks = (() => {

${_esc(walk.beschreibung)}

` : ''} + ${rsvpSectionHTML} +
- - ${teilnehmerHTML} +
+ + ${isOwn && !isPast ? `` : ''} +
+
${invListHTML}
+ ${walk.teilnehmer?.length ? ` +
+ + ${teilnehmerHTML} +
+ ` : ''} +

Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}

@@ -380,6 +452,31 @@ window.Page_walks = (() => { _showEditForm(walk); }); + // Einladen-Button + document.getElementById('wd-invite-btn')?.addEventListener('click', () => { + UI.modal.close(); + _showInviteModal(walk); + }); + + // RSVP-Buttons + document.querySelectorAll('.walks-rsvp-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const status = btn.dataset.rsvp; + try { + await API.walks.rsvp(walk.id, status); + // Buttons aktualisieren + document.querySelectorAll('.walks-rsvp-btn').forEach(b => { + b.classList.toggle('active', b.dataset.rsvp === status); + }); + UI.toast.success( + status === 'yes' ? 'Zugesagt!' : + status === 'maybe' ? 'Antwort: Vielleicht.' : + 'Abgesagt.' + ); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal) let _cancelPending = false; document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => { @@ -442,6 +539,66 @@ window.Page_walks = (() => { }); } + // ---------------------------------------------------------- + // Freunde einladen + // ---------------------------------------------------------- + async function _showInviteModal(walk) { + let candidates; + try { + candidates = await API.walks.inviteCandidates(walk.id); + } catch (err) { + UI.toast.error(err.message); + return; + } + + const listHTML = candidates.length + ? candidates.map(f => ` +
+
${_avatarInitials(f.friend_name)}
+
${_esc(f.friend_name)}
+ +
`).join('') + : `

Alle Freunde wurden bereits eingeladen.

`; + + const body = ` +

+ ${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr + ${walk.ort_name ? `· ${_esc(walk.ort_name)}` : ''} +

+
${listHTML}
+ `; + + const footer = ` + + `; + + UI.modal.open({ title: `${UI.icon('user-plus')} Freunde einladen`, body, footer }); + + document.getElementById('invite-back')?.addEventListener('click', () => { + UI.modal.close(); + _openDetail(walk.id); + }); + + document.querySelectorAll('.walks-invite-send').forEach(btn => { + btn.addEventListener('click', async () => { + const row = btn.closest('.walks-invite-row'); + const friendId = parseInt(row.dataset.friendId); + const name = row.dataset.friendName; + await UI.asyncButton(btn, async () => { + await API.walks.invite(walk.id, friendId); + row.innerHTML = ` +
${_avatarInitials(name)}
+
${_esc(name)}
+ Eingeladen + `; + UI.toast.success(`${name} eingeladen.`); + }); + }); + }); + } + // ---------------------------------------------------------- // Beitreten-Formular (Hunde wählen) // ---------------------------------------------------------- @@ -547,7 +704,15 @@ window.Page_walks = (() => {
-
+
+
@@ -615,7 +780,7 @@ window.Page_walks = (() => { function _placeMarker(lat, lon) { if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } - _miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap); + _miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap); _miniMarker.on('dragend', () => { const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng; @@ -649,16 +814,18 @@ window.Page_walks = (() => { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) .addTo(_miniMap); _miniMap.invalidateSize(); - if (_locLat) { - _placeMarker(lat, lon); - _miniMarker.dragging.disable(); - } + if (_locLat) _placeMarker(lat, lon); _miniMap.on('click', e => { - if (!_mapEditing) return; _setCoords(e.latlng.lat, e.latlng.lng); _placeMarker(_locLat, _locLon); document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; }); + document.getElementById('wf-map-pin-here')?.addEventListener('click', () => { + const c = _miniMap.getCenter(); + _setCoords(c.lat, c.lng); + _placeMarker(c.lat, c.lng); + document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; + }); }, 150); }); diff --git a/backend/static/sw.js b/backend/static/sw.js index 64d26b1..43b128a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v203'; +const CACHE_VERSION = 'by-v205'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten