"""FlowDeck — Public API v2 : engagement. Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). """ from __future__ import annotations import json import logging from fastapi import APIRouter, Body, Header, HTTPException, Request from app.db import get_conn from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers audit_log, check_idempotency, check_v2_rate_limit, get_bearer_user, has_scope, paginate_headers, parse_pagination, require_scope, row_to_dict, store_idempotency, to_iso8601, validate_scopes_input, ) from app.services.automations import fire_event as _fire_event from app.services.automations import run_event_sync from ._common import _v2_rate_check logger = logging.getLogger(__name__) router = APIRouter(tags=["api-v2"]) @router.get("/pages/{page_id}/comments") def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) limit, offset = parse_pagination(request) with get_conn() as conn: total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0] rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall() return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} @router.post("/pages/{page_id}/comments") def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) text = (body.get("body") or body.get("content") or "").strip() if not text: raise HTTPException(400, "body is required") with get_conn() as conn: cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end"))) nid = cur.lastrowid conn.commit() row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone() audit_log(user, "comment.create", "comment", nid, text[:80], request) try: run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})) except Exception: logger.exception("create_comment_v2") return {"id": nid, "status": "created", "comment": row_to_dict(row)} @router.patch("/comments/{comment_id}") def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() if not row: raise HTTPException(404, "Comment not found") if row["user_id"] != user["id"] and not user.get("is_admin"): raise HTTPException(403, "Not your comment") body_text = body.get("body", row["body"]) resolved = body.get("resolved", row["resolved"]) was_resolved = int(row["resolved"] or 0) conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id)) conn.commit() if int(bool(resolved)) and not was_resolved: try: run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})) except Exception: logger.exception("patch_comment_v2") return {"id": comment_id, "status": "updated"} @router.delete("/comments/{comment_id}") def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone() if not row: raise HTTPException(404, "Comment not found") if row["user_id"] != user["id"] and not user.get("is_admin"): raise HTTPException(403, "Not your comment") conn.execute("DELETE FROM comments WHERE id=?", (comment_id,)) conn.commit() return {"id": comment_id, "status": "deleted"} @router.post("/pages/{page_id}/mentions") def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) targets = body.get("user_ids") or body.get("mentions") or [] if isinstance(targets, int): targets = [targets] if not targets: raise HTTPException(400, "user_ids required") created = 0 with get_conn() as conn: for uid in targets: try: conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}")) created += 1 except Exception: logger.exception("create_mention_v2") conn.commit() if created: try: run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})) except Exception: logger.exception("create_mention_v2") return {"mentions": created, "status": "created"} @router.get("/notifications") def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) limit, offset = parse_pagination(request) unread = request.query_params.get("unread") with get_conn() as conn: where = "user_id=?" params: list = [user["id"]] if unread == "1": where += " AND is_read=0" total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0] rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} @router.get("/notifications/unread-count") def unread_count(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0] return {"unread": cnt} @router.post("/notifications/{notif_id}/read") def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"])) conn.commit() return {"id": notif_id, "status": "read"} @router.post("/notifications/read-all") def mark_all_read(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],)) conn.commit() return {"status": "all read"} @router.patch("/users/me/preferences") def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() try: cur = json.loads(row["notification_prefs"] or "{}") except Exception: cur = {} cur.update(body) conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"])) conn.commit() return {"preferences": cur} @router.get("/favorites") def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall() return {"favorites": [row_to_dict(r) for r in rows]} @router.post("/favorites") def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) pid = body.get("page_id") if not pid: raise HTTPException(400, "page_id required") with get_conn() as conn: try: conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid)) conn.commit() except Exception as e: raise HTTPException(409, str(e)) from None try: run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})) except Exception: logger.exception("add_favorite_v2") return {"page_id": pid, "status": "added"} @router.delete("/favorites/{page_id}") def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id)) conn.commit() try: run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})) except Exception: logger.exception("remove_favorite_v2") return {"page_id": page_id, "status": "removed"} @router.get("/tags") def list_tags_v2(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) q = (request.query_params.get("q") or "").strip() with get_conn() as conn: if q: rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall() else: rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall() return {"tags": [dict(r) for r in rows]} @router.post("/tags") def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name required") color = body.get("color", "#787774") with get_conn() as conn: try: cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"])) tid = cur.lastrowid conn.commit() except Exception as e: raise HTTPException(409, str(e)) from None return {"id": tid, "name": name, "color": color, "status": "created"} @router.patch("/tags/{tag_id}") def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone() if not row: raise HTTPException(404, "Tag not found") name = body.get("name", row["name"]) color = body.get("color", row["color"]) conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id)) conn.commit() return {"id": tag_id, "status": "updated"} @router.delete("/tags/{tag_id}") def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])) conn.commit() return {"id": tag_id, "status": "deleted"} @router.post("/pages/{page_id}/tags") def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) tag_id = body.get("tag_id") if not tag_id: raise HTTPException(400, "tag_id required") with get_conn() as conn: try: conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id)) conn.commit() except Exception as e: raise HTTPException(409, str(e)) from None return {"page_id": page_id, "tag_id": tag_id, "status": "attached"} @router.delete("/pages/{page_id}/tags/{tag_id}") def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id)) conn.commit() return {"status": "detached"} @router.get("/recents") def list_recents_v2(request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) limit = int(request.query_params.get("limit", "20")) st = request.query_params.get("source_type") with get_conn() as conn: if st: rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall() else: rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall() return {"recents": [row_to_dict(r) for r in rows]} @router.get("/pages/{page_id}/shares") def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall() return {"shares": [dict(r) for r in rows]}