"""FlowDeck — Calendar links + Meetings API (v7.1.0). ``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer ``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` — weekday availability. ``/api/v2/meetings/*`` — audio upload, manual transcript, AI summary (fires ``meeting.summarized``). See ``docs/V71_Calendar_Meetings.md``. """ from __future__ import annotations import secrets from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager from app.db import get_conn from app.services import calendar_sync as cal from app.services import meetings as meet from app.services.api_v2_helpers import ( audit_log, has_scope, resolve_bearer_token, row_to_dict, ) router = APIRouter(tags=["calendar-meetings"]) def _auth_user(request: Request, *, require_write: bool = False) -> dict: sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if sess: return sess auth = request.headers.get("authorization") or request.headers.get("Authorization") or "" if auth.lower().startswith("bearer "): user = resolve_bearer_token(auth[7:].strip()) if not user: raise HTTPException(401, "Invalid or expired API token") if require_write and not has_scope(user.get("_token_scopes") or "read", "write"): raise HTTPException(403, "Insufficient scope. Required: write") return user raise HTTPException(401, "Authentication required") # ── calendar links ───────────────────────────────────────────────────────── @router.post("/api/v2/calendar-links") def create_link(request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) provider = (body.get("provider") or "").lower() if provider not in cal.PROVIDERS: raise HTTPException(400, "provider must be google|caldav") try: collection_id = int(body.get("collection_id", 0)) except (TypeError, ValueError): raise HTTPException(400, "collection_id required") from None creds = body.get("credentials") or {} if provider == "google" and not creds.get("access_token"): raise HTTPException(400, "google needs credentials.access_token") if provider == "caldav" and not creds.get("url"): raise HTTPException(400, "caldav needs credentials.url") try: out = cal.save_link(user["id"], provider, collection_id, creds, body.get("calendar_id") or "primary", body.get("date_property") or "") except ValueError as exc: raise HTTPException(400, str(exc)) from None audit_log(user, "calendar.link", "collection", collection_id, provider, request) return JSONResponse(status_code=201, content=out) @router.get("/api/v2/calendar-links") def get_links(request: Request): user = _auth_user(request) return {"links": cal.list_links(user["id"])} @router.delete("/api/v2/calendar-links/{link_id}") def remove_link(link_id: int, request: Request): user = _auth_user(request, require_write=True) if not cal.delete_link(user["id"], link_id): raise HTTPException(404, "Link not found") audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request) return {"status": "deleted", "id": link_id} @router.post("/api/v2/calendar-links/{link_id}/sync") async def sync_now(link_id: int, request: Request): user = _auth_user(request, require_write=True) with get_conn() as conn: row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone() if not row or (row["user_id"] != user["id"] and not user.get("is_admin")): raise HTTPException(404, "Link not found") try: stats = await cal.sync_link(link_id) except (cal.SyncError, ValueError) as exc: raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400, str(exc)) from None audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request) return {"link_id": link_id, **stats} # ── free/busy ────────────────────────────────────────────────────────────── @router.get("/db/{collection_id}/calendar/freebusy") def freebusy(collection_id: int, request: Request): _auth_user(request) qp = request.query_params try: out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""), qp.get("date_property", "")) except ValueError as exc: raise HTTPException(400, str(exc)) from None return out # ── meetings ─────────────────────────────────────────────────────────────── @router.post("/api/v2/meetings/transcribe") async def upload_and_transcribe(request: Request): user = _auth_user(request, require_write=True) try: form = await request.form() except Exception: raise HTTPException(400, "multipart upload required") from None upload = form.get("audio") try: page_id = int(form.get("page_id", 0)) except (TypeError, ValueError): raise HTTPException(400, "page_id required") from None language = (form.get("language") or "fr")[:10] manual = (form.get("transcript") or "").strip() if upload is None and not manual: raise HTTPException(400, "audio file or transcript required") audio_path = "" if upload is not None: filename = (upload.filename or "").lower() ext = filename.rsplit(".", 1)[-1] if "." in filename else "" if ext not in meet.AUDIO_EXTENSIONS: raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}") data = await upload.read() if len(data) > meet.MAX_AUDIO_BYTES: raise HTTPException(413, "audio exceeds 100 MB") if not data: raise HTTPException(400, "empty audio file") audio_path = str(meet.meetings_dir() / f"{page_id}_{secrets.token_hex(8)}.{ext}") with open(audio_path, "wb") as fh: fh.write(data) transcript = manual if not transcript and audio_path: try: transcript = meet.transcribe_audio(audio_path, language) except meet.TranscriptionUnavailable as exc: transcript = "" # stored; client transcribes or posts manual text later _ = exc try: tid = meet.save_transcript(page_id, transcript, language, audio_path) except ValueError as exc: raise HTTPException(404, str(exc)) from None with get_conn() as conn: row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone() audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request) return JSONResponse(status_code=201, content={ **row_to_dict(row), "transcribed": bool(transcript)}) @router.post("/api/v2/meetings/transcripts/{transcript_id}/text") def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})): """Store a client-side (manual) transcript on an existing row.""" _auth_user(request, require_write=True) text = (body.get("transcript") or "").strip() if not text: raise HTTPException(400, "transcript required") with get_conn() as conn: if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?", (transcript_id,)).fetchone(): raise HTTPException(404, "Transcript not found") conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?", (text, transcript_id)) conn.commit() row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)).fetchone() return row_to_dict(row) @router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize") async def summarize(transcript_id: int, request: Request): user = _auth_user(request, require_write=True) try: out = await meet.summarize_transcript(transcript_id, user.get("id")) except ValueError as exc: raise HTTPException(400, str(exc)) from None except RuntimeError as exc: raise HTTPException(502, str(exc)) from None audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request) return out