- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
(19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
`create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
211 lines
8.6 KiB
Python
211 lines
8.6 KiB
Python
"""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
|
|
from app.services.automations import fire_event as _fire_event
|
|
|
|
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()
|
|
|
|
# v7.3.0: commenting implies following — the author gets the
|
|
# (throttled) page.updated notifications like any other follower.
|
|
from app.services import wiki as wiki_svc
|
|
wiki_svc.ensure_follow(page_id, uid, conn=conn)
|
|
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()
|
|
|
|
try:
|
|
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
|
mentioned_ids = notif.extract_mentions(text)
|
|
if mentioned_ids:
|
|
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
|
except Exception:
|
|
logger.exception("add_comment")
|
|
|
|
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()
|
|
if mentioned:
|
|
try:
|
|
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
|
except Exception:
|
|
logger.exception("notify_page_mentions")
|
|
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),
|
|
)
|
|
was_resolved = int(row["resolved"] or 0)
|
|
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()
|
|
if body.get("resolved") and not was_resolved:
|
|
try:
|
|
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
|
except Exception:
|
|
logger.exception("update_comment")
|
|
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"}
|