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:
parent
e3230237a2
commit
066b722c5e
7 changed files with 489 additions and 18 deletions
|
|
@ -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}
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue