"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0). Design : ``docs/V73_Wiki_Teamspaces_Polish.md``. - ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux non-membres (404, comme une collection restricted). - ``page_verifications`` : badge ✅ avec expiration (90 j par défaut). - ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ; ``guest_shares`` (``/g/``) ; ``page_views`` (compteurs journaliers). """ from __future__ import annotations import datetime import secrets from app.db import get_conn VERIFICATION_DAYS_DEFAULT = 90 # Roles, strongest first. Mirrors collection roles. TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer") _ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))} def _utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) def _iso(dt: datetime.datetime) -> str: return dt.replace(microsecond=0).isoformat() # ── teamspaces ───────────────────────────────────────────────────────────── def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None: """Explicit role, else workspace role, else ``None`` when unreachable.""" if not user_id: return None with get_conn() as conn: ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone() if not ts: return None row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?", (teamspace_id, user_id)).fetchone() if row: return row["role"] admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() if admin and admin["is_admin"]: return "owner" owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (ts["workspace_id"],)).fetchone() if owner and owner["owner_id"] == user_id: return "owner" if not ts["private"]: # a public teamspace is still workspace-scoped: no workspace # membership means no access (otherwise any logged-in account on # the instance could read every public teamspace). member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (ts["workspace_id"], user_id)).fetchone() return member["role"] if member else None return None def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool: return get_teamspace_role(user_id, teamspace_id) is not None def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool: role = get_teamspace_role(user_id, teamspace_id) return role in ("owner", "editor") def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]: """Teamspaces the user can see (private ones filtered out). ``workspace_id=None`` lists across every workspace (global sidebar). """ with get_conn() as conn: if workspace_id is not None: ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user_id)).fetchone() if not ws_member: owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone() admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() if not owner or owner["owner_id"] != user_id: if not (admin and admin["is_admin"]): return [] rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name", (workspace_id,)).fetchall() else: rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall() out = [] for r in rows: role = get_teamspace_role(user_id, r["id"]) if role is None: continue counts = conn.execute( """SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?) + (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""", (r["id"], r["id"])).fetchone() item = dict(r) item["role"] = role item["workspace_name"] = _workspace_name(conn, r["workspace_id"]) item["item_count"] = counts["n"] out.append(item) return out def _workspace_name(conn, workspace_id: int) -> str: row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone() return row["name"] if row else "" def teamspace_pages(teamspace_id: int) -> list[dict]: """Non-deleted pages belonging to a teamspace (title/URL order).""" with get_conn() as conn: return [dict(r) for r in conn.execute( """SELECT id, title, workspace_id, teamspace_id FROM pages WHERE teamspace_id=? AND deleted_at IS NULL ORDER BY title""", (teamspace_id,)).fetchall()] def teamspace_collections(teamspace_id: int) -> list[dict]: """Databases belonging to a teamspace.""" with get_conn() as conn: return [dict(r) for r in conn.execute( """SELECT id, name, icon FROM collections WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()] def create_teamspace(workspace_id: int, name: str, user_id: int, description: str = "", private: bool = False) -> int: with get_conn() as conn: try: cur = conn.execute( "INSERT INTO teamspaces (workspace_id, name, description, private, created_by)" " VALUES (?,?,?,?,?)", (workspace_id, name.strip(), description[:400], 1 if private else 0, user_id)) except Exception as exc: # UNIQUE(workspace_id, name) if "UNIQUE" in str(exc): raise ValueError("A teamspace with this name already exists") from None raise conn.commit() # the creator is owner conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)" " VALUES (?,?,'owner')", (cur.lastrowid, user_id)) conn.commit() return cur.lastrowid def teamspace_member_ids(teamspace_id: int) -> list[int]: with get_conn() as conn: return [r["user_id"] for r in conn.execute( "SELECT user_id FROM teamspace_members WHERE teamspace_id=?", (teamspace_id,)).fetchall()] # ── verified pages ───────────────────────────────────────────────────────── def is_expired(row) -> bool: if not row or not row["expires_at"]: return False try: return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]) except ValueError: return False def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT, note: str = "") -> dict: days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365)) expires = _iso(_utcnow() + datetime.timedelta(days=days)) with get_conn() as conn: conn.execute( """INSERT INTO page_verifications (page_id, verified_by, note, expires_at) VALUES (?,?,?,?) ON CONFLICT(page_id) DO UPDATE SET verified_by=excluded.verified_by, note=excluded.note, verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""", (page_id, user_id, note[:400], expires)) conn.commit() return verification(page_id) def verification(page_id: int) -> dict | None: with get_conn() as conn: row = conn.execute( """SELECT v.*, u.full_name, u.login FROM page_verifications v LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""", (page_id,)).fetchone() if not row: return None d = dict(row) d["expired"] = is_expired(row) d["active"] = not d["expired"] return d def unverify_page(page_id: int) -> bool: with get_conn() as conn: cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,)) conn.commit() return bool(cur.rowcount) def expiring_verifications(days: int = 7) -> list[dict]: """Verifications expiring within ``days`` (drives the owner notification). ``pages`` has no owner column in FlowDeck, so the reminder targets the user who performed the verification. """ horizon = _iso(_utcnow() + datetime.timedelta(days=days)) with get_conn() as conn: rows = conn.execute( """SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v JOIN pages p ON p.id = v.page_id WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""", (horizon,)).fetchall() return [dict(r) for r in rows] # ── follows ──────────────────────────────────────────────────────────────── def is_following(page_id: int, user_id: int) -> bool: with get_conn() as conn: return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", (page_id, user_id)).fetchone()) def toggle_follow(page_id: int, user_id: int) -> bool: """Returns the new state (True = now following).""" with get_conn() as conn: if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", (page_id, user_id)).fetchone(): conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?", (page_id, user_id)) conn.commit() return False conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)", (page_id, user_id)) conn.commit() return True def ensure_follow(page_id: int, user_id: int, conn=None) -> bool: """Follow unless already following. Returns True when newly followed.""" if not user_id: return False def _run(c): if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?", (page_id, user_id)).fetchone(): return False c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)", (page_id, user_id)) return True if conn is not None: added = _run(conn) conn.commit() return added with get_conn() as _c: added = _run(_c) _c.commit() return added def followers(page_id: int) -> list[int]: with get_conn() as conn: return [r["user_id"] for r in conn.execute( "SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()] # Rate-limit window for ``page.updated`` notifications: the block editor # autosaves every ~1.5 s; without it followers would be spammed per keystroke. _UPDATE_NOTIF_WINDOW_MIN = 10 def notify_followers_of_page_update(page_id: int, actor_id: int | None, title: str = "") -> int: """Create a ``page.updated`` notification for every follower (except the actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes. Returns the number of notifications created.""" if not page_id: return 0 from app.services.notifications import create_notification with get_conn() as conn: followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall() if not followed: return 0 page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() title = (title or (page["title"] if page else "") or "Untitled") created = 0 for f in followed: uid = f["user_id"] if uid == actor_id: continue recent = conn.execute( """SELECT 1 FROM notifications WHERE user_id=? AND resource_type='page' AND resource_id=? AND ntype='page.updated' AND created_at >= datetime('now', ?)""", (uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone() if recent: continue create_notification( uid, actor_id, "page.updated", title=f'"{title}" was updated', message=f'Page "{title}" has been modified', resource_type="page", resource_id=page_id, url=f"/pages/{page_id}", conn=conn, commit=False, ) created += 1 conn.commit() return created # ── comment reactions ────────────────────────────────────────────────────── def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict: """Add or remove ``emoji``; returns the aggregated counts for the comment.""" emoji = (emoji or "").strip()[:16] with get_conn() as conn: if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone(): raise LookupError("Comment not found") existing = conn.execute( "SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?", (comment_id, user_id, emoji)).fetchone() if existing: conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],)) conn.commit() else: conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)" " VALUES (?,?,?)", (comment_id, user_id, emoji)) conn.commit() return reactions(comment_id) def reactions(comment_id: int) -> dict[str, dict]: with get_conn() as conn: rows = conn.execute( """SELECT emoji, COUNT(*) AS n, GROUP_CONCAT(user_id) AS users FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""", (comment_id,)).fetchall() return {r["emoji"]: {"count": r["n"], "users": [int(u) for u in (r["users"] or "").split(",") if u]} for r in rows} # ── guest shares ─────────────────────────────────────────────────────────── def create_guest_share(page_id: int, email: str, role: str, created_by: int, days: int | None = 30) -> dict: if role not in ("viewer", "commenter"): raise ValueError("role must be viewer or commenter") token = f"g_{secrets.token_urlsafe(24)}" expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None with get_conn() as conn: cur = conn.execute( """INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at) VALUES (?,?,?,?,?,?)""", (page_id, email[:200], token, role, created_by, expires)) conn.commit() return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?", (cur.lastrowid,)).fetchone()) def resolve_guest_share(token: str) -> dict | None: """Active share for ``token``, or ``None`` (unknown / revoked / expired).""" with get_conn() as conn: row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone() if not row or row["revoked"]: return None if row["expires_at"]: try: if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]): return None except ValueError: pass return dict(row) # ── page views ───────────────────────────────────────────────────────────── def record_view(page_id: int, day: str | None = None) -> int: day = day or _utcnow().strftime("%Y-%m-%d") with get_conn() as conn: conn.execute( """INSERT INTO page_views (page_id, day, views) VALUES (?,?,1) ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""", (page_id, day)) conn.commit() row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?", (page_id, day)).fetchone() return row["views"] def view_stats(page_id: int, days: int = 30) -> dict: days = max(1, min(int(days or 30), 365)) since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d") with get_conn() as conn: rows = conn.execute( "SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day", (page_id, since)).fetchall() series = {r["day"]: r["views"] for r in rows} # fill the gap so charts have no holes out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1) for _ in range(days): key = cursor.strftime("%Y-%m-%d") out.append({"day": key, "views": series.get(key, 0)}) cursor += datetime.timedelta(days=1) return {"page_id": page_id, "days": days, "total": sum(series.values()), "series": out}