feat: add /trip slash commands + add trips & itinerary system
New /trips router with full CRUD, attendee management, and nested events. Events validate date is within parent trip range and inherit trip location when not explicitly set. Leaving a trip cascades removal from all its events. New TripCommands cog with /trip create, list, view, delete, join, leave and /trip event add, remove, join, leave. Event autocomplete is scoped to the selected trip. Enforces must-be-on-trip rule for event joins with a clear error message.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trips():
|
||||
return await fstore.collection_list("trips")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_trip(body: TripCreate):
|
||||
if body.end_date < body.start_date:
|
||||
raise HTTPException(400, "end_date must be on or after start_date.")
|
||||
trip_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"trip_id": trip_id,
|
||||
"name": body.name,
|
||||
"location": body.location,
|
||||
"maps_link": body.maps_link,
|
||||
"start_date": body.start_date,
|
||||
"end_date": body.end_date,
|
||||
"attendees": {}, # {discord_user_id: discord_username}
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/{trip_id}")
|
||||
async def get_trip(trip_id: str):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
|
||||
return {**trip, "events": events}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}")
|
||||
async def delete_trip(trip_id: str):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
await fstore.doc_delete("trip_events", e["event_id"])
|
||||
await fstore.doc_delete("trips", trip_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/join")
|
||||
async def join_trip(trip_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/leave")
|
||||
async def leave_trip(trip_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
# cascade: remove from all events in this trip
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
event_attendees = e.get("attendees", {})
|
||||
if body.discord_user_id in event_attendees:
|
||||
event_attendees.pop(body.discord_user_id)
|
||||
await fstore.doc_update("trip_events", e["event_id"], {"attendees": event_attendees})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events")
|
||||
async def create_event(trip_id: str, body: TripEventCreate):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if not (trip["start_date"] <= body.date <= trip["end_date"]):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Event date {body.date} is outside the trip range "
|
||||
f"{trip['start_date']} – {trip['end_date']}.",
|
||||
)
|
||||
event_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"event_id": event_id,
|
||||
"trip_id": trip_id,
|
||||
"title": body.title,
|
||||
"date": body.date,
|
||||
"time": body.time,
|
||||
"location": body.location if body.location is not None else trip["location"],
|
||||
"location_inherited": body.location is None,
|
||||
"maps_link": body.maps_link,
|
||||
"notes": body.notes,
|
||||
"attendees": {},
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trip_events", event_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/events/{event_id}")
|
||||
async def delete_event(trip_id: str, event_id: str):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
await fstore.doc_delete("trip_events", event_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/join")
|
||||
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if body.discord_user_id not in trip.get("attendees", {}):
|
||||
raise HTTPException(403, "You must join the trip before joining an event.")
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/leave")
|
||||
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user