"""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 json import secrets from datetime import UTC, date, datetime, timedelta 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 # ── upcoming (sidebar "Meeting" tab) ──────────────────────────────────────── @router.get("/api/v2/meetings/upcoming") def upcoming_meetings(request: Request, days: int = 30): """Upcoming calendar events for the sidebar Meeting tab. Reads every calendar collection linked to the current user, extracts the date property of each linked row and returns the ones falling in the next ``days`` days (today included), sorted chronologically. Rows imported from Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``. """ user = _auth_user(request) horizon = max(1, min(days, 365)) today = datetime.now(UTC).date() end = today + timedelta(days=horizon) events: list[dict] = [] with get_conn() as conn: links = conn.execute( "SELECT id, collection_id, date_property, calendar_id, provider " "FROM calendar_links WHERE user_id=? ORDER BY id", (user["id"],), ).fetchall() for link in links: collection_id = link["collection_id"] if not collection_id: continue date_prop = cal.date_prop_id(conn, collection_id, link["date_property"] or "") if not date_prop: continue prop_id, prop_name = date_prop rows = conn.execute( "SELECT id, title, property_values_json, external_event_id " "FROM collection_pages WHERE collection_id=?", (collection_id,), ).fetchall() for row in rows: try: values = json.loads(row["property_values_json"] or "{}") except (TypeError, ValueError): continue raw = cal.row_date(values, prop_id, prop_name)[:10] if len(raw) != 10: continue try: day = date.fromisoformat(raw) except ValueError: continue if not (today <= day <= end): continue events.append({ "id": row["id"], "title": row["title"] or "Untitled", "date": raw, "today": day == today, "collection_id": collection_id, "calendar_id": link["calendar_id"] or "primary", "provider": link["provider"], "synced": bool(row["external_event_id"]), "url": f"/db/{collection_id}", }) events.sort(key=lambda e: (e["date"], e["title"].lower())) return {"today": today.isoformat(), "days": horizon, "events": events[:200], "count": len(events)} # ── 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