import uuid import json import httpx from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction from app.internal import firestore as fstore from app.config import settings from app.internal.logger import logger from app.internal.auth import ( require_service_or_firebase_token, require_service_key, require_service_key_or_admin, trip_chat_limiter, ) router = APIRouter(prefix="/trips", tags=["trips"]) # --------------------------------------------------------------------------- # Access control helpers # --------------------------------------------------------------------------- async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]: link = await fstore.doc_get("firebase_discord_links", firebase_uid) return (link or {}).get("discord_user_id") def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool: """Return True if the caller may read this trip.""" if is_service: return True # bot sees all; it filters client-side per-user if trip.get("visibility", "public") == "public": return True if not firebase_uid: return False # attendees keyed by discord_id — check linked discord_id if discord_id: if discord_id in trip.get("attendees", {}): return True if discord_id in trip.get("invited_discord_ids", []): return True return False # --------------------------------------------------------------------------- # AI assistant — tool definitions # --------------------------------------------------------------------------- _TOOLS = [ { "type": "function", "function": { "name": "search_places", "description": ( "Search Google Maps for places (restaurants, bars, attractions, hotels, venues). " "Use this whenever the user asks about specific places or you need to find options." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'", }, "near": { "type": "string", "description": "Location to search near, e.g. 'downtown Nashville, TN'", }, }, "required": ["query", "near"], }, }, }, { "type": "function", "function": { "name": "add_tag", "description": ( "Add a new tag to the trip's available tag list so it can be used on events. " "Use this when you want to apply a tag that doesn't exist yet." ), "parameters": { "type": "object", "properties": { "tag": { "type": "string", "description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'", }, }, "required": ["tag"], }, }, }, { "type": "function", "function": { "name": "propose_event", "description": ( "Propose a specific event to add to the itinerary. " "The user will see a card and can approve or dismiss it. " "Call this once per proposed event — do not bundle multiple events into one call." ), "parameters": { "type": "object", "properties": { "title": {"type": "string"}, "date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"}, "start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"}, "end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"}, "location": {"type": "string", "description": "Full address or place name"}, "maps_link": {"type": "string", "description": "Google Maps URL"}, "notes": {"type": "string", "description": "Brief tips or reasoning"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"}, }, "required": ["title"], }, }, }, ] _PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText" _PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri" async def _places_search(query: str, near: str) -> list[dict]: if not settings.google_maps_api_key: return [] full_query = f"{query} {near}".strip() try: async with httpx.AsyncClient(timeout=8) as client: r = await client.post( _PLACES_SEARCH_URL, json={"textQuery": full_query}, headers={ "X-Goog-Api-Key": settings.google_maps_api_key, "X-Goog-FieldMask": _PLACES_FIELDS, }, ) data = r.json() places = data.get("places", []) logger.info(f"Places search '{full_query}': count={len(places)}") if not places and "error" in data: logger.warning(f"Places API error: {data['error'].get('message', '')}") return [ { "name": p.get("displayName", {}).get("text"), "address": p.get("formattedAddress"), "place_id": p.get("id"), "maps_link": p.get("googleMapsUri"), "rating": p.get("rating"), } for p in places[:5] ] except Exception as e: logger.error(f"Places search in assistant failed: {e}") return [] def _build_system_prompt(trip: dict, events: list[dict]) -> str: by_date: dict[str, list] = {} for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")): by_date.setdefault(e["date"], []).append(e) lines = [] for date, day_events in sorted(by_date.items()): lines.append(f"\n {date}:") for e in day_events: t = "" if e.get("start_time"): t = f" {e['start_time']}" if e.get("end_time"): t += f"–{e['end_time']}" loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else "" lines.append(f" • {e['title']}{t}{loc}") if e.get("notes"): lines.append(f" Notes: {e['notes']}") itinerary = "".join(lines) if lines else "\n (no events yet)" attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified" available_tags = trip.get("available_tags") or [] tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else "" return f"""You are a trip planning assistant for the following trip. Trip: {trip["name"]} Destination: {trip["location"]} Dates: {trip["start_date"]} to {trip["end_date"]} Attendees: {attendees}{tags_section} Current itinerary:{itinerary} Guidelines: - Be conversational and concise — don't over-explain. - Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links. - When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing. - Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one. - When proposing events, apply relevant tags. Before using a tag, check if it exists in the available tags list. If it doesn't, call `add_tag` first to create it, then use it in `propose_event`. - Be mindful of the existing schedule when assigning times. Avoid obvious conflicts. - All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}. - If the user says something like "everyone should be there by 6", factor that into your time proposals. - If you don't know a specific address, search for the place first.""" class ChatMsg(BaseModel): role: str content: str class ChatRequest(BaseModel): message: str history: list[ChatMsg] = [] @router.get("") async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)): trips = await fstore.collection_list("trips") is_service = bool(decoded.get("service")) firebase_uid = decoded.get("uid") discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)] @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} "available_tags": body.available_tags, "overlap_tags": body.overlap_tags, "visibility": body.visibility if body.visibility in ("public", "private") else "public", "invited_discord_ids": body.invited_discord_ids, "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, decoded: dict = Depends(require_service_or_firebase_token)): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") is_service = bool(decoded.get("service")) firebase_uid = decoded.get("uid") discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id): raise HTTPException(403, "This trip is private.") events = await fstore.collection_list("trip_events", trip_id=trip_id) events.sort(key=lambda e: (e["date"], e.get("start_time") or "")) return {**trip, "events": events} @router.put("/{trip_id}/tags") async def update_trip_tags(trip_id: str, body: dict): """Replace the trip's available tag list and overlap-allowed tag list.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") tags = [str(t) for t in body.get("available_tags", []) if t] overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags] await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap}) return {"available_tags": tags, "overlap_tags": overlap} @router.delete("/{trip_id}") async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)): 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, _: dict = Depends(require_service_key), ): """Join a trip as an attendee. Only the Discord bot (service key) may call this.""" trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") if trip.get("visibility", "public") == "private": invited = trip.get("invited_discord_ids", []) attendees_existing = trip.get("attendees", {}) if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing: raise HTTPException(403, "This trip is private. You need an invite to join.") 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.put("/{trip_id}/visibility") async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") visibility = body.get("visibility", "public") if visibility not in ("public", "private"): raise HTTPException(400, "visibility must be 'public' or 'private'.") await fstore.doc_update("trips", trip_id, {"visibility": visibility}) return {"visibility": visibility} @router.post("/{trip_id}/invite/{discord_user_id}") async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id])) await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited}) return {"ok": True, "invited_discord_ids": invited} @router.delete("/{trip_id}/invite/{discord_user_id}") async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id] await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited}) return {"ok": True, "invited_discord_ids": invited} @router.post("/{trip_id}/leave") async def leave_trip( trip_id: str, body: AttendeeAction, _: dict = Depends(require_service_key), ): """Leave a trip. Only the Discord bot (service key) may call this.""" 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, "start_time": body.start_time, "end_time": body.end_time, "location": body.location if body.location is not None else trip["location"], "location_inherited": body.location is None, "maps_link": body.maps_link, "place_id": body.place_id, "notes": body.notes, "tags": body.tags, "attendees": {}, "created_at": now, } await fstore.doc_set("trip_events", event_id, doc, merge=False) return doc @router.patch("/{trip_id}/events/{event_id}") async def update_event(trip_id: str, event_id: str, body: TripEventUpdate): 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}'.") trip = await fstore.doc_get("trips", trip_id) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") updates: dict = {} if body.title is not None: updates["title"] = body.title if body.date is not None: if not (trip["start_date"] <= body.date <= trip["end_date"]): raise HTTPException(400, f"Event date {body.date} is outside the trip range.") updates["date"] = body.date if body.start_time is not None: updates["start_time"] = body.start_time or None if body.end_time is not None: updates["end_time"] = body.end_time or None if body.location is not None: updates["location"] = body.location updates["location_inherited"] = False if body.maps_link is not None: updates["maps_link"] = body.maps_link or None if body.place_id is not None: updates["place_id"] = body.place_id or None if body.notes is not None: updates["notes"] = body.notes or None if body.tags is not None: updates["tags"] = body.tags if updates: await fstore.doc_update("trip_events", event_id, updates) return {**event, **updates} @router.delete("/{trip_id}/events/{event_id}") async def delete_event( trip_id: str, event_id: str, _: dict = Depends(require_service_key_or_admin), ): 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, _: dict = Depends(require_service_key), ): """Join an event. Only the Discord bot (service key) may call this.""" 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, _: dict = Depends(require_service_key), ): """Leave an event. Only the Discord bot (service key) may call this.""" 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} # --------------------------------------------------------------------------- # AI trip planning assistant # --------------------------------------------------------------------------- @router.post("/{trip_id}/chat") async def trip_chat( trip_id: str, body: ChatRequest, decoded: dict = Depends(require_service_or_firebase_token), ): if not settings.openai_api_key: raise HTTPException(503, "OpenAI not configured.") # Rate limit by caller identity caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown") trip_chat_limiter.check(f"{caller_key}:{trip_id}") 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) from openai import AsyncOpenAI oai = AsyncOpenAI(api_key=settings.openai_api_key) # Strip history to only user/assistant roles to prevent prompt injection safe_history = [ {"role": m.role, "content": m.content} for m in body.history[-20:] if m.role in ("user", "assistant") ] # Truncate message to prevent oversized single requests user_message = body.message[:2000] messages: list[dict] = [ {"role": "system", "content": _build_system_prompt(trip, events)}, *safe_history, {"role": "user", "content": user_message}, ] suggestions: list[dict] = [] reply = "" for _ in range(6): # max tool-call iterations response = await oai.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=_TOOLS, tool_choice="auto", max_tokens=1000, ) msg = response.choices[0].message if not msg.tool_calls: reply = msg.content or "" break # Append assistant message with tool calls messages.append({ "role": "assistant", "content": msg.content, "tool_calls": [tc.model_dump() for tc in msg.tool_calls], }) for tc in msg.tool_calls: args = json.loads(tc.function.arguments) if tc.function.name == "add_tag": new_tag = str(args.get("tag", "")).strip()[:50] if new_tag and new_tag not in trip.get("available_tags", []): updated_tags = list(trip.get("available_tags") or []) + [new_tag] trip["available_tags"] = updated_tags await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags}) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps({"available_tags": trip.get("available_tags", [])}), }) elif tc.function.name == "search_places": # Limit query string lengths before hitting the Maps API query = str(args.get("query", ""))[:200] near = str(args.get("near", ""))[:200] results = await _places_search(query, near) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(results), }) elif tc.function.name == "propose_event": suggestion = {k: args.get(k) for k in ( "title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags" )} if not isinstance(suggestion.get("tags"), list): suggestion["tags"] = [] suggestions.append(suggestion) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps({"proposed": True, "title": args.get("title")}), }) if not reply: reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip." return {"reply": reply, "suggestions": suggestions}