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
+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")