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:
Logan
2026-06-20 23:25:08 -04:00
parent a4962d7b0e
commit fb096d582d
6 changed files with 730 additions and 1 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips
from app.internal import firestore as fstore
@@ -68,6 +68,7 @@ app.include_router(calls.router, dependencies=[Depends(require_service_or_fi
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
+26
View File
@@ -134,3 +134,29 @@ class AlertEvent(BaseModel):
transcript_snippet: Optional[str] = None
triggered_at: Optional[datetime] = None
acknowledged: bool = False
# ---------------------------------------------------------------------------
# Trips
# ---------------------------------------------------------------------------
class TripCreate(BaseModel):
name: str
location: str
maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
class TripEventCreate(BaseModel):
title: str
date: str # YYYY-MM-DD, must fall within parent trip range
time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None
notes: Optional[str] = None
class AttendeeAction(BaseModel):
discord_user_id: str
discord_username: Optional[str] = None
+150
View File
@@ -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}