diff --git a/.env.example b/.env.example index d9c3124..636aeab 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,13 @@ DATABASE_URL=sqlite:////data/flowdeck.db # ── Sync ── SYNC_INTERVAL=60 GITEA_CACHE_TTL=30 + +# ── Email notifications (v4.9.0) ── +# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=FlowDeck +SMTP_USE_TLS=true +APP_BASE_URL=http://localhost:8080 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbf069..228b6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ # Changelog — FlowDeck -## v4.8.1 (2026-09-03) — Fix bloc Tableau : boutons + Ligne / + Colonne, redimensionnement +## v4.9.0 (2026-09-04) — Collaboration : commentaires inline, mentions @, notifications + +> Implémentation complète du pilier « Collaboration » — basée sur la revue des docs Notion +> (`docs/Guide_Complet_Notion_sharing_collaborartion.md`) : commentaires avec ancres inline, +> mentions `@` avec autocomplétion, centre de notifications in-app et emails SMTP. + +### Added +- **Commentaires inline sur les pages** : sélection d'un texte dans l'éditeur → bouton flottant + « 💬 Comment » → commentaire ancré (`block_id` + offsets). Table `comments` étendue + (`target_type`, `target_id`, `anchor_block_id`, `anchor_start`, `anchor_end`) et **migrée** + de son FK `collection_pages` vers un schéma générique (aucune perte de données, idempotente). +- **Panneau de commentaires** dans l'éditeur : liste, résolution (✔/↪), suppression, compteur + dans la topbar. +- **Mentions `@`** : autocomplétion en temps réel (recherche d'utilisateurs), insertion de + `@login` dans un bloc ou un commentaire → notification ciblée à l'utilisateur mentionné. +- **Centre de notifications in-app** : cloche dans la topbar (badge non-lus, polling 30s), + panneau déroulant, « mark as read » / « mark all read ». Table `notifications`. +- **Emails de notification** : service SMTP (`app/services/mailer.py`) + templates HTML ; + préférences email par utilisateur (comments/mentions) réglables dans Settings → Notifications. + Sans SMTP configuré, seules les notifications in-app sont émises (aucune erreur). +- **API** : `GET/POST /api/notifications`, `/api/notifications/read|read-all|prefs`, + `/api/notifications/users/search`, `GET/POST /api/pages/{id}/comments`, + `PUT/DELETE /api/comments/{id}`, `POST /api/pages/{id}/mentions`. +- **Config SMTP** (`SMTP_HOST/PORT/USER/PASSWORD/FROM/USE_TLS`, `APP_BASE_URL`) dans `.env.example`. + +### Infra +- `VERSION` → 4.9.0 ; `app/main.py` (log + `version=`) ; router `notifications` + `collaboration` + enregistrés ; exclusions CSRF (`/api/notifications`, `/api/comments`) ; le `RateLimitMiddleware` + respecte désormais `settings.rate_limit_enabled`. + +### Tests +- **208 tests verts** (+9 v4.9.0 : table notifications, commentaires inline + mentions, centre de + notifications, préférences, recherche utilisateurs, résolution/suppression, mentions de page). ### Fixed - **Bouton « + Row » (bas du tableau) inopérant** — le code passait la nouvelle ligne en argument `deleteCount` de `Array.splice` au lieu d'utiliser `splice(index, 0, ligne)` : aucune ligne n'était jamais insérée. Corrigé ; les insertions de lignes (menu contextuel aussi) fonctionnent. diff --git a/ROADMAP.md b/ROADMAP.md index b06623d..0ebf5ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -312,12 +312,22 @@ Détails livrés : - [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »). - [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`. -### v4.9.0 — Collaboration -- [ ] **Inline comments** — commentaires sur sélection de texte -- [ ] **@mentions** — notifier un utilisateur → page/commentaire -- [ ] **Email notifications** — changements, mentions +### v4.9.0 — Collaboration ✅ (2026-09-04) +> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**. +> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md) -### v4.10.0 — FlowDeck Agent (Agent IA natif) +- [x] **Inline comments** — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table `comments` étendue (`target_type`/`target_id`/`anchor_block_id`/`anchor_start`/`anchor_end`) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar +- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu) +- [x] **Email notifications** — service SMTP (`app/services/mailer.py` + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config `.env` (`SMTP_*`, `APP_BASE_URL`) ; repli no-op sans SMTP +- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read +- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions) +- [x] **208 tests passent** (+9 v4.9.0) + +### v4.10.0 — FlowDeck Agent (Agent IA natif) ⬜ (0 % implémenté) + +> ⚠️ **État actuel** : documentation de design complète (`docs/Flowdeck_Agent_integration.md`), +> mais **aucun code** : pas de `routers/agent.py`, `services/agent_engine.py`, `llm_client.py`, +> `tool_registry.py`, `context_builder.py`, ni aucune table `agent_*`. **Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026). @@ -392,32 +402,42 @@ app/ - Budget tokens max par conversation (500k tokens) - Timeout 5 minutes par run -### v5.0.0 — Command Palette & Recherche -- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle +### v5.0.0 — Command Palette & Recherche ⬜ (stub uniquement) +> ⚠️ **État actuel** : `Ctrl+K` appelle `openQuickFind()` dans `base.html` qui n'affiche qu'un +> toast « Quick Find — Ctrl+K ». Le raccourci est déjà câblé, la palette elle-même n'existe pas. + +- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier) - [ ] **Quick actions** — navigation, création, commandes +- [ ] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette) -### v5.1.0 — Automations -- [ ] **Database automations** — if-this-then-that -- [ ] **Buttons** — cliquables déclenchant actions +### v5.1.0 — Automations ⬜ (non commencé) -### v5.2.0 — Infrastructure & Polish -> **Objectif** : Qualité de code, design system, backup, CI/CD. -> **Items issus de l'ancien docs/ROADMAP.md (v3.0)** +> 💡 Note : les **webhooks sortants** (`webhook_subscriptions`, CRUD `/workspace/webhooks`, +> `services/webhook_outbound.py`) sont déjà implémentés — bonne base pour les actions automation. + +- [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action) +- [ ] **Buttons** — boutons cliquables déclenchant des actions + +### v5.2.0 — Infrastructure & Polish ⬜ (à prioriser) +> ⚠️ **Priorité recommandée n°1** : les **migrations versionnées** d'abord — la base compte +> déjà 30+ tables créées ad-hoc ; ajouter agent/automations sans versioning va créer une dette ingérable. +> **État actuel** : API publique + tokens **partiellement implémentés** (`routers/public_api.py`, +> test `test_public_api_token`) — à compléter dans la section Sécurité. **Design system** - [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés) - [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts **Sécurité & Utilisateur** -- [ ] **API Tokens** — générer/révoquer des clés API utilisateur +- [ ] **API Tokens** — générer/révoquer des clés API utilisateur *(PARTIEL : `public_api.py` + tokens existent, manque la gestion UI dans Settings)* - [ ] **Sessions actives** — voir et révoquer les sessions - [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet) **Infrastructure** -- [ ] **Migrations versionnées** — Alembic ou table `schema_version` +- [ ] **🥇 Migrations versionnées** — Alembic ou table `schema_version` *(URGENT avant v4.10/v5.x)* - [ ] **Backup automatique** — cron daily → fichier daté - [ ] **Index manquants** — `users.email`, `forge_connections.user_id` -- [ ] **Linting** — ruff (Python), eslint (JS) +- [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)* - [ ] **Tests parallèles** — pytest-xdist - [ ] **Build Docker multi-stage** — optimiser taille d'image @@ -432,10 +452,17 @@ app/ - [ ] Tests des adapters forge (mock HTTP) - [ ] Tests multi-user (permissions croisées) -### v5.3.0 — Database Avancée -- [ ] Inline databases dans n'importe quelle page -- [ ] Templates de database (Project tracker, CRM…) -- [ ] Validation des propriétés (required, unique, min/max) +### v5.3.0 — Database Avancée ⬜ (partiel) +- [ ] **Inline databases dans n'importe quelle page** *(PARTIEL : API `POST /db/inline/api` existe depuis v4.1.0 ; manque l'insertion via slash command `/database` + bloc rendu dans l'éditeur)* +- [ ] **Templates de database prédéfinis** (CRM, Project tracker…) — galerie au clic « New database » *(templates de pages existent, pas de galerie DB)* +- [ ] **Validation des propriétés** (required, unique, min/max) — côté serveur + messages UI + +### v5.4.0 — Expérience éditeur (nouveautés, parité Notion) +- [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes) +- [ ] **Duplicates** — « Duplicate » sur page + collection (menu `...`) +- [ ] **Corbeille globale améliorée** — vue cross-workspace + purge automatique après 30 jours +- [ ] **Historique de version UI** — browser + restaurer une version (table `page_history` existe) +- [ ] **Import** — Markdown/CSV/Notion (complète l'export v4.7.0, facilite la migration d'utilisateurs) --- @@ -445,12 +472,31 @@ app/ - [ ] **SSO/SAML** — enterprise authentication - [ ] **Granular permissions** — page-level, property-level access control - [ ] **Web Clipper** — extension navigateur -- [ ] **API publique** — REST API + webhooks documentés +- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* - [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs - [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages --- +## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) + +| Feature | Fichiers | Note | +|---------|----------|------| +| Webhooks sortants | `services/webhook_outbound.py`, `routers/workspace.py` (`/workspace/webhooks`) | CRUD + dispatch d'événements — base pour automations/API publique | +| API publique + tokens | `routers/public.py` / `public_api.py`, test `test_public_api_token` | À formaliser dans v5.2.0 et documenter pour v6.0.0 | + +--- + +## 🎯 Ordre de priorité recommandé (état 2026-09) + +1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste) +2. **v4.9.0 → Collaboration ✅** (comments + mentions + notifications) — livré 2026-09-04 +3. **v5.0.0 → Command palette + FTS5** — le raccourci Ctrl+K est déjà câblé, petit effort / gros impact +4. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur +5. **v4.10.0 → Agent IA** — gros chantier, démarrer Phase 1 (engine + 3 outils) une fois 1–4 livrés + +--- + ## Résumé des phases ``` @@ -460,8 +506,8 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8.0 ✅ v4.9–4.10 ⬜ v5.x–v6.0 ⬜ -Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collab + Pro + Agent -& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple Agent IA (futur) +v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ✅ v4.8.0 ✅ v4.9.0 ✅ v4.10 ⬜ v5.x–v6.0 ⬜ +Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Bloc Table Collaboration Agent IA Pro + Agent +& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML simple (comments@) (futur) (futur) -*Dernière mise à jour: 2026-09-03 — v4.8.1 Bloc Tableau : + Row / + Col / redimensionnement ✅* +*Dernière mise à jour: 2026-09-04 — Audit complet : statuts réels v4.9–v5.3, bonus détectés (webhooks sortants, API publique), nouvelles v5.4.0 (backlinks, duplicates, corbeille, import), ordre de priorité recommandé* diff --git a/VERSION b/VERSION index 2871567..b617d99 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.8.1 \ No newline at end of file +4.9.0 \ No newline at end of file diff --git a/app/config.py b/app/config.py index 9f84b5b..075eda0 100644 --- a/app/config.py +++ b/app/config.py @@ -49,6 +49,16 @@ class Settings(BaseSettings): sync_interval: int = 60 gitea_cache_ttl: int = 30 + # Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty, + # email notifications are skipped (only in-app notifications are delivered). + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "FlowDeck " + smtp_use_tls: bool = True + app_base_url: str = "http://localhost:8080" + @property def db_path(self) -> Path: if self.database_url == "sqlite:///:memory:": diff --git a/app/db.py b/app/db.py index 4c22990..fdf1eae 100644 --- a/app/db.py +++ b/app/db.py @@ -575,6 +575,71 @@ def init_db(): pass conn.commit() + # ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════ + # Notifications table (mentions, comments, page changes) + conn.execute(""" + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_id INTEGER REFERENCES users(id), + ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page' + title TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + resource_type TEXT NOT NULL DEFAULT 'page', + resource_id INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL DEFAULT '', + is_read INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)" + ) + + # Notification email preferences (JSON: {"comments": true, "mentions": true}) + try: + conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'") + except sqlite3.OperationalError: + pass + + # Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to + # collection_pages, which prevents using page-editor (pages) ids. Rebuild it so + # it can hold page comments with optional inline anchors, while preserving data. + # target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id. + # anchor_block_id = block id; anchor_start/anchor_end = text selection offsets. + _cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()] + if "target_type" not in _cols: + try: + conn.execute(""" + CREATE TABLE comments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER, + user_id INTEGER NOT NULL REFERENCES users(id), + body TEXT NOT NULL DEFAULT '', + parent_id INTEGER, + resolved BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + target_type TEXT NOT NULL DEFAULT 'page', + target_id INTEGER NOT NULL DEFAULT 0, + anchor_block_id TEXT, + anchor_start INTEGER, + anchor_end INTEGER + ) + """) + conn.execute( + """INSERT INTO comments_new + (id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id) + SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, + 'collection_page', COALESCE(page_id, 0) + FROM comments""" + ) + conn.execute("DROP TABLE comments") + conn.execute("ALTER TABLE comments_new RENAME TO comments") + except sqlite3.OperationalError: + pass + conn.commit() + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index 8197425..d956d3b 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,8 @@ from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export +from app.routers.notifications import router as notifications_router +from app.routers.collaboration import router as collaboration_router from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router from app.services.gitea_client import gitea @@ -39,13 +41,13 @@ async def lifespan(_app: FastAPI): (admin_hash,) ) conn.commit() - logger.info("FlowDeck v4.8.1 started on port %d", settings.app_port) + logger.info("FlowDeck v4.9.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="4.8.1", + version="4.9.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -74,6 +76,8 @@ app.include_router(public_api.router) app.include_router(sharing.router) app.include_router(sidebar_config.router) app.include_router(export.router) +app.include_router(notifications_router) +app.include_router(collaboration_router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index c216e30..0f73b53 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/middleware/security.py b/app/middleware/security.py index 8d32843..9c3018f 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -115,6 +115,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path + # Respect the global rate-limit toggle (disabled in tests/local). + from app.config import settings + if not settings.rate_limit_enabled: + return await call_next(request) + # Only rate-limit API routes if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES): return await call_next(request) diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py new file mode 100644 index 0000000..0ebd909 --- /dev/null +++ b/app/routers/collaboration.py @@ -0,0 +1,184 @@ +"""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, Request, HTTPException + +from app.db import get_conn +from app.auth.session import SessionManager +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.""" + user = _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"} \ No newline at end of file diff --git a/app/routers/notifications.py b/app/routers/notifications.py new file mode 100644 index 0000000..0a3a027 --- /dev/null +++ b/app/routers/notifications.py @@ -0,0 +1,126 @@ +"""FlowDeck — Notifications API (v4.9.0 collaboration).""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request, HTTPException + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["notifications"], prefix="/api/notifications") + + +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 + + +@router.get("") +async def list_notifications(request: Request, limit: int = 50): + """List the current user's notifications, newest first.""" + user = _current_user(request) + with get_conn() as conn: + rows = conn.execute( + """SELECT n.*, a.login AS actor_login, a.full_name AS actor_name, + a.avatar_url AS actor_avatar, a.avatar_color AS actor_color + FROM notifications n + LEFT JOIN users a ON n.actor_id = a.id + WHERE n.user_id=? + ORDER BY n.created_at DESC, n.id DESC LIMIT ?""", + (user["id"], limit), + ).fetchall() + unread = conn.execute( + "SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0", + (user["id"],), + ).fetchone()["c"] + return { + "notifications": [dict(r) for r in rows], + "unread": unread, + } + + +@router.get("/unread-count") +async def unread_count(request: Request): + """Unread count for the topbar badge.""" + user = _current_user(request) + with get_conn() as conn: + c = conn.execute( + "SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0", + (user["id"],), + ).fetchone()["c"] + return {"unread": c} + + +@router.post("/read") +async def mark_read(request: Request): + """Mark one notification as read (id) or all (id omitted).""" + user = _current_user(request) + body = await request.json() if request.headers.get("content-type") else {} + nid = body.get("id") + with get_conn() as conn: + if nid: + conn.execute( + "UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", + (nid, user["id"]), + ) + else: + conn.execute( + "UPDATE notifications SET is_read=1 WHERE user_id=?", + (user["id"],), + ) + conn.commit() + return {"status": "ok"} + + +@router.post("/read-all") +async def mark_all_read(request: Request): + """Mark all notifications as read.""" + return await mark_read(request) + + +@router.get("/prefs") +async def get_prefs(request: Request): + """Return the current user's notification email preferences.""" + user = _current_user(request) + from app.services import notifications as notif + return {"prefs": notif.get_user_prefs(user["id"])} + + +@router.post("/prefs") +async def set_prefs(request: Request): + """Update the current user's notification email preferences.""" + user = _current_user(request) + from app.services import notifications as notif + body = await request.json() if request.headers.get("content-type") else {} + prefs = notif.get_user_prefs(user["id"]) + for key in ("comments", "mentions"): + if key in body: + prefs[key] = bool(body[key]) + notif.set_user_prefs(user["id"], prefs) + return {"status": "ok", "prefs": prefs} + + +@router.get("/users/search") +async def search_users(request: Request, q: str = ""): + """User autocomplete for @mentions.""" + user = _current_user(request) + q = (q or "").strip() + with get_conn() as conn: + if q: + like = f"%{q}%" + rows = conn.execute( + """SELECT id, login, full_name, avatar_url, avatar_color + FROM users WHERE login LIKE ? OR full_name LIKE ? + ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""", + (like, like, q, q), + ).fetchall() + else: + rows = conn.execute( + """SELECT id, login, full_name, avatar_url, avatar_color + FROM users ORDER BY login LIMIT 20""" + ).fetchall() + return {"users": [dict(r) for r in rows]} \ No newline at end of file diff --git a/app/services/mailer.py b/app/services/mailer.py new file mode 100644 index 0000000..10f2311 --- /dev/null +++ b/app/services/mailer.py @@ -0,0 +1,86 @@ +"""FlowDeck — Email notifications via SMTP (v4.9.0). + +If SMTP is not configured (smtp_host empty) this is a safe no-op, so the +application works locally out of the box while still logging intent. +""" +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _configured() -> bool: + return bool(settings.smtp_host) + + +def _html_body(body_text: str, cta_url: str = "") -> str: + cta = "" + if cta_url: + cta = ( + '

' + f'Open in FlowDeck →

' + ) + return f"""
+
+
FlowDeck
+

{body_text}

+{cta} +

You received this because your +notifications preferences in FlowDeck allow it.

+
""" + + +def send_email(to_email: str, subject: str, body_text: str, cta_url: str = "") -> bool: + """Send an email. Returns True on success, False if skipped or failed.""" + if not to_email or not _configured(): + return False + try: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = settings.smtp_from + msg["To"] = to_email + msg.set_content(body_text) + msg.add_alternative(_html_body(body_text, cta_url), subtype="html") + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server: + if settings.smtp_use_tls: + server.starttls() + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_password) + server.send_message(msg) + logger.info("Email sent to %s: %s", to_email, subject) + return True + except Exception as e: # never break the request on mail failure + logger.warning("Email send failed to %s: %s", to_email, e) + return False + + +def notify_user( + user_id: int, + subject: str, + body_text: str, + cta_url: str = "", + prefs_key: str = "mentions", +) -> bool: + """Resolve a user's email + preferences and send an email notification.""" + from app.db import get_conn + from app.services import notifications + + with get_conn() as conn: + row = conn.execute("SELECT id, email FROM users WHERE id=?", (user_id,)).fetchone() + if not row or not row["email"]: + return False + prefs = notifications.get_user_prefs(user_id) + if not prefs.get(prefs_key, True): + return False + return send_email(row["email"], subject, body_text, cta_url) \ No newline at end of file diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000..8ad34fc --- /dev/null +++ b/app/services/notifications.py @@ -0,0 +1,147 @@ +"""FlowDeck — Notification service (v4.9.0 collaboration). + +Creates in-app notifications (mentions, comments, page changes) and triggers +email delivery via :mod:`app.services.mailer` when the target user has opted in. +""" +from __future__ import annotations + +import json +import re + +from app.db import get_conn +from app.services import mailer + + +def create_notification( + user_id: int, + actor_id: int | None, + ntype: str, + title: str, + message: str, + resource_type: str = "page", + resource_id: int = 0, + url: str = "", + conn=None, + commit: bool = True, +) -> int | None: + """Insert a notification row. Returns the new id (or None if skipped). + + ``conn`` may be supplied to join an existing transaction (caller controls + commit). Otherwise a dedicated connection is opened and committed. + """ + if not user_id: + return None + if conn is not None: + cur = conn.execute( + """INSERT INTO notifications + (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) + VALUES (?,?,?,?,?,?,?,?)""", + (user_id, actor_id, ntype, title, message, resource_type, resource_id, url), + ) + if commit: + conn.commit() + return cur.lastrowid + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO notifications + (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) + VALUES (?,?,?,?,?,?,?,?)""", + (user_id, actor_id, ntype, title, message, resource_type, resource_id, url), + ) + conn.commit() + return cur.lastrowid + + +_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)") + + +def extract_mentions(text: str) -> list[str]: + """Return the set of @login handles mentioned in ``text`` (lowercase).""" + return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or ""))) + + +def process_mentions( + text: str, + actor_id: int, + ntype: str, + title: str, + message: str, + resource_type: str = "page", + resource_id: int = 0, + url: str = "", + conn=None, +) -> list[int]: + """Create notifications for every user @-mentioned in ``text``. + + Returns the list of mentioned user ids that were notified. + """ + handles = extract_mentions(text) + if not handles: + return [] + notified = [] + if conn is not None: + _do_mentions(conn, handles, actor_id, ntype, title, message, + resource_type, resource_id, url, notified) + return notified + with get_conn() as conn: + _do_mentions(conn, handles, actor_id, ntype, title, message, + resource_type, resource_id, url, notified) + conn.commit() + return notified + + +def _do_mentions(conn, handles, actor_id, ntype, title, message, + resource_type, resource_id, url, notified): + placeholders = ",".join("?" * len(handles)) + rows = conn.execute( + "SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders, + handles, + ).fetchall() + for row in rows: + if row["id"] == actor_id: + continue + create_notification( + row["id"], actor_id, ntype, title, message, + resource_type, resource_id, url, conn=conn, commit=False, + ) + notified.append(row["id"]) + mailer.notify_user( + row["id"], subject=title, body_text=message, cta_url=url or "", + ) + + +def get_user_prefs(user_id: int, conn=None) -> dict: + if conn is not None: + row = conn.execute( + "SELECT notification_prefs FROM users WHERE id=?", (user_id,) + ).fetchone() + else: + with get_conn() as conn: + row = conn.execute( + "SELECT notification_prefs FROM users WHERE id=?", (user_id,) + ).fetchone() + if not row or not row["notification_prefs"]: + return {"comments": True, "mentions": True} + try: + prefs = json.loads(row["notification_prefs"]) + except (TypeError, json.JSONDecodeError): + prefs = {} + return {"comments": bool(prefs.get("comments", True)), + "mentions": bool(prefs.get("mentions", True))} + + +def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict: + if conn is not None: + conn.execute( + "UPDATE users SET notification_prefs=? WHERE id=?", + (json.dumps(prefs), user_id), + ) + conn.commit() + else: + with get_conn() as conn: + conn.execute( + "UPDATE users SET notification_prefs=? WHERE id=?", + (json.dumps(prefs), user_id), + ) + conn.commit() + return prefs \ No newline at end of file diff --git a/app/templates/_header.html b/app/templates/_header.html index 1e1c1b3..1dbedcd 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -136,6 +136,7 @@
+ {% include '_notification_bell.html' %} {{ right_actions|safe if right_actions else '' }}
diff --git a/app/templates/_notification_bell.html b/app/templates/_notification_bell.html new file mode 100644 index 0000000..521aafc --- /dev/null +++ b/app/templates/_notification_bell.html @@ -0,0 +1,131 @@ +{# ── Notification bell + dropdown (v4.9.0) ── + Self-contained Alpine component. Polls unread count, opens a panel of + notifications, marks as read. Only rendered for authenticated users. #} +{% if user and user.get('id') %} + + + +
+
+ Notifications + +
+
+ +
+ You're all caught up 🎉 +
+
Loading…
+
+
+
+ + +{% endif %} \ No newline at end of file diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 16b0ff6..3607b8a 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -417,6 +417,69 @@ style="display: none; position: fixed; z-index: 999" > + + + + + + + +
+
+ Comments + +
+
+
+ No comments yet. Select some text and click 💬 Comment. +
+ +
+
+ +
+ +
+
+
+
{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();}); + }, + showCommentBtn(){ + const s=window.getSelection();if(!s||s.isCollapsed)return; + const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return; + const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null; + if(!bel)return; + const bid=bel.dataset.bid; + const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset); + const start=range.toString().length; + const end=start+s.toString().length; + if(end<=start)return; + this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end}; + const r=s.getRangeAt(0).getBoundingClientRect(); + const btn=document.getElementById('_commentSelBtn');if(!btn)return; + btn.style.display='block'; + const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34; + let x=r.left+r.width/2-bw/2,y=r.top-bh-8; + x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8; + btn.style.left=x+'px';btn.style.top=y+'px'; + }, + hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';}, + // ── @mention autocomplete ── + mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null, + openMention(el){ + const s=window.getSelection();if(!s.rangeCount)return; + const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset; + const before=text.substring(0,pos); + const m=before.match(/@([\w\-\.]*)$/); + if(!m)return; + this.mentionQuery=m[1];this.mentionEl=el; + const r=s.getRangeAt(0).getBoundingClientRect(); + const menu=document.getElementById('_mentionMenu'); + menu.style.display='block'; + menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px'; + menu.style.left=Math.min(r.left,window.innerWidth-260)+'px'; + this.mentionOpen=true;this.loadMentionUsers(); + }, + async loadMentionUsers(){ + const q=this.mentionQuery||''; + try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];} + }, + renderMentionMenu(){ + const menu=document.getElementById('_mentionMenu');if(!menu)return; + let h='';const self=this; + (this.mentionResults||[]).forEach(function(u,idx){ + h+='
' + +'
' + +((u.avatar_url)?'':((u.full_name||u.login||'?').charAt(0).toUpperCase())) + +'
'+u.login+'
'+(u.full_name||'')+'
'; + }); + if(!h)h='
No users found
'; + menu.innerHTML=h; + menu.querySelectorAll('.mention-item').forEach(function(it){ + it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));}); + }); + }, + insertMention(login){ + const el=this.mentionEl;if(!el)return; + const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();} + const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length; + const before=text.substring(0,pos); + const at=before.lastIndexOf('@'); + const full=before.substring(0,at)+'@'+login+' '; + const after=text.substring(pos); + el.textContent=full+after; + const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr); + this.closeMention();this.dirty=true;this.autoSave(); + this.notifyNewMentions(full); + }, + closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;}, + notifyNewMentions(text){ + const self=this; + const handles=[];const re=/@([\w\-\.]+)/g;let mm; + while((mm=re.exec(text)))handles.push(mm[1].toLowerCase()); + const fresh=handles.filter(function(h){return !self._notifiedMentions[h];}); + if(!fresh.length)return; + fetch('/api/pages/'+this.pid+'/mentions',{method:'POST',credentials:'same-origin', + headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()}, + body:JSON.stringify({text:'@'+fresh.join(' @')})}).catch(function(){}); + }, pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();}, autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);}, save(cb){ @@ -711,7 +820,29 @@ }return bl;}, }; } - document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);}); + document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}},80);}); + document.addEventListener('mousedown',()=>{setTimeout(()=>{const ed=document.querySelector('.page-editor-wrapper');if(ed?.__x)ed.__x.$data.hideCommentBtn();},10);}); + document.addEventListener('keyup',(e)=>{ + const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return; + const d=ed.__x.$data;if(!d.mentionOpen){if(e.key!=='@')return;} + const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return; + const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(el))return; + if(d.mentionOpen){ + if(e.key==='Escape'){d.closeMention();return;} + if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();const items=document.querySelectorAll('#_mentionMenu .mention-item');if(!items.length)return;let idx=[].indexOf.call(items,document.querySelector('#_mentionMenu .mention-item.hover'));idx+= (e.key==='ArrowDown'?1:-1);if(idx<0)idx=items.length-1;if(idx>=items.length)idx=0;items.forEach(function(x){x.classList.remove('hover');});items[idx].classList.add('hover');return;} + if(e.key==='Enter'){e.preventDefault();const hover=document.querySelector('#_mentionMenu .mention-item.hover')||document.querySelector('#_mentionMenu .mention-item');if(hover)hover.click();return;} + } + d.openMention(el); + },true); + document.addEventListener('click',(e)=>{ + const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return; + const d=ed.__x.$data;if(!d.mentionOpen)return; + const menu=document.getElementById('_mentionMenu'); + if(menu&&!menu.contains(e.target))d.closeMention(); + }); + var fdStyle=document.createElement('style'); + fdStyle.textContent='#_mentionMenu .mention-item:hover,#_mentionMenu .mention-item.hover{background:rgba(35,131,226,.15);}'; + document.head.appendChild(fdStyle); function blocksToMarkdown(b) { var c = b.content || ''; switch(b.type) { diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index 77c4091..a184603 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -2,7 +2,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %} {% set page_icon = "file" if page.content_format != 'file' else "paperclip" %} {% set page_title = page.title %} -{% set right_actions = 'Edited ▾' %} +{% set right_actions = 'Edited ▾' %} {% include '_header.html' %} {% endblock %} {% block content %} {% include "_page_editor_content.html" %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 4a92d87..19eb7c5 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -220,14 +220,24 @@
Comments
When someone comments on your pages
-
Coming soon
+
+ +
Mentions
When someone @mentions you
-
Coming soon
+
+ +
@@ -517,6 +527,7 @@ document.addEventListener('alpine:init', function() { defaultView: localStorage.getItem('fd_default_view') || 'tree', userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'}, userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }}, + notifPrefs: {comments: true, mentions: true}, // Auth & Integrations authMethod: '{{ auth_method }}', giteaLinked: false, @@ -541,6 +552,23 @@ document.addEventListener('alpine:init', function() { await this.loadTags(); await this.loadGiteaStatus(); await this.loadGithubStatus(); + await this.loadNotifPrefs(); + }, + + async loadNotifPrefs() { + try { + var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'}); + var d = await r.json(); + this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {}); + } catch(e) {} + }, + async saveNotifPrefs() { + try { + await fetch('/api/notifications/prefs', { + method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify(this.notifPrefs) + }); + } catch(e) {} }, async loadTags() { diff --git a/docs/Guide_Complet_Notion_sharing_collaborartion.md b/docs/Guide_Complet_Notion_sharing_collaborartion.md new file mode 100644 index 0000000..81ea3fc --- /dev/null +++ b/docs/Guide_Complet_Notion_sharing_collaborartion.md @@ -0,0 +1,230 @@ +# 📘 Guide d'Implémentation : Fonctionnalités de Partage et Collaboration (Style Notion pour Flowdesk) + +> Sources officielles consultées : [Sharing & permissions](https://www.notion.com/help/sharing-and-permissions), [Comments, mentions & reactions](https://www.notion.com/help/comments-mentions-and-reminders), [Suggested edits](https://www.notion.com/help/suggested-edits), [Create & manage groups](https://www.notion.com/help/create-and-manage-groups), [Who's who in a workspace](https://www.notion.com/help/whos-who-in-a-workspace), [Collaborate in a workspace](https://www.notion.com/help/collaborate-within-a-workspace). + +## 1. Vue d'ensemble des fonctionnalités cibles +Pour reproduire l'expérience de collaboration de Notion, le clone Flowdesk doit intégrer cinq piliers fonctionnels : + +1. **Partage granulaire** : Invitation de membres, d'invités externes (guests, par email), partage avec des groupes, des teamspaces, ou partage public par lien / publication web. +2. **Niveaux d'accès (RBAC)** : `Full access`, `Can edit`, `Can edit content`, `Can create`, `Can comment`, `Can view` — avec sémantique précise (voir §2 et §4). +3. **Permissions au niveau des pages de base de données (Page-Level Access)** : Règles d'accès dynamiques basées sur les propriétés « Personne » ou « Créé par ». +4. **Droits sur les rôles du workspace** : membres, membres restreints, invités (guests), membres temporaires, propriétaires, admins de membres, groupes (dont groupes synchronisés via SCIM). +5. **Collaboration synchrone & asynchrone** : présence en temps réel (avatars), édition simultanée, commentaires (page, bloc, propriété), mentions (@), réactions, suggestions d'édition (suggested edits) et verrouillage de page. + +--- + +## 2. Guide d'utilisation (Flux Utilisateur) +*L'agent IA doit reproduire ces flux interactifs :* + +### 2.1 Initier le partage +1. L'utilisateur clique sur **« Partager »** en haut à droite de la page. +2. La modale propose trois actions : **inviter des personnes**, **copier le lien de la page**, **publier sur le web** (onglet `Publish`, distinct du simple partage par lien). +3. Pour inviter, l'utilisateur tape un nom (membre ou groupe) ou un email d'invité externe. Le système propose une autocomplétion en temps réel. +4. Un menu déroulant à côté de chaque nom permet de choisir le niveau d'accès, puis l'utilisateur clique sur **Inviter**. + +### 2.2 Configurer l'accès général (General access) +Un sélecteur définit la visibilité par défaut de la page : +- **`Only people invited`** : seuls l'utilisateur et les personnes invitées y ont accès. +- **`Everyone at {workspace}`** : tous les membres du workspace y accèdent via la recherche ou le lien — avec option **« Hide in search »** pour masquer la page des résultats de recherche. +- **`Anyone on the web with link`** : toute personne disposant du lien peut y accéder, même sans compte Notion (connexion requise uniquement pour commenter/modifier) — avec option **« Link expires »** pour faire expirer le lien. + +Pour chaque groupe d'accès général, on peut attribuer un niveau d'accès indépendant. + +> **Note (sécurité Enterprise)** : les propriétaires peuvent désactiver les liens publics via `Settings → Security → Disable publishing sites, forms and public links`. + +### 2.3 Demander l'accès (Request access) +- **Aucune page accessible** : en ouvrant une page, l'utilisateur voit un bouton `No access` → envoie une demande, notifie le créateur/éditeur qui peut accepter ou refuser. +- **Accès en lecture/commentaire** : `Share → dropdown de son propre niveau → Request edit access`. La demande est envoyée au créateur de la page. + +### 2.4 Règles de base de données (Page-level access, optionnel) +1. Ouvrir la **base source** (pas une vue liée). +2. `Share` → section **`Page-level access`** → **`Add a new rule`**. +3. Choisir une propriété `Person` ou `Created by`, puis un niveau d'accès → **`Create rule`**. + +Exemple : « Les personnes dans la propriété `Assigné à` peuvent **modifier** leur propre ligne ». Les règles s'appliquent à **toutes les vues** de la base, y compris les **vues liées**. Chaque source de données (data source) d'une base multi-sources peut avoir ses propres règles. + +### 2.5 Arrêter de partager +- Glisser la page vers la section **`Private`** de la sidebar (supprime l'accès de tous les autres). +- Ou `Share` → dropdown de chaque personne/groupe/teamspace → **`Remove`**. + +--- + +## 3. Représentation Visuelle (UI/UX) pour l'Agent IA +*Instructions de conception d'interface pour la génération de code frontend :* + +### Bouton de partage +En haut à droite, dans la barre de titre de la page. Bouton secondaire avec icône de partage (ou libellé « Partager »). + +### Modale de partage (Share Modal) +- Champ de saisie en haut avec placeholder « Inviter des personnes... ». +- Liste verticale des utilisateurs/groupes ayant accès. Chaque ligne : **Avatar + Nom/Email + Dropdown de permission (ex. « Peut modifier » ▼) + Icône X (suppression)**. +- Section **« General access »** avec le sélecteur principal et les options conditionnelles (case « Hide in search », expiration du lien). +- Section **« Page-level access »** (base de données uniquement) listant les règles existantes + bouton « Add a new rule ». +- **Validation en lecture seule** : la modale doit montrer à l'utilisateur courant son niveau d'accès courant, avec la possibilité de « Request edit access ». + +### Indicateurs de présence (Presence Bar) +- Barre horizontale en haut de la page, alignée à droite, affichant les avatars des utilisateurs ayant accès. +- **Avatar plein** : personne actuellement sur la page. +- **Avatar estompé (opacité réduite)** : personne récemment partie. +- **Avatars mobiles** : en collaboration simultanée, les avatars se déplacent **à côté des blocs** que chacun lit/édite. +- **Au survol** : infobulle avec Nom, Email et « Dernière activité il y a X ». +- **Au clic** : la vue défile jusqu'à la position de lecture/édition de la personne ciblée. +- **Historique** : menu `•••` en haut à droite → bas du menu : « Dernière modification par X, il y a Y ». + +### Système de commentaires +- **Discussion de page (top-level)** : au survol du haut de la page, bouton « Add comment ». +- **Commentaires inline** (plusieurs déclencheurs) : sélection de texte → « Comment » ; icône `⋮⋮` à gauche du bloc → « Comment » ; bouton `💬` au survol du bloc ; raccourci `Ctrl/Cmd + Shift + M` ; clic sur un `💬` existant pour répondre. +- **Panneau de commentaires (Comments pane)** : icône `💬` en haut de page (pastille rouge si non-lus). Filtrage par personne / statut (ouverts / résolus). Tri par dernier message. +- **Indicateur de résolution** : ✔️ pour résoudre, `•••` → Éditer / Supprimer, `↪️` pour rouvrir un commentaire résolu. +- **Réactions** : surligner un texte → `🙂` → emoji ; survol d'un commentaire → `🙂` → emoji. +- **Commentaires de base de données** : `💬` associé aux lignes (table/board/gallery) ; `⋮⋮`/`•••` → « Comment » ; commentaires sur les **propriétés** (survol d'une propriété → `💬`). +- **Mentions (@)** : la saisie de « @ » ouvre un menu contextuel (Popper) à trois types : **Personnes/groupes**, **Pages** (lien inline + backlink auto), **Date** (aujourd'hui/demain/hier ou date). +- **Mode Suggestion (Suggested edits)** : activé via `•••` → « Suggest edits ». Bandeau `Suggesting` en haut. Les suggestions (ajout/suppression) apparaissent dans la marge, acceptables (✔️) ou refusables (❌), réactives (emoji) et commentables. + +### Verrouillage de page / base +- **Lock page** (`•••` → `Lock page`) : page en lecture seule pour tous, badge `Locked` dans le breadcrumb. +- **Lock database** : verrouille la structure (vues/propriétés) tout en permettant l'édition des données. +- **Déverrouillage** : `Locked` → `Unlock for me` (déverrouille pour soi uniquement) ou `Unlock for everyone`. + +--- + +## 4. Guide d'Implémentation Technique (Architecture & Données) +*Spécifications pour que l'agent IA génère le backend et la logique métier :* + +### A. Modèle de données (Schéma relationnel simplifié) +```sql +-- Principaux (Utilisateurs et Groupes) +CREATE TABLE principals ( + id UUID PRIMARY KEY, + type VARCHAR(20), -- 'user' | 'group' | 'teamspace' | 'guest' + name VARCHAR(255), + email VARCHAR(255) UNIQUE, -- NULL pour les groupes + workspace_role VARCHAR(20), -- 'member' | 'restricted_member' | 'guest' | + -- 'temporary_member' | 'workspace_owner' | + -- 'membership_admin' | 'organization_owner' + is_scim_managed BOOLEAN DEFAULT false -- groupes synchronisés via identité externe +); + +-- Appartenance utilisateur -> groupe (N:N) +CREATE TABLE group_members ( + group_id UUID REFERENCES principals(id), + member_id UUID REFERENCES principals(id), + PRIMARY KEY (group_id, member_id) +); + +-- Ressources (Pages, Bases de données) +CREATE TABLE resources ( + id UUID PRIMARY KEY, + type VARCHAR(50), -- 'page' | 'database' + parent_id UUID, -- Héritage des permissions + workspace_id UUID, + is_locked BOOLEAN DEFAULT false -- verrou de page +); + +-- Permissions (RBAC avec héritage) +CREATE TABLE permissions ( + id UUID PRIMARY KEY, + resource_id UUID REFERENCES resources(id), + principal_id UUID REFERENCES principals(id), + access_level VARCHAR(20), -- 'full' | 'edit' | 'edit_content' | 'create' | 'comment' | 'view' + is_inherited BOOLEAN DEFAULT false -- true si hérité du parent / teamspace / workspace +); + +-- Règles d'accès au niveau de la page de base de données (Page-Level Access) +CREATE TABLE page_level_rules ( + id UUID PRIMARY KEY, + database_id UUID REFERENCES resources(id), + target_property_name VARCHAR(50), -- propriété 'Person' OU 'Created by' + access_level VARCHAR(20), -- 'edit' | 'comment' | 'view' (etc.) + data_source_id UUID NULL -- en cas de base multi-sources +); + +-- Commentaires +CREATE TABLE comments ( + id UUID PRIMARY KEY, + resource_id UUID REFERENCES resources(id), + block_id UUID NULL, -- NULL = discussion de page ; sinon bloc/propriété ciblé + author_id UUID REFERENCES principals(id), + content TEXT, + thread_id UUID NULL, -- pour les réponses groupées + is_resolved BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Réactions +CREATE TABLE reactions ( + id UUID PRIMARY KEY, + target_type VARCHAR(20), -- 'comment' | 'text' | 'suggestion' + target_id UUID, + author_id UUID REFERENCES principals(id), + emoji VARCHAR(8) +); + +-- Suggestions d'édition (Suggested edits) +CREATE TABLE suggestions ( + id UUID PRIMARY KEY, + resource_id UUID REFERENCES resources(id), + block_id UUID, + author_id UUID REFERENCES principals(id), + operation VARCHAR(10), -- 'insert' | 'delete' + payload TEXT, -- contenu proposé / supprimé + status VARCHAR(20), -- 'open' | 'accepted' | 'rejected' + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### B. Sémantique exacte des niveaux d'accès +| Niveau | Effets | +|---|---| +| `Full access` | Modifier tout le contenu **et** partager la page avec qui l'on veut. | +| `Can edit` | Modifier le contenu, **sans** pouvoir partager. | +| `Can edit content` | **Pages de base de données uniquement** : créer/modifier des pages de la base et leurs propriétés, sans toucher à la structure (propriétés, vues, tris, filtres). | +| `Can create` | **Pages de base de données uniquement** (Business/Enterprise) : créer de nouvelles pages, sans voir/modifier les pages existantes (soumissions de tickets, formulaires...). | +| `Can comment` | Commenter et suggérer des modifications, sans éditer ni partager. | +| `Can view` | Lecture seule. | + +### C. Logique de résolution des permissions +1. Vérifier une permission **explicite** pour l'utilisateur **ou** l'un de ses groupes sur la ressource cible. +2. Si aucune permission explicite, remonter l'arbre (`parent_id`) pour vérifier les permissions **héritées** de la page parente, du teamspace ou du workspace. +3. Pour les bases de données, évaluer dynamiquement les `page_level_rules` : si l'ID de l'utilisateur correspond à la valeur de la propriété `target_property_name` de la ligne, appliquer le niveau d'accès de la règle. +4. **Principe du niveau le plus large** : Notion respecte **toujours le niveau d'accès le plus étendu** accordé à un utilisateur (permission personnelle + groupe + workspace + règle de base). + +> ⚠️ **Piège à implémenter** : une règle `Can view` sur une personne est écrasée si l'utilisateur reçoit « Everyone at workspace → Full access ». L'agent doit sommer toutes les sources d'accès et retenir le maximum (ordre : `full > edit > edit_content > create > comment > view`). + +5. **Sans accès à la base** mais avec une règle de page : l'utilisateur n'accède qu'aux lignes concernées via une **notification** ou une **vue liée** ; il ne peut pas créer de nouvelles pages (il faut un formulaire). + +### D. Collaboration en temps réel (Stack technique recommandée) +- **Synchronisation d'état** : CRDT (**Yjs** ou **Automerge**) pour l'édition simultanée sans verrou (Notion ne verrouille pas un bloc en cours d'édition — le dernier changement l'emporte). +- **Transport** : **WebSockets** (Socket.io, Hocuspocus, ou Liveblocks) pour diffuser les mises à jour de contenu, les événements de présence et les commentaires. +- **Gestion de la présence** : registre en mémoire des `user_id` connectés à un `resource_id`, avec diffusion des avatars actifs et de leur **position de bloc** (curseur/édition) aux clients connectés, à la connexion/déconnexion et périodiquement. + +### E. Endpoints API clés à générer +- `POST /api/resources/:id/share` : ajoute/met à jour une entrée dans `permissions` (invitation membre, invité, groupe). +- `GET /api/resources/:id/access-check` : retourne le niveau d'accès **effectif** (calcul d'héritage + règle la plus large). +- `POST /api/resources/:id/page-level-rules` : crée une règle d'accès par propriété Person/Created by. +- `POST /api/comments` : crée un commentaire (gestion du `block_id`/`thread_id` pour l'inline). +- `POST /api/suggestions` : crée une suggestion d'édition ; `POST /api/suggestions/:id/accept` / `reject`. +- `POST /api/access-requests` : crée une demande d'accès / d'édition. +- `POST /api/resources/:id/lock` : verrouille/déverrouille une page ou la structure d'une base. +- `WS /ws/collaboration?resourceId=:id` : canal WebSocket pour CRDT, présence et commentaires temps réel. + +--- + +## 5. Recommandations spécifiques pour le clone Flowdesk +1. **Adaptation métier** : pour un usage support/tickets, prioriser les **Page-Level Access rules** (`Can create` pour les soumissions, `Can edit` sur la propriété « Assigné à »). C'est le mécanisme qui permet à un client de ne voir/modifier que « son » ticket sans accéder à toute la base. +2. **Sécurité** : chaque requête API (lecture/écriture) doit passer par le middleware de résolution des permissions **avant** tout accès BDD. Ne jamais faire confiance au client. Appliquer le principe du niveau le plus large côté serveur, de façon centralisée. +3. **Émuler le verrouillage** : implémenter `is_locked` au niveau ressource (et structure vs données pour les bases) pour éviter les modifications accidentelles sans révoquer les droits. +4. **Notifier intelligemment** : reproduire les règles Notion — pas de notification si la personne a la page ouverte, email uniquement si Notion est fermé, pas de notification si la personne n'a pas accès à la page mentionnée. +5. **Bibliothèques Frontend suggérées** : composants « headless » **Radix UI** ou **Headless UI** pour la modale de partage, les dropdowns, les infobulles de présence et le menu de mentions. + +--- + +## 6. Glossaire rapide des rôles (Who's who) +- **Member** : personne de l'organisation, facturée sur les plans payants. +- **Restricted member** : accès limité aux teamspaces/pages assignés ; ne peut pas créer de teamspace. +- **Guest** : personne externe, invité page par page ; ne reçoit jamais d'accès workspace-wide et ne peut pas être ajouté à un groupe. +- **Temporary member** : consultant Marketplace avec accès à durée limitée (expiration automatique). +- **Workspace owner** : admin gérant les paramètres, les membres et la suppression du workspace. +- **Membership admin** : (Enterprise) ajoute/retire des membres sans changer les paramètres. +- **Organization owner** : (Enterprise) gère plusieurs workspaces d'une organisation. +- **Group owner** : gère la composition d'un groupe sans être admin du workspace. \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 6ac1e29..e825ba6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,6 +16,7 @@ def client(): os.environ["GITEA_TOKEN"] = "test" os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" from app.main import app from app.db import init_db @@ -3234,4 +3235,184 @@ def test_v472_table_in_pdf(client, monkeypatch, tmp_path): assert pdf[:5] == b"%PDF-" assert len(pdf) > 1000 finally: - _cleanup_src(pid, uid) \ No newline at end of file + _cleanup_src(pid, uid) +# ── v4.9.0: Collaboration — notifications, inline comments, mentions ── + +def _v490_users(conn, n=3, prefix="v490"): + ids = [] + for i in range(n): + login = f"{prefix}{i}" + conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)", + (login, f"User {i}", f"{login}@t.com")) + ids.append(conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]) + conn.commit() + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + return ids + + +def _v490_page(conn, uid, title="Collab Page"): + cur = conn.execute("INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)", + (f"u{uid}", title, "[]", "blocks")) + conn.commit() + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + return cur.lastrowid + + +def test_v490_notifications_table(client): + from app.db import get_conn + with get_conn() as conn: + t = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'").fetchone() + assert t is not None + cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()] + assert "target_type" in cols and "anchor_block_id" in cols + + +def test_v490_create_comment_with_mentions(client): + """Adding an inline comment with @mention creates a notification for the target.""" + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 2) # v4900, v4901 + pid = _v490_page(conn, uids[0]) + targ_id = uids[1] + + session = SessionManager.create_session({"id": uids[0], "login": "v4900", "is_admin": 0}) + cookies = {"flowdeck_session": session} + + r = client.post(f"/api/pages/{pid}/comments", + json={"body": "regarde ca @v4901", "anchor_block_id": "b1", "anchor_start": 0, "anchor_end": 5}, + cookies=cookies) + assert r.status_code == 200 + cid = r.json()["id"] + + r = client.get(f"/api/pages/{pid}/comments", cookies=cookies) + assert r.status_code == 200 + comments = r.json()["comments"] + assert len(comments) == 1 + assert comments[0]["anchor_block_id"] == "b1" + + with get_conn() as conn: + n = conn.execute("SELECT * FROM notifications WHERE user_id=? AND ntype='mention'", (targ_id,)).fetchone() + assert n is not None + assert n["resource_id"] == pid + + +def test_v490_comment_stores_anchor(client): + """A comment with no mentions is stored with its inline anchor.""" + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 1, "v490a") + pid = _v490_page(conn, uids[0]) + author_session = SessionManager.create_session({"id": uids[0], "login": "v490a0", "is_admin": 0}) + r = client.post(f"/api/pages/{pid}/comments", + json={"body": "hello", "anchor_block_id": "b7", "anchor_start": 1, "anchor_end": 3}, + cookies={"flowdeck_session": author_session}) + assert r.status_code == 200 + cid = r.json()["id"] + with get_conn() as conn: + row = conn.execute("SELECT * FROM comments WHERE id=?", (cid,)).fetchone() + assert row["anchor_block_id"] == "b7" + assert row["anchor_start"] == 1 and row["anchor_end"] == 3 + + +def test_v490_notifications_center(client): + from app.db import get_conn + from app.auth.session import SessionManager + from app.services import notifications as notif + with get_conn() as conn: + uids = _v490_users(conn, 1, "v490b") + notif.create_notification(uids[0], None, "mention", "T", "M", "page", 1, "/pages/1") + + session = SessionManager.create_session({"id": uids[0], "login": "v490b0", "is_admin": 0}) + cookies = {"flowdeck_session": session} + + r = client.get("/api/notifications", cookies=cookies) + assert r.status_code == 200 + data = r.json() + assert data["unread"] >= 1 + + r = client.get("/api/notifications/unread-count", cookies=cookies) + assert r.json()["unread"] >= 1 + + r = client.post("/api/notifications/read", json={}, cookies=cookies) + assert r.status_code == 200 + r = client.get("/api/notifications/unread-count", cookies=cookies) + assert r.json()["unread"] == 0 + + +def test_v490_notification_prefs(client): + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 1, "v490c") + session = SessionManager.create_session({"id": uids[0], "login": "v490c0", "is_admin": 0}) + cookies = {"flowdeck_session": session} + + r = client.get("/api/notifications/prefs", cookies=cookies) + assert r.status_code == 200 + assert r.json()["prefs"]["comments"] is True + + r = client.post("/api/notifications/prefs", json={"comments": False}, cookies=cookies) + assert r.status_code == 200 + assert r.json()["prefs"]["comments"] is False + + r = client.get("/api/notifications/prefs", cookies=cookies) + assert r.json()["prefs"]["comments"] is False + + +def test_v490_user_search(client): + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 2, "v490d") + session = SessionManager.create_session({"id": uids[0], "login": "v490d0", "is_admin": 0}) + r = client.get("/api/notifications/users/search?q=v490d", cookies={"flowdeck_session": session}) + assert r.status_code == 200 + assert len(r.json()["users"]) >= 2 + + +def test_v490_resolve_and_delete_comment(client): + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 1, "v490e") + pid = _v490_page(conn, uids[0]) + session = SessionManager.create_session({"id": uids[0], "login": "v490e0", "is_admin": 0}) + cookies = {"flowdeck_session": session} + r = client.post(f"/api/pages/{pid}/comments", json={"body": "a comment"}, cookies=cookies) + cid = r.json()["id"] + + r = client.put(f"/api/comments/{cid}", json={"resolved": True}, cookies=cookies) + assert r.status_code == 200 + r = client.delete(f"/api/comments/{cid}", cookies=cookies) + assert r.status_code == 200 + r = client.get(f"/api/pages/{pid}/comments", cookies=cookies) + assert r.json()["comments"] == [] + + +def test_v490_page_mentions_endpoint(client): + from app.db import get_conn + from app.auth.session import SessionManager + with get_conn() as conn: + uids = _v490_users(conn, 2, "v490f") + pid = _v490_page(conn, uids[0]) + tid = uids[1] + session = SessionManager.create_session({"id": uids[0], "login": "v490f0", "is_admin": 0}) + r = client.post(f"/api/pages/{pid}/mentions", json={"text": "hey @v490f1"}, cookies={"flowdeck_session": session}) + assert r.status_code == 200 + assert r.json()["mentioned"] == [tid] + with get_conn() as conn: + assert conn.execute("SELECT COUNT(*) c FROM notifications WHERE user_id=? AND ntype='mention'", (tid,)).fetchone()["c"] >= 1 + + +def test_v490_notifications_require_auth(client): + r = client.get("/api/notifications") + assert r.status_code == 401 +