feat(collab): v4.9.0 Collaboration - commentaires inline, mentions @, notifications in-app + email
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-04 23:40:21 -04:00
parent 23df10732b
commit b60cc8a7c6
21 changed files with 1515 additions and 35 deletions
+10
View File
@@ -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 <[email protected]>
SMTP_USE_TLS=true
APP_BASE_URL=http://localhost:8080
+33 -1
View File
@@ -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.
+71 -25
View File
@@ -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é*
+1 -1
View File
@@ -1 +1 @@
4.8.1
4.9.0
+10
View File
@@ -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 <[email protected]>"
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+65
View File
@@ -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():
+6 -2
View File
@@ -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")
+1 -1
View File
@@ -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
+5
View File
@@ -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)
+184
View File
@@ -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"}
+126
View File
@@ -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]}
+86
View File
@@ -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 = (
'<p style="margin:24px 0 0;">'
f'<a href="{cta_url}" '
'style="background:#2383E2;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:8px;display:inline-block;'
'font-weight:600;">Open in FlowDeck &rarr;</a></p>'
)
return f"""<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;
background:#191919;color:#e0e0e0;padding:32px;">
<div style="max-width:520px;margin:0 auto;background:#252525;border:1px solid #333;
border-radius:12px;padding:24px;">
<div style="font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">FlowDeck</div>
<p style="color:#e0e0e0;line-height:1.6;white-space:pre-wrap;">{body_text}</p>
{cta}
<p style="margin-top:24px;font-size:12px;color:#999;">You received this because your
notifications preferences in FlowDeck allow it.</p>
</div></div>"""
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)
+147
View File
@@ -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
+1
View File
@@ -136,6 +136,7 @@
</div>
<div class="topbar-right header-actions">
{% include '_notification_bell.html' %}
{{ right_actions|safe if right_actions else '' }}
</div>
</header>
+131
View File
@@ -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') %}
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
@click.outside="open=false" style="position:relative;display:inline-flex;">
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
{{ fd_icon("bell", 16) }}
<span x-show="unread > 0" x-cloak
class="fd-notif-badge"
x-text="unread > 99 ? '99+' : unread"
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
</button>
<div x-show="open" x-cloak x-transition
class="fd-notif-panel"
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
<div class="fd-notif-header"
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Notifications</span>
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
style="font-size:12px;cursor:pointer;">Mark all read</button>
</div>
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
<template x-for="n in items" :key="n.id">
<a :href="n.url || '#'" @click.prevent="openItem(n)"
class="fd-notif-item"
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:14px;color:#fff;"
:style="{ background: n.actor_color || '#3A3A3A' }">
<template x-if="n.actor_avatar">
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
</template>
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
x-text="n.message"></div>
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
</div>
</a>
</template>
<div x-show="!loading && items.length === 0"
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
You're all caught up 🎉
</div>
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
</div>
</div>
</span>
<script>
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
Alpine.data('fdNotifications', function () {
return {
open: false, items: [], unread: 0, loading: false, _timer: null,
init() {
this.load();
var self = this;
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
},
toggle() { this.open = !this.open; if (this.open) this.load(); },
timeAgo(s) {
if (!s) return '';
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
if (isNaN(t.getTime())) t = new Date(s);
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
csrf() {
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
},
refreshCount() {
var self = this;
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) { self.unread = d.unread || 0; })
.catch(function () {});
},
load() {
var self = this;
this.loading = true;
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
self.items = d.notifications || [];
self.unread = d.unread || 0;
self.loading = false;
})
.catch(function () { self.loading = false; });
},
openItem(n) {
if (!n.is_read) {
var self = this;
fetch('/api/notifications/read', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
body: JSON.stringify({ id: n.id })
}).then(function () { self.refreshCount(); self.load(); });
}
if (n.url) window.location.href = n.url;
this.open = false;
},
markAllRead() {
var self = this;
fetch('/api/notifications/read', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
body: JSON.stringify({})
}).then(function () { self.refreshCount(); self.load(); });
}
};
});
});
</script>
{% endif %}
+63
View File
@@ -417,6 +417,69 @@
style="display: none; position: fixed; z-index: 999"
></div>
<!-- ═══════════ @mention autocomplete (v4.9.0) ═══════════ -->
<div id="_mentionMenu" class="mention-menu"
style="display:none;position:fixed;z-index:1200;min-width:240px;max-height:280px;overflow-y:auto;
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
box-shadow:var(--shadow-modal);padding:6px;"></div>
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
<button type="button" id="_commentSelBtn"
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="window.E && window.E.commentOnSelection()">
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
<div class="comments-drawer-header"
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Comments</span>
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
<div class="comment-thread" style="margin-bottom:18px;"
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
</div>
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
</div>
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
@click="deleteComment(c.id)">🗑 Delete</button>
</div>
</div>
</template>
</div>
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
<textarea x-model="commentDraft" rows="2"
placeholder="Add a comment... Use @ to mention someone."
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
</div>
</div>
</div>
<div
class="format-toolbar"
x-show="fmt.open"
+132 -1
View File
@@ -298,6 +298,11 @@
pageUrl:window.location.href,publishedUrl:'',
inviteEmail:'',invitePermission:'editor',accessList:[],
toastVisible:false,toastMsg:'',
// ── v4.9.0: Collaboration — comments & mentions ──
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
currentUserId:{{ user.get('id', 0) if user else 0 }},
_commentSel:{range:null,bid:null,start:0,end:0},
_notifiedMentions:{},
get timeAgo() {
if (!this.updatedAt) return 'just now';
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
@@ -679,6 +684,110 @@
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
csrfTok(){return (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'';},
async addPageComment(){
const text=(this.commentDraft||'').trim();if(!text)return;
const self=this;const sel=this._commentSel;
try{
const r=await fetch('/api/pages/'+this.pid+'/comments',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
const d=await r.json();
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
else{this.showToast(d.detail||'Failed to add comment');}
}catch(e){this.showToast('Failed to add comment');}
},
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
commentOnSelection(){
const sel=this._commentSel;if(!sel.range)return;
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
this.$nextTick(()=>{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+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
});
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
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) {
+1 -1
View File
@@ -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 = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+30 -2
View File
@@ -220,14 +220,24 @@
<div class="setting-label">Comments</div>
<div class="setting-desc">When someone comments on your pages</div>
</div>
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.comments" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Mentions</div>
<div class="setting-desc">When someone @mentions you</div>
</div>
<div class="setting-control"><span style="font-size:12px;color:var(--text-dim);">Coming soon</span></div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.mentions" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
@@ -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() {
@@ -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.
+182 -1
View File
@@ -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)
_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