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:
@@ -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})
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]})
|
||||
Reference in New Issue
Block a user