Files
FabledScribe/src/fabledassistant/services/sharing.py
T
bvandeusen c7ef709633 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>
2026-03-11 15:37:00 -04:00

265 lines
9.5 KiB
Python

"""Sharing service — create/manage project and note shares."""
import logging
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.group import Group, GroupMembership
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.models.share import NoteShare, ProjectShare
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
async def share_project(
acting_user_id: int,
project_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
from fabledassistant.services.access import can_admin_project
if not await can_admin_project(acting_user_id, project_id):
return None
async with async_session() as session:
share = ProjectShare(
project_id=project_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_project_share(
acting_user_id: int, share_id: int, permission: str
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return None
from fabledassistant.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_project_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return False
from fabledassistant.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return False
await session.delete(share)
await session.commit()
return True
async def list_project_shares(project_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(ProjectShare).where(ProjectShare.project_id == project_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
# ---------------------------------------------------------------------------
# Note shares
# ---------------------------------------------------------------------------
async def share_note(
acting_user_id: int,
note_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
from fabledassistant.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, note_id)
if perm not in ("admin", "owner"):
return None
async with async_session() as session:
share = NoteShare(
note_id=note_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_note_share(
acting_user_id: int, share_id: int, permission: str
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return None
from fabledassistant.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_note_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return False
from fabledassistant.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return False
await session.delete(share)
await session.commit()
return True
async def list_note_shares(note_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(NoteShare).where(NoteShare.note_id == note_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
async def list_shared_with_me(user_id: int) -> dict:
"""All projects and notes shared with the user (directly or via group)."""
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
# --- Projects ---
proj_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
proj_group: list[ProjectShare] = []
if user_group_ids:
proj_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_projects: dict[int, str] = {}
for share in list(proj_direct) + list(proj_group):
prev = seen_projects.get(share.project_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_projects[share.project_id] = share.permission
projects = []
for pid, perm in seen_projects.items():
proj = await session.get(Project, pid)
if proj:
owner = await session.get(User, proj.user_id)
projects.append({
"id": proj.id,
"title": proj.title,
"description": proj.description,
"status": proj.status,
"color": proj.color,
"updated_at": proj.updated_at.isoformat(),
"owner_username": owner.username if owner else None,
"permission": perm,
})
# --- Notes / Tasks ---
note_direct = (await session.execute(
select(NoteShare).where(NoteShare.shared_with_user_id == user_id)
)).scalars().all()
note_group: list[NoteShare] = []
if user_group_ids:
note_group = (await session.execute(
select(NoteShare).where(
NoteShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_notes: dict[int, str] = {}
for share in list(note_direct) + list(note_group):
prev = seen_notes.get(share.note_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_notes[share.note_id] = share.permission
notes = []
for nid, perm in seen_notes.items():
note = await session.get(Note, nid)
if note:
owner = await session.get(User, note.user_id)
notes.append({
"id": note.id,
"title": note.title,
"is_task": note.is_task,
"project_id": note.project_id,
"updated_at": note.updated_at.isoformat(),
"owner_username": owner.username if owner else None,
"permission": perm,
})
return {"projects": projects, "notes": notes}