feat(collab): v4.9.0 Collaboration - commentaires inline, mentions @, notifications in-app + email
This commit is contained in:
@@ -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
@@ -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
@@ -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é*
|
||||
|
||||
@@ -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:":
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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]}
|
||||
@@ -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 →</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)
|
||||
@@ -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
|
||||
@@ -136,6 +136,7 @@
|
||||
</div>
|
||||
|
||||
<div class="topbar-right header-actions">
|
||||
{% include '_notification_bell.html' %}
|
||||
{{ right_actions|safe if right_actions else '' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user