feat(sharing): pending-state invites, notification bell, sidebar polish

Shares used to activate instantly on the owner's side with no notice to
the recipient. Introduce a pending/accepted lifecycle so a recipient
gets a bell notification on login and explicitly Accept or Decline
before the shared item lands in their sidebar.

Backend
- Migration 0014 adds `status` + `accepted_at` to heap_shares and
  folder_shares; pre-existing rows are backfilled to 'accepted' so
  nothing disappears from anyone's current sidebar. One-migration trick:
  server_default 'accepted' during add_column, then strip so new inserts
  fall through to the Python model default 'pending'.
- New recipient-only endpoints: POST /sharing/{heaps|folders}/{id}/accept
  (idempotent) and /decline (hard delete, so re-invites are clean).
- New GET /sharing/pending returning {heaps, folders} of outstanding
  invites with target_name + owner_username + permission.
- list_shared_{heaps,folders} now filter to status='accepted' and carry
  share_id so the recipient can Leave without a second lookup.
- ShareResponse exposes status so the owner sees pending invites.

Frontend
- NotificationBell lives in the LeftSidebar user row: a Popover
  triggered by Bell with a count badge. Each row shows owner avatar,
  "{owner} shared {heap|folder} {name}" with a permission subtitle,
  and Accept / Decline inline. Polls /sharing/pending every 60s.
- Shared Avatar helper extracted to sharing/Avatar.tsx — used by
  ShareDialog, NotificationBell, and the sidebar shared rows so one
  user's identity colour is stable everywhere.
- Sidebar shared-row polish: owner avatar bubble + Eye/Pencil
  permission icon (was uppercase pill). Right-click opens a context
  menu with Open / Leave; Leave calls the existing recipient-revoke
  DELETE and invalidates the shared-{heaps,folders} query.
- ShareDialog shows an amber "Invited" pill next to pending recipients.
- New shadcn context-menu primitive (radix dep already installed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 23:17:53 +02:00
parent 11343c17dc
commit 319be20389
11 changed files with 845 additions and 107 deletions

View File

@@ -39,11 +39,16 @@ class ShareResponse(BaseModel):
shared_with_id: str
shared_with_username: str
permission: str
status: str # 'pending' | 'accepted'
created_at: str
class SharedHeapResponse(BaseModel):
# `id` is the heap id (used for navigation). `share_id` is the
# heap_shares row id, needed so the recipient can "Leave" via the
# existing DELETE endpoint without a separate lookup.
id: str
share_id: str
name: str
owner_username: str
permission: str
@@ -52,6 +57,7 @@ class SharedHeapResponse(BaseModel):
class SharedFolderResponse(BaseModel):
id: str
share_id: str
name: str
folder_type: str
owner_username: str
@@ -59,6 +65,22 @@ class SharedFolderResponse(BaseModel):
photo_count: int
class PendingInvite(BaseModel):
"""A share that exists in the DB but hasn't been accepted yet. Powers
the notification bell in the left-sidebar user section."""
share_id: str
target_id: str # heap id or folder id
target_name: str
owner_username: str
permission: str
created_at: str
class PendingInvitesResponse(BaseModel):
heaps: list[PendingInvite]
folders: list[PendingInvite]
class ShareableUser(BaseModel):
id: str
username: str
@@ -87,6 +109,69 @@ async def list_shareable_users(
]
# ── Pending invites (recipient-facing, cross-type) ───────────────────────
@router.get("/pending", response_model=PendingInvitesResponse)
async def list_pending_invites(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Every share targeting the current user that's still waiting on
them to accept. Feeds the notification bell in the sidebar."""
heap_rows = (await db.execute(
select(HeapShare, Heap, User)
.join(Heap, HeapShare.heap_id == Heap.id)
.join(User, HeapShare.owner_id == User.id)
.where(HeapShare.shared_with_id == current_user.id)
.where(HeapShare.status == "pending")
)).all()
heaps = [
PendingInvite(
share_id=share.id,
target_id=heap.id,
target_name=heap.name,
owner_username=owner.username,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, heap, owner in heap_rows
]
folder_rows = (await db.execute(
select(FolderShare, User)
.join(User, FolderShare.owner_id == User.id)
.where(FolderShare.shared_with_id == current_user.id)
.where(FolderShare.status == "pending")
)).all()
folders: list[PendingInvite] = []
for share, owner in folder_rows:
if share.folder_type == "source_root":
entity = (await db.execute(
select(SourceRoot).where(SourceRoot.id == share.folder_id)
)).scalar_one_or_none()
else:
entity = (await db.execute(
select(Folder).where(Folder.id == share.folder_id)
)).scalar_one_or_none()
# If the underlying folder was deleted while an invite was
# still pending, just skip — the share is effectively orphaned
# and the owner's revoke path will clean it up.
if entity is None:
continue
folders.append(PendingInvite(
share_id=share.id,
target_id=share.folder_id,
target_name=entity.name,
owner_username=owner.username,
permission=share.permission,
created_at=share.created_at.isoformat() if share.created_at else "",
))
return PendingInvitesResponse(heaps=heaps, folders=folders)
# ── Heap sharing ─────────────────────────────────────────────────────────
@router.get("/heaps/shared-with-me")
@@ -94,12 +179,14 @@ async def list_shared_heaps(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all heaps that have been shared with the current user."""
"""List all accepted heap shares for the current user. Pending
invites are hidden here and surfaced via /sharing/pending instead."""
result = await db.execute(
select(HeapShare, Heap, User)
.join(Heap, HeapShare.heap_id == Heap.id)
.join(User, HeapShare.owner_id == User.id)
.where(HeapShare.shared_with_id == current_user.id)
.where(HeapShare.status == "accepted")
)
rows = result.all()
@@ -115,6 +202,7 @@ async def list_shared_heaps(
items.append(SharedHeapResponse(
id=heap.id,
share_id=share.id,
name=heap.name,
owner_username=owner.username,
permission=share.permission,
@@ -143,6 +231,7 @@ async def list_heap_shares(
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
@@ -186,6 +275,54 @@ async def share_heap(
return {"status": "shared", "share_id": share.id}
@router.post("/heaps/{heap_id}/accept", status_code=200)
async def accept_heap_share(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Recipient accepts a pending heap invite. Idempotent — if the
share is already accepted, returns 200 anyway so double-clicks in
the notification popover are harmless."""
result = await db.execute(
select(HeapShare).where(
HeapShare.heap_id == heap_id,
HeapShare.shared_with_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Invite not found")
if share.status != "accepted":
share.status = "accepted"
share.accepted_at = func.now()
await db.commit()
return {"status": "accepted"}
@router.post("/heaps/{heap_id}/decline", status_code=200)
async def decline_heap_share(
heap_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Recipient declines a pending heap invite. The share row is
deleted — there's no separate 'declined' status. A re-invite just
creates a fresh pending row."""
result = await db.execute(
select(HeapShare).where(
HeapShare.heap_id == heap_id,
HeapShare.shared_with_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Invite not found")
await db.delete(share)
await db.commit()
return {"status": "declined"}
@router.delete("/heaps/{heap_id}/{share_id}", status_code=204)
async def revoke_heap_share(
heap_id: str,
@@ -217,11 +354,14 @@ async def list_shared_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all folders/source roots shared with the current user."""
"""List all accepted folder/source-root shares for the current
user. Pending invites are hidden here and surfaced via
/sharing/pending instead."""
result = await db.execute(
select(FolderShare, User)
.join(User, FolderShare.owner_id == User.id)
.where(FolderShare.shared_with_id == current_user.id)
.where(FolderShare.status == "accepted")
)
rows = result.all()
@@ -270,6 +410,7 @@ async def list_shared_folders(
count = count_result.scalar() or 0
items.append(SharedFolderResponse(
id=share.folder_id,
share_id=share.id,
name=name,
folder_type=share.folder_type,
owner_username=owner.username,
@@ -314,6 +455,7 @@ async def list_folder_shares(
shared_with_id=user.id,
shared_with_username=user.username,
permission=share.permission,
status=share.status,
created_at=share.created_at.isoformat() if share.created_at else "",
)
for share, user in result.all()
@@ -370,6 +512,50 @@ async def share_folder(
return {"status": "shared", "share_id": share.id}
@router.post("/folders/{folder_id}/accept", status_code=200)
async def accept_folder_share(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Recipient accepts a pending folder invite. Idempotent."""
result = await db.execute(
select(FolderShare).where(
FolderShare.folder_id == folder_id,
FolderShare.shared_with_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Invite not found")
if share.status != "accepted":
share.status = "accepted"
share.accepted_at = func.now()
await db.commit()
return {"status": "accepted"}
@router.post("/folders/{folder_id}/decline", status_code=200)
async def decline_folder_share(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Recipient declines a pending folder invite. Row is deleted."""
result = await db.execute(
select(FolderShare).where(
FolderShare.folder_id == folder_id,
FolderShare.shared_with_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Invite not found")
await db.delete(share)
await db.commit()
return {"status": "declined"}
@router.delete("/folders/{folder_id}/{share_id}", status_code=204)
async def revoke_folder_share(
folder_id: str,