Feature: Walk-Einladungen und RSVP-System

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
This commit is contained in:
rene 2026-04-18 14:14:31 +02:00
parent e3230237a2
commit 066b722c5e
7 changed files with 489 additions and 18 deletions

View file

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