"""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/transcripts") def create_transcript(request: Request, body: dict = Body(default={})): """Crée une note de réunion vide (état idle) pour une page — §6.5. Idempotent par occurrence calendrier : renvoie la note existante. """ user = _auth_user(request, require_write=True) try: page_id = int(body.get("page_id", 0)) except (TypeError, ValueError): raise HTTPException(400, "page_id required") from None occ = (body.get("event_occurrence_id") or "").strip()[:200] with get_conn() as conn: if occ: row = conn.execute( "SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)).fetchone() if row: return {**row_to_dict(row), "already_existed": True} try: tid = meet.save_transcript( page_id, "", (body.get("language") or "fr")[:10], mode=(body.get("mode") or "mic_only") if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only", instruction_id=(body.get("instruction_id") or "auto")[:40], event_occurrence_id=occ) except ValueError as exc: msg = str(exc) raise HTTPException(404 if "not found" in msg else 400, msg) from None with get_conn() as conn: row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone() audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request) return JSONResponse(status_code=201, content={**row_to_dict(row), "already_existed": False}) @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, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: out = await meet.summarize_transcript( transcript_id, user.get("id"), allow_empty=bool((body or {}).get("allow_empty"))) 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 # ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ─── @router.get("/api/v2/meetings/instructions") def meeting_instructions(request: Request): _auth_user(request) return {"instructions": [ {"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items() ]} # Une page est une « note de réunion » dès qu'elle contient un bloc # AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté # navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces. MEETING_BLOCK_PREDICATE = ( "REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'" ) @router.get("/api/v2/meetings/notes") def meeting_notes(request: Request, limit: int = 50): """Notes de réunion : pages contenant un bloc AI Meeting Notes. Alimente la section Meetings de la sidebar (ces pages sont exclues de l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes jamais enregistrées (aucune ligne ``meeting_transcripts``) avec ``status = idle``. """ user = _auth_user(request) uid = user.get("id") lim = max(1, min(int(limit or 50), 200)) pred = MEETING_BLOCK_PREDICATE.format(col="p.content") with get_conn() as conn: rows = conn.execute( f"""SELECT p.id, p.title, p.updated_at, (SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status, (SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) AND {pred} ORDER BY p.updated_at DESC LIMIT ?""", (uid, lim), ).fetchall() notes = [] for r in rows: d = row_to_dict(r) notes.append({ "id": d["id"], "title": d.get("title") or "Untitled", "updated_at": d.get("updated_at") or "", "status": d.get("status") or "idle", "has_summary": bool((d.get("summary") or "").strip()), }) return {"notes": notes, "count": len(notes)} @router.get("/api/v2/meetings/history") def meetings_history(request: Request, query: str = "", limit: int = 50): """Historique des notes de réunion (passé + recherche plein texte).""" user = _auth_user(request) _ = user like = f"%{query.strip()}%" if query.strip() else "%" lim = max(1, min(int(limit or 50), 200)) with get_conn() as conn: rows = conn.execute( """SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt JOIN pages p ON p.id = mt.page_id WHERE (p.title LIKE ? OR mt.transcript LIKE ? OR mt.summary LIKE ? OR mt.generated_title LIKE ?) ORDER BY mt.id DESC LIMIT ?""", (like, like, like, like, lim), ).fetchall() out = [] for r in rows: d = row_to_dict(r) d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting" out.append(d) return {"meetings": out, "count": len(out)} @router.get("/api/v2/meetings/transcripts/{transcript_id}") def get_transcript_row(transcript_id: int, request: Request): _auth_user(request) try: return meet.get_transcript(transcript_id) except ValueError as exc: raise HTTPException(404, str(exc)) from None @router.post("/api/v2/meetings/transcripts/{transcript_id}/consents") def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: entry = meet.record_consent( transcript_id, method=(body.get("method") or "start_attestation"), message_ref=(body.get("message_ref") or "")[:200], attested_by=user.get("id"), participants=body.get("participants") or []) except ValueError as exc: msg = str(exc) raise HTTPException(404 if "not found" in msg else 400, msg) from None audit_log(user, "meeting.consent", "transcript", transcript_id, entry["method"], request) return entry @router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions") def start_session(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: out = meet.start_session( transcript_id, mode=(body.get("mode") or "mic_only"), channels=body.get("channels"), language=(body.get("language") or "fr"), instruction_id=(body.get("instruction_id") or "auto"), user_id=user.get("id")) except ValueError as exc: msg = str(exc) raise HTTPException(404 if "not found" in msg else 400, msg) from None audit_log(user, "meeting.session.start", "transcript", transcript_id, out.get("mode", ""), request) return out @router.post("/api/v2/meetings/transcripts/{transcript_id}/pause") def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: out = meet.pause_session(transcript_id, paused_ms_delta=int(body.get("paused_ms") or 0)) except ValueError as exc: raise HTTPException(400, str(exc)) from None audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request) return out @router.post("/api/v2/meetings/transcripts/{transcript_id}/resume") def resume_session(transcript_id: int, request: Request): user = _auth_user(request, require_write=True) try: out = meet.resume_session(transcript_id) except ValueError as exc: raise HTTPException(400, str(exc)) from None audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request) return out @router.post("/api/v2/meetings/transcripts/{transcript_id}/stop") async def stop_session(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: stopped = meet.stop_session(transcript_id, duration_ms=int((body or {}).get("duration_ms") or 0)) except ValueError as exc: raise HTTPException(400, str(exc)) from None audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request) # Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas # d'échec LLM le transcript reste consultable (état failed, retry). # allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8), # jamais une ligne coincée en « processing ». try: out = await meet.run_processing( transcript_id, user.get("id"), allow_empty=bool((body or {}).get("allow_empty"))) except ValueError as exc: raise HTTPException(400, str(exc)) from None except RuntimeError as exc: raise HTTPException(502, str(exc)) from None return {"session": {"id": stopped["id"], "status": "done"}, **out} @router.post("/api/v2/meetings/transcripts/{transcript_id}/segments") def append_segments(transcript_id: int, request: Request, body: dict = Body(default={})): _auth_user(request, require_write=True) try: return meet.append_segments(transcript_id, body.get("segments") or []) except ValueError as exc: msg = str(exc) raise HTTPException(404 if "not found" in msg else 400, msg) from None @router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript") def grouped_transcript(transcript_id: int, request: Request): _auth_user(request) try: return meet.grouped_transcript(transcript_id) except ValueError as exc: raise HTTPException(404, str(exc)) from None @router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker") def patch_speaker(transcript_id: int, seq: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: return meet.set_segment_speaker(transcript_id, seq, body.get("speaker") or "", user.get("id")) except ValueError as exc: raise HTTPException(400, str(exc)) from None @router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps") def processing_steps(transcript_id: int, request: Request): _auth_user(request) try: tr = meet.get_transcript(transcript_id) except ValueError as exc: raise HTTPException(404, str(exc)) from None import json as _json try: steps = _json.loads(tr.get("processing_steps_json") or "[]") except (TypeError, ValueError): steps = [] return {"transcript_id": transcript_id, "status": tr.get("status") or "idle", "steps": steps} @router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry") async def retry_processing(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: tr = meet.get_transcript(transcript_id) except ValueError as exc: raise HTTPException(404, str(exc)) from None if tr.get("status") not in ("failed", "processing", "done"): raise HTTPException(400, "nothing to retry — no failed processing") try: return await meet.run_processing( transcript_id, user.get("id"), allow_empty=bool((body or {}).get("allow_empty"))) except ValueError as exc: raise HTTPException(400, str(exc)) from None except RuntimeError as exc: raise HTTPException(502, str(exc)) from None @router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title") def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) title = (body.get("title") or "").strip()[:200] if not title: raise HTTPException(400, "title 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 generated_title=?," " title_source='user' WHERE id=?", (title, transcript_id)) conn.commit() audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request) return {"transcript_id": transcript_id, "generated_title": title} @router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions") def post_distribution(transcript_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) try: out = meet.record_distribution( transcript_id, channel=(body.get("channel") or "copy_link"), actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500], status=body.get("status") or "created") except ValueError as exc: msg = str(exc) raise HTTPException(404 if "not found" in msg else 400, msg) from None audit_log(user, "meeting.share", "transcript", transcript_id, out["channel"], request) return out @router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes") def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})): _auth_user(request, require_write=True) 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 notes_text=? WHERE id=?", ((body.get("notes") or "")[:50000], transcript_id)) conn.commit() return {"transcript_id": transcript_id, "saved": True}