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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user