Files
server-26/drb-c2-core/app/routers/trips.py
T
Logan fb096d582d 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.
2026-06-20 23:25:08 -04:00

151 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}