add trips permissions
This commit is contained in:
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
|||||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
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, trips, places
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi
|
|||||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ class TripCreate(BaseModel):
|
|||||||
end_date: str # YYYY-MM-DD
|
end_date: str # YYYY-MM-DD
|
||||||
available_tags: List[str] = [] # tag labels configured for this trip
|
available_tags: List[str] = [] # tag labels configured for this trip
|
||||||
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
||||||
|
visibility: str = "public" # "public" | "private"
|
||||||
|
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
||||||
|
|
||||||
|
|
||||||
class TripEventCreate(BaseModel):
|
class TripEventCreate(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_firebase_token, require_service_key
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_CODE_TTL_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_code() -> str:
|
||||||
|
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web: generate a short-lived linking code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/link/generate")
|
||||||
|
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
|
||||||
|
"""Authenticated Firebase user generates a code to paste into Discord /link."""
|
||||||
|
firebase_uid = decoded["uid"]
|
||||||
|
|
||||||
|
# Check if already linked
|
||||||
|
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
|
if existing and existing.get("discord_user_id"):
|
||||||
|
return {
|
||||||
|
"already_linked": True,
|
||||||
|
"discord_user_id": existing["discord_user_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
code = _gen_code()
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
|
||||||
|
await fstore.doc_set("link_codes", code, {
|
||||||
|
"firebase_uid": firebase_uid,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discord bot: resolve a code and store the link
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LinkResolveBody(BaseModel):
|
||||||
|
code: str
|
||||||
|
discord_user_id: str
|
||||||
|
discord_username: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/link")
|
||||||
|
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
|
||||||
|
"""Discord bot resolves a linking code and permanently links the accounts."""
|
||||||
|
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, "Invalid or expired code.")
|
||||||
|
|
||||||
|
expires_at = datetime.fromisoformat(doc["expires_at"])
|
||||||
|
if datetime.now(timezone.utc) > expires_at:
|
||||||
|
await fstore.doc_delete("link_codes", body.code)
|
||||||
|
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
|
||||||
|
|
||||||
|
firebase_uid = doc["firebase_uid"]
|
||||||
|
|
||||||
|
# Check if this Discord account is already linked to a different Firebase UID
|
||||||
|
existing = await fstore.doc_get("discord_links", body.discord_user_id)
|
||||||
|
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
|
||||||
|
raise HTTPException(409, "This Discord account is already linked to a different account.")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Store both directions
|
||||||
|
await fstore.doc_set("discord_links", body.discord_user_id, {
|
||||||
|
"firebase_uid": firebase_uid,
|
||||||
|
"discord_username": body.discord_username,
|
||||||
|
"linked_at": now,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
await fstore.doc_set("firebase_discord_links", firebase_uid, {
|
||||||
|
"discord_user_id": body.discord_user_id,
|
||||||
|
"discord_username": body.discord_username,
|
||||||
|
"linked_at": now,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
# Clean up the code
|
||||||
|
await fstore.doc_delete("link_codes", body.code)
|
||||||
|
|
||||||
|
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
|
||||||
|
return {"ok": True, "firebase_uid": firebase_uid}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web: check current link status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/link/status")
|
||||||
|
async def link_status(decoded: dict = Depends(require_firebase_token)):
|
||||||
|
firebase_uid = decoded["uid"]
|
||||||
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
|
if link and link.get("discord_user_id"):
|
||||||
|
return {
|
||||||
|
"linked": True,
|
||||||
|
"discord_user_id": link["discord_user_id"],
|
||||||
|
"discord_username": link.get("discord_username", ""),
|
||||||
|
"linked_at": link.get("linked_at"),
|
||||||
|
}
|
||||||
|
return {"linked": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web: unlink
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.delete("/link")
|
||||||
|
async def unlink(decoded: dict = Depends(require_firebase_token)):
|
||||||
|
firebase_uid = decoded["uid"]
|
||||||
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
|
if not link or not link.get("discord_user_id"):
|
||||||
|
raise HTTPException(404, "No linked Discord account.")
|
||||||
|
discord_user_id = link["discord_user_id"]
|
||||||
|
await fstore.doc_delete("discord_links", discord_user_id)
|
||||||
|
await fstore.doc_delete("firebase_discord_links", firebase_uid)
|
||||||
|
return {"ok": True}
|
||||||
@@ -18,6 +18,32 @@ from app.internal.auth import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
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
|
# AI assistant — tool definitions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_trips():
|
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
return await fstore.collection_list("trips")
|
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("")
|
@router.post("")
|
||||||
@@ -209,6 +239,8 @@ async def create_trip(body: TripCreate):
|
|||||||
"attendees": {}, # {discord_user_id: discord_username}
|
"attendees": {}, # {discord_user_id: discord_username}
|
||||||
"available_tags": body.available_tags,
|
"available_tags": body.available_tags,
|
||||||
"overlap_tags": body.overlap_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,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||||
@@ -216,12 +248,17 @@ async def create_trip(body: TripCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{trip_id}")
|
@router.get("/{trip_id}")
|
||||||
async def get_trip(trip_id: str):
|
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
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 = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||||
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
|
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
|
||||||
return {**trip, "events": events}
|
return {**trip, "events": events}
|
||||||
|
|
||||||
|
|
||||||
@@ -259,12 +296,49 @@ async def join_trip(
|
|||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
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 = trip.get("attendees", {})
|
||||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||||
return {"ok": True, "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")
|
@router.post("/{trip_id}/leave")
|
||||||
async def leave_trip(
|
async def leave_trip(
|
||||||
trip_id: str,
|
trip_id: str,
|
||||||
|
|||||||
@@ -908,6 +908,7 @@ export default function TripDetailPage() {
|
|||||||
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
||||||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [inviteInput, setInviteInput] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1024,6 +1025,28 @@ export default function TripDetailPage() {
|
|||||||
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
|
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleVisibility() {
|
||||||
|
if (!trip) return;
|
||||||
|
const next = trip.visibility === "private" ? "public" : "private";
|
||||||
|
await c2api.setTripVisibility(trip.trip_id, next);
|
||||||
|
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
const discordId = inviteInput.trim();
|
||||||
|
if (!trip || !discordId) return;
|
||||||
|
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
|
||||||
|
await c2api.inviteToTrip(trip.trip_id, discordId);
|
||||||
|
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
|
||||||
|
setInviteInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeInvite(discordId: string) {
|
||||||
|
if (!trip) return;
|
||||||
|
await c2api.revokeInvite(trip.trip_id, discordId);
|
||||||
|
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleOverlap(tag: string) {
|
async function handleToggleOverlap(tag: string) {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
const current = trip.overlap_tags ?? [];
|
const current = trip.overlap_tags ?? [];
|
||||||
@@ -1074,7 +1097,13 @@ export default function TripDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* Visibility badge */}
|
||||||
|
{trip.visibility === "private" && (
|
||||||
|
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
|
||||||
|
🔒 private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1083,6 +1112,12 @@ export default function TripDetailPage() {
|
|||||||
>
|
>
|
||||||
+ Add Event
|
+ Add Event
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleVisibility}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
{trip.visibility === "private" ? "Make public" : "Make private"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteTrip}
|
onClick={handleDeleteTrip}
|
||||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||||
@@ -1131,6 +1166,39 @@ export default function TripDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invite management — admin only, only when private */}
|
||||||
|
{isAdmin && trip.visibility === "private" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(trip.invited_discord_ids ?? []).length === 0 && (
|
||||||
|
<span className="text-xs text-gray-600">No invites yet</span>
|
||||||
|
)}
|
||||||
|
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
||||||
|
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
|
||||||
|
<span className="font-mono">{discordId}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevokeInvite(discordId)}
|
||||||
|
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={inviteInput}
|
||||||
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
||||||
|
placeholder="Discord user ID…"
|
||||||
|
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
|
||||||
|
/>
|
||||||
|
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
{/* Two-column layout */}
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ export const c2api = {
|
|||||||
request(`/trips/${id}`, { method: "DELETE" }),
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
||||||
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
||||||
|
setTripVisibility: (id: string, visibility: "public" | "private") =>
|
||||||
|
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
|
||||||
|
inviteToTrip: (id: string, discord_user_id: string) =>
|
||||||
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
|
||||||
|
revokeInvite: (id: string, discord_user_id: string) =>
|
||||||
|
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
|
||||||
|
generateLinkCode: () =>
|
||||||
|
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
|
||||||
|
getLinkStatus: () =>
|
||||||
|
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
|
||||||
|
unlinkDiscord: () =>
|
||||||
|
request("/auth/link", { method: "DELETE" }),
|
||||||
createTripEvent: (tripId: string, body: object) =>
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export interface TripRecord {
|
|||||||
attendees: Record<string, string>;
|
attendees: Record<string, string>;
|
||||||
available_tags: string[];
|
available_tags: string[];
|
||||||
overlap_tags: string[];
|
overlap_tags: string[];
|
||||||
|
visibility: "public" | "private";
|
||||||
|
invited_discord_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
events?: TripEvent[];
|
events?: TripEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str):
|
|||||||
# Cog
|
# Cog
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
|
||||||
|
if trip.get("visibility", "public") == "public":
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("attendees", {}):
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("invited_discord_ids", []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TripCommands(commands.Cog):
|
class TripCommands(commands.Cog):
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
@@ -79,10 +89,11 @@ class TripCommands(commands.Cog):
|
|||||||
self, interaction: discord.Interaction, current: str
|
self, interaction: discord.Interaction, current: str
|
||||||
) -> list[app_commands.Choice[str]]:
|
) -> list[app_commands.Choice[str]]:
|
||||||
trips = await c2.get_trips()
|
trips = await c2.get_trips()
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
return [
|
return [
|
||||||
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
||||||
for t in trips
|
for t in trips
|
||||||
if current.lower() in t["name"].lower()
|
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
|
||||||
][:25]
|
][:25]
|
||||||
|
|
||||||
async def event_autocomplete(
|
async def event_autocomplete(
|
||||||
@@ -172,6 +183,9 @@ class TripCommands(commands.Cog):
|
|||||||
today = date.today().strftime("%Y-%m-%d")
|
today = date.today().strftime("%Y-%m-%d")
|
||||||
trips.sort(key=lambda t: t.get("start_date", ""))
|
trips.sort(key=lambda t: t.get("start_date", ""))
|
||||||
|
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
|
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
|
||||||
|
|
||||||
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
||||||
for t in trips[:10]:
|
for t in trips[:10]:
|
||||||
upcoming = t.get("start_date", "") >= today
|
upcoming = t.get("start_date", "") >= today
|
||||||
@@ -204,6 +218,9 @@ class TripCommands(commands.Cog):
|
|||||||
if not data:
|
if not data:
|
||||||
await interaction.followup.send("Trip not found.")
|
await interaction.followup.send("Trip not found.")
|
||||||
return
|
return
|
||||||
|
if not _user_can_see_trip(data, str(interaction.user.id)):
|
||||||
|
await interaction.followup.send("This trip is private.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
attendee_names = list(data.get("attendees", {}).values())
|
attendee_names = list(data.get("attendees", {}).values())
|
||||||
desc_lines = [
|
desc_lines = [
|
||||||
@@ -302,9 +319,11 @@ class TripCommands(commands.Cog):
|
|||||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
||||||
if ok:
|
if result is True:
|
||||||
await interaction.followup.send("You're on the trip!")
|
await interaction.followup.send("You're on the trip!")
|
||||||
|
elif result == "private":
|
||||||
|
await interaction.followup.send("This trip is private — you need an invite to join.")
|
||||||
else:
|
else:
|
||||||
await interaction.followup.send("Failed to join trip.")
|
await interaction.followup.send("Failed to join trip.")
|
||||||
|
|
||||||
@@ -433,5 +452,64 @@ class TripCommands(commands.Cog):
|
|||||||
await interaction.followup.send("Failed to leave event.")
|
await interaction.followup.send("Failed to leave event.")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip invite
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
|
||||||
|
@app_commands.describe(trip="The trip.", user="The user to invite.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.invite_to_trip(trip, str(user.id))
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to send invite.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip privacy
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="privacy", description="Set a trip to public or private.")
|
||||||
|
@app_commands.describe(trip="The trip.", visibility="public or private")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
@app_commands.choices(visibility=[
|
||||||
|
app_commands.Choice(name="Public — anyone can see and join", value="public"),
|
||||||
|
app_commands.Choice(name="Private — invite only", value="private"),
|
||||||
|
])
|
||||||
|
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.set_trip_visibility(trip, visibility)
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Trip is now **{visibility}**.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to update trip privacy.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /link
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
|
||||||
|
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
|
||||||
|
async def link_account(self, interaction: discord.Interaction, code: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
result = await c2.link_discord_account(
|
||||||
|
code.upper().strip(),
|
||||||
|
str(interaction.user.id),
|
||||||
|
interaction.user.display_name,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
msgs = {
|
||||||
|
"invalid_code": "Invalid code. Generate a new one from the web app.",
|
||||||
|
"expired": "Code has expired. Generate a new one from the web app.",
|
||||||
|
"already_linked": "This Discord account is already linked to a different web account.",
|
||||||
|
"failed": "Something went wrong. Try again.",
|
||||||
|
}
|
||||||
|
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot):
|
async def setup(bot: commands.Bot):
|
||||||
await bot.add_cog(TripCommands(bot))
|
await bot.add_cog(TripCommands(bot))
|
||||||
|
|||||||
@@ -112,7 +112,55 @@ class C2Client:
|
|||||||
logger.error(f"C2 delete_trip failed: {e}")
|
logger.error(f"C2 delete_trip failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool:
|
async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 invite_to_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.put(
|
||||||
|
f"{self.base}/trips/{trip_id}/visibility",
|
||||||
|
json={"visibility": visibility},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 set_trip_visibility failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/auth/link",
|
||||||
|
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return {"error": "invalid_code"}
|
||||||
|
if r.status_code == 410:
|
||||||
|
return {"error": "expired"}
|
||||||
|
if r.status_code == 409:
|
||||||
|
return {"error": "already_linked"}
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 link_discord_account failed: {e}")
|
||||||
|
return {"error": "failed"}
|
||||||
|
|
||||||
|
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
|
||||||
|
"""Returns True on success, 'private' on 403, False on other errors."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
@@ -120,6 +168,8 @@ class C2Client:
|
|||||||
json={"discord_user_id": user_id, "discord_username": username},
|
json={"discord_user_id": user_id, "discord_username": username},
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
)
|
)
|
||||||
|
if r.status_code == 403:
|
||||||
|
return "private"
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user