"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions. Comments live in the existing `comments` table, extended with a target_type / target_id pair and inline anchors (anchor_block_id + text offsets). Mentions written in a comment body automatically notify the mentioned users. """ from __future__ import annotations import logging from fastapi import APIRouter, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn from app.services import notifications as notif logger = logging.getLogger(__name__) router = APIRouter(tags=["collaboration"], prefix="/api") def _current_user(request: Request) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Authentication required") return user def _page_url(page_id: int) -> str: from app.config import settings return f"{settings.app_base_url}/pages/{page_id}" def _serialize(rows): out = [] for r in rows: d = dict(r) d["author"] = { "id": r["author_id"], "login": r["author_login"], "full_name": r["author_name"], "avatar_url": r["author_avatar"], "avatar_color": r["author_color"], } for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"): d.pop(k, None) out.append(d) return out @router.get("/pages/{page_id}/comments") async def list_comments(request: Request, page_id: int): """List page-level and inline comments for a FlowDeck page.""" _current_user(request) with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") rows = conn.execute( """SELECT c.*, c.user_id AS author_id, u.login AS author_login, u.full_name AS author_name, u.avatar_url AS author_avatar, u.avatar_color AS author_color FROM comments c JOIN users u ON c.user_id = u.id WHERE c.target_type='page' AND c.target_id=? ORDER BY c.created_at ASC, c.id ASC""", (page_id,), ).fetchall() return {"page_id": page_id, "comments": _serialize(rows)} @router.post("/pages/{page_id}/comments") async def add_comment(request: Request, page_id: int): """Create a page or inline comment. Mentions (@login) notify users.""" user = _current_user(request) body = await request.json() if request.headers.get("content-type") else {} text = (body.get("body") or "").strip() if not text: raise HTTPException(400, "body required") anchor_block = body.get("anchor_block_id") anchor_start = body.get("anchor_start") anchor_end = body.get("anchor_end") # normalize empty anchor → page-level comment if not anchor_block or anchor_start is None or anchor_end is None: anchor_block, anchor_start, anchor_end = None, None, None elif int(anchor_start) == int(anchor_end): anchor_block, anchor_start, anchor_end = None, None, None parent_id = body.get("parent_id") uid = user["id"] with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") conn.execute( "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", (uid, user.get("login", "admin"), user.get("full_name", "Admin")), ) cur = conn.execute( """INSERT INTO comments (page_id, user_id, body, parent_id, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""", (page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end), ) comment_id = cur.lastrowid conn.commit() # Notify users @-mentioned in the comment (skip the author). url = _page_url(page_id) title = f"New comment on “{page['title']}”" message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}" notif.process_mentions( text, uid, "mention", title, message, "page", page_id, url, conn=conn, ) conn.commit() return {"id": comment_id, "status": "created"} @router.post("/pages/{page_id}/mentions") async def notify_page_mentions(request: Request, page_id: int): """Notify users @-mentioned in a page's content (called on save). Accepts {"text": "..."} containing @login handles. Deduplicated server-side against a per-page cache so repeated auto-saves don't spam notifications. """ user = _current_user(request) body = await request.json() if request.headers.get("content-type") else {} text = body.get("text") or "" with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") url = _page_url(page_id) mentioned = notif.process_mentions( text, user["id"], "mention", f"You were mentioned in “{page['title']}”", f"{user.get('full_name') or user.get('login')} mentioned you on a page.", "page", page_id, url, conn=conn, ) conn.commit() return {"mentioned": mentioned} @router.put("/comments/{comment_id}") async def update_comment(request: Request, comment_id: int): """Update a comment body or resolve/unresolve it.""" user = _current_user(request) body = await request.json() if request.headers.get("content-type") else {} 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"]: raise HTTPException(403, "Not allowed to edit this comment") if "body" in body and body.get("body") is not None: conn.execute( "UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body["body"].strip(), comment_id), ) if "resolved" in body and body.get("resolved") is not None: conn.execute("UPDATE comments SET resolved=? WHERE id=?", (1 if body["resolved"] else 0, comment_id)) conn.commit() return {"id": comment_id, "status": "updated"} @router.delete("/comments/{comment_id}") async def delete_comment(request: Request, comment_id: int): """Delete a comment and its replies.""" user = _current_user(request) 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"]: # allow page "owners" — fall back to a simple ownership rule for now raise HTTPException(403, "Not allowed to delete this comment") conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id)) conn.commit() return {"id": comment_id, "status": "deleted"}