"""FlowDeck — Sharing, Publishing & Recents API (v4.0).""" from __future__ import annotations import logging import re import unicodedata from datetime import datetime from fastapi import APIRouter, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["sharing"], prefix="/api") def _require_auth(request: Request) -> dict: """Require an authenticated session. Returns user dict or raises 401.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: raise HTTPException(status_code=401, detail="Authentication required") return user def _slugify(title: str) -> str: """Generate a URL-safe slug from a page title.""" slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii") slug = re.sub(r"[^\w\s-]", "", slug.lower()) slug = re.sub(r"[-\s]+", "-", slug).strip("-") return slug or "untitled" # ── Page Sharing ── @router.post("/pages/{page_id}/share") async def share_page(page_id: int, request: Request): """Invite a user or email to a page.""" user = _require_auth(request) body = await request.json() if request.headers.get("content-type") else {} target_user_id = body.get("user_id") email = body.get("email", "") permission = body.get("permission", "view") if permission not in ("view", "comment", "edit"): raise HTTPException(400, "Invalid permission. Use view, comment, or edit.") if not target_user_id and not email: raise HTTPException(400, "Provide user_id or email to share with.") with get_conn() as conn: # Verify page exists page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") # Verify target user exists if user_id given if target_user_id: target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone() if not target: raise HTTPException(404, "Target user not found") # Upsert to avoid duplicates: update the existing permission if the same # target (user or email) is already shared on this page. target_row = None if target_user_id: target_row = conn.execute( "SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?", (page_id, target_user_id), ).fetchone() elif email: target_row = conn.execute( """SELECT id FROM page_shares WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""", (page_id, email.strip()), ).fetchone() if target_row: conn.execute( "UPDATE page_shares SET permission=?, created_by=? WHERE id=?", (permission, user["id"], target_row["id"]), ) share_id = target_row["id"] else: if not email: email = "" cur = conn.execute( """INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)""", (page_id, target_user_id, email.strip(), permission, user["id"]), ) share_id = cur.lastrowid conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,)) conn.commit() return { "id": share_id, "page_id": page_id, "shared_with_user_id": target_user_id, "shared_with_email": email, "permission": permission, "status": "shared", } @router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.") async def update_share_permission(page_id: int, share_id: int, request: Request): """Change the permission level of an existing share entry.""" _require_auth(request) body = await request.json() if request.headers.get("content-type") else {} permission = body.get("permission", "") if permission not in ("view", "comment", "edit"): raise HTTPException(400, "Invalid permission. Use view, comment, or edit.") with get_conn() as conn: row = conn.execute( "SELECT id FROM page_shares WHERE id=? AND page_id=?", (share_id, page_id), ).fetchone() if not row: raise HTTPException(404, "Share entry not found") conn.execute( "UPDATE page_shares SET permission=? WHERE id=?", (permission, share_id), ) conn.commit() return {"status": "updated", "share_id": share_id, "permission": permission} @router.delete("/pages/{page_id}/share/{share_id}") async def remove_share(page_id: int, share_id: int, request: Request): """Remove a share invitation.""" _require_auth(request) with get_conn() as conn: row = conn.execute( "SELECT id FROM page_shares WHERE id=? AND page_id=?", (share_id, page_id), ).fetchone() if not row: raise HTTPException(404, "Share entry not found") conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,)) # If no more shares, unset is_shared remaining = conn.execute( "SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,) ).fetchone()["c"] if remaining == 0: conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,)) conn.commit() return {"status": "removed", "share_id": share_id} @router.get("/pages/{page_id}/shares") async def list_shares(page_id: int, request: Request): """Get all shares for a page.""" _require_auth(request) with get_conn() as conn: page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") rows = conn.execute( """SELECT s.*, u.login, u.full_name, u.avatar_url FROM page_shares s LEFT JOIN users u ON s.shared_with_user_id = u.id WHERE s.page_id=? ORDER BY s.created_at DESC""", (page_id,), ).fetchall() return { "page_id": page_id, "shares": [ { "id": r["id"], "shared_with_user_id": r["shared_with_user_id"], "shared_with_email": r["shared_with_email"], "permission": r["permission"], "created_at": r["created_at"], "created_by": r["created_by"], "user_login": r["login"], "user_full_name": r["full_name"], "user_avatar_url": r["avatar_url"], } for r in rows ], } # ── Page Publishing ── @router.post("/pages/{page_id}/publish") async def publish_page(page_id: int, request: Request): """Publish a page (is_published=1) with a URL slug.""" _require_auth(request) with get_conn() as conn: page = conn.execute( "SELECT id, title, is_published FROM pages WHERE id=?", (page_id,) ).fetchone() if not page: raise HTTPException(404, "Page not found") slug = _slugify(page["title"]) # Ensure uniqueness by appending suffix if needed base_slug = slug counter = 1 while conn.execute( "SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id) ).fetchone(): slug = f"{base_slug}-{counter}" counter += 1 conn.execute( "UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id), ) conn.commit() return { "page_id": page_id, "is_published": True, "publish_slug": slug, "status": "published", } @router.delete("/pages/{page_id}/publish") async def unpublish_page(page_id: int, request: Request): """Unpublish a page.""" _require_auth(request) with get_conn() as conn: page = conn.execute( "SELECT id, is_published FROM pages WHERE id=?", (page_id,) ).fetchone() if not page: raise HTTPException(404, "Page not found") conn.execute( "UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,), ) conn.commit() return { "page_id": page_id, "is_published": False, "status": "unpublished", } # ── Recents ── @router.post("/recents/track") async def track_recent(request: Request): """Record a page access in recents.""" user = _require_auth(request) body = await request.json() if request.headers.get("content-type") else {} page_id = body.get("page_id") workspace = body.get("workspace", "") source_type = body.get("source_type", "local") if not page_id: raise HTTPException(400, "page_id is required") with get_conn() as conn: page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(404, "Page not found") conn.execute( """INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, page_id) DO UPDATE SET workspace=excluded.workspace, source_type=excluded.source_type, accessed_at=excluded.accessed_at""", (user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()), ) conn.commit() return { "status": "tracked", "user_id": user["id"], "page_id": page_id, "accessed_at": datetime.utcnow().isoformat(), }