Multi-user sharing, groups, and in-app notifications

Backend:
- Alembic migration 0025: groups, group_memberships, project_shares,
  note_shares, notifications tables
- models: Group, GroupMembership, ProjectShare, NoteShare, Notification
- services/access.py: permission resolution (viewer/editor/admin/owner)
- services/groups.py + routes/groups.py: full group CRUD + membership
- services/sharing.py + routes/shares.py: project/note sharing API
- services/notifications.py: in-app notification create/list/mark-read
- routes/in_app_notifications.py: GET/POST notification endpoints
- routes/users.py: user search endpoint
- services/projects.py + services/notes.py: *_for_user variants
- routes updated to use *_for_user on get/list; adds permission field
- app.py: register all new blueprints

Frontend:
- api/client.ts: ShareEntry, GroupEntry, NotificationEntry types + helpers
- stores/notifications.ts: Pinia store with polling
- NotificationBell.vue: bell icon + badge + dropdown toggle + 60s poll
- NotificationsPanel.vue: unread notification list with mark-all-read
- ShareDialog.vue: teleport modal for sharing projects/notes with users/groups
- SharedWithMeView.vue: /shared route listing shared projects and notes
- AppHeader: NotificationBell, Shared nav link
- ProjectView, NoteViewerView, TaskViewerView: Share button + ShareDialog
- SettingsView: Groups admin tab with create/delete groups + member mgmt
- router: /shared route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:37:00 -04:00
parent 878b149f4d
commit c7ef709633
31 changed files with 3147 additions and 16 deletions
+112
View File
@@ -0,0 +1,112 @@
import asyncio
from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services import groups as group_svc
from fabledassistant.services.notifications import notify_group_added
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@groups_bp.route("", methods=["GET"])
@login_required
async def list_groups():
uid = get_current_user_id()
return jsonify({"groups": await group_svc.list_groups(uid)})
@groups_bp.route("", methods=["POST"])
@login_required
async def create_group():
uid = get_current_user_id()
data = await request.get_json()
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "Name is required"}), 400
try:
group = await group_svc.create_group(uid, name, data.get("description"))
except Exception:
return jsonify({"error": "Group name already taken"}), 409
return jsonify(group.to_dict()), 201
@groups_bp.route("/<int:group_id>", methods=["GET"])
@login_required
async def get_group(group_id: int):
group = await group_svc.get_group(group_id)
if not group:
return jsonify({"error": "Not found"}), 404
members = await group_svc.list_members(group_id)
return jsonify({**group.to_dict(), "members": members})
@groups_bp.route("/<int:group_id>", methods=["PATCH"])
@login_required
async def update_group(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
updates = {k: v for k, v in data.items() if k in ("name", "description")}
group = await group_svc.update_group(uid, group_id, g.user.role == "admin", **updates)
if not group:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(group.to_dict())
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
@login_required
async def delete_group(group_id: int):
uid = get_current_user_id()
deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin")
if not deleted:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
@groups_bp.route("/<int:group_id>/members", methods=["GET"])
@login_required
async def list_members(group_id: int):
members = await group_svc.list_members(group_id)
return jsonify({"members": members})
@groups_bp.route("/<int:group_id>/members", methods=["POST"])
@login_required
async def add_member(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
target_uid = data.get("user_id")
role = data.get("role", "member")
if not target_uid:
return jsonify({"error": "user_id required"}), 400
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin")
if not membership:
return jsonify({"error": "Forbidden, group not found, or user already a member"}), 403
asyncio.create_task(notify_group_added(group_id, role, uid, target_uid))
return jsonify(membership.to_dict()), 201
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["PATCH"])
@login_required
async def update_member(group_id: int, target_uid: int):
uid = get_current_user_id()
data = await request.get_json()
role = data.get("role")
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin")
if not m:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(m.to_dict())
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["DELETE"])
@login_required
async def remove_member(group_id: int, target_uid: int):
uid = get_current_user_id()
removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin")
if not removed:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
@@ -0,0 +1,44 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services.notifications import (
list_in_app_notifications,
mark_all_notifications_read,
mark_notification_read,
unread_notification_count,
)
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
@notifications_bp.route("", methods=["GET"])
@login_required
async def list_notifications():
uid = get_current_user_id()
all_flag = request.args.get("all", "false").lower() == "true"
items = await list_in_app_notifications(uid, unread_only=not all_flag)
return jsonify({"notifications": items})
@notifications_bp.route("/count", methods=["GET"])
@login_required
async def get_count():
uid = get_current_user_id()
return jsonify({"count": await unread_notification_count(uid)})
@notifications_bp.route("/<int:notif_id>/read", methods=["POST"])
@login_required
async def mark_read(notif_id: int):
uid = get_current_user_id()
if not await mark_notification_read(uid, notif_id):
return jsonify({"error": "Not found"}), 404
return jsonify({"status": "ok"})
@notifications_bp.route("/read-all", methods=["POST"])
@login_required
async def mark_all_read():
uid = get_current_user_id()
count = await mark_all_notifications_read(uid)
return jsonify({"status": "ok", "marked": count})
+7 -3
View File
@@ -26,6 +26,7 @@ from fabledassistant.services.notes import (
get_backlinks,
get_note,
get_note_by_title,
get_note_for_user,
get_or_create_note_by_title,
list_notes,
update_note,
@@ -189,10 +190,13 @@ async def resolve_title_route():
@login_required
async def get_note_route(note_id: int):
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
return jsonify(note.to_dict())
note, permission = result
data = note.to_dict()
data["permission"] = permission
return jsonify(data)
@notes_bp.route("/<int:note_id>", methods=["PUT"])
+14 -8
View File
@@ -11,8 +11,9 @@ from fabledassistant.services.projects import (
create_project,
delete_project,
get_project,
get_project_for_user,
get_project_summary,
list_projects,
list_projects_for_user,
update_project,
)
@@ -26,8 +27,8 @@ projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
projects = await list_projects(uid, status=status)
return jsonify({"projects": [p.to_dict() for p in projects]})
projects = await list_projects_for_user(uid, status=status)
return jsonify({"projects": projects})
@projects_bp.route("", methods=["POST"])
@@ -51,12 +52,16 @@ async def create_project_route():
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
summary = await get_project_summary(uid, project_id)
project, permission = result
# Summary uses the project owner's uid for stats when viewer is not the owner
owner_uid = project.user_id or uid
summary = await get_project_summary(owner_uid, project_id)
data = project.to_dict()
data["summary"] = summary
data["permission"] = permission
return jsonify(data)
@@ -87,9 +92,10 @@ async def delete_project_route(project_id: int):
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
+128
View File
@@ -0,0 +1,128 @@
import asyncio
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services import sharing as share_svc
from fabledassistant.services.access import can_admin_project, can_write_note
from fabledassistant.services.notifications import notify_note_shared, notify_project_shared
from fabledassistant.services.sharing import list_shared_with_me
shares_bp = Blueprint("shares", __name__)
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["GET"])
@login_required
async def list_project_shares(project_id: int):
uid = get_current_user_id()
if not await can_admin_project(uid, project_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_project_shares(project_id)})
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["POST"])
@login_required
async def create_project_share(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_project(
uid, project_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_project_shared(
project_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_project_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_project_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Note / task shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["GET"])
@login_required
async def list_note_shares(note_id: int):
uid = get_current_user_id()
if not await can_write_note(uid, note_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_note_shares(note_id)})
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["POST"])
@login_required
async def create_note_share(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_note(
uid, note_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_note_shared(
note_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_note_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_note_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
@shares_bp.route("/api/shared-with-me", methods=["GET"])
@login_required
async def shared_with_me():
uid = get_current_user_id()
return jsonify(await list_shared_with_me(uid))
+5 -2
View File
@@ -7,6 +7,7 @@ from fabledassistant.services.notes import (
create_note,
delete_note,
get_note,
get_note_for_user,
list_notes,
update_note,
)
@@ -99,10 +100,12 @@ async def create_task_route():
@login_required
async def get_task_route(task_id: int):
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, permission = result
data = task.to_dict()
data["permission"] = permission
if task.parent_id:
parent = await get_note(uid, task.parent_id)
data["parent_title"] = parent.title if parent else None
+29
View File
@@ -0,0 +1,29 @@
from quart import Blueprint, jsonify, request
from sqlalchemy import or_, select
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import async_session
from fabledassistant.models.user import User
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@users_bp.route("/search", methods=["GET"])
@login_required
async def search_users():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if len(q) < 2:
return jsonify({"users": []})
like = f"{q}%"
async with async_session() as session:
users = (await session.execute(
select(User).where(
User.id != uid,
or_(User.username.ilike(like), User.email.ilike(like)),
).limit(10)
)).scalars().all()
return jsonify({"users": [
{"id": u.id, "username": u.username, "email": u.email}
for u in users
]})