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>
63 lines
2.6 KiB
Python
63 lines
2.6 KiB
Python
"""
|
|
Sharing models — cross-user access to heaps and folders.
|
|
|
|
HeapShare grants another user read or read+write access to a heap.
|
|
FolderShare does the same for a folder (or source root).
|
|
"""
|
|
import uuid
|
|
|
|
from sqlalchemy import (
|
|
Column, DateTime, ForeignKey, Index, String, UniqueConstraint, func,
|
|
)
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class HeapShare(Base):
|
|
__tablename__ = "heap_shares"
|
|
|
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
heap_id = Column(
|
|
String, ForeignKey("heaps.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
# Denormalized from heap.user_id for fast "shares I own" lookups.
|
|
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
|
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
|
|
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
|
|
# Lifecycle: 'pending' while the recipient hasn't acted, 'accepted'
|
|
# once they've Accept'd in the notification bell. Decline deletes the
|
|
# row outright — see migration 0014 for the backfill of pre-existing
|
|
# rows to 'accepted' so nothing vanishes from existing sidebars.
|
|
status = Column(String, nullable=False, default="pending")
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
accepted_at = Column(DateTime, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("heap_id", "shared_with_id", name="uq_heap_share"),
|
|
Index("ix_heap_shares_shared_with", "shared_with_id"),
|
|
Index("ix_heap_shares_heap_id", "heap_id"),
|
|
Index("ix_heap_shares_shared_with_status", "shared_with_id", "status"),
|
|
)
|
|
|
|
|
|
class FolderShare(Base):
|
|
__tablename__ = "folder_shares"
|
|
|
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
# Can reference either a Folder.id or a SourceRoot.id.
|
|
folder_id = Column(String, nullable=False)
|
|
folder_type = Column(String, nullable=False, default="folder") # 'folder' | 'source_root'
|
|
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
|
shared_with_id = Column(String, ForeignKey("users.id"), nullable=False)
|
|
permission = Column(String, nullable=False, default="read") # 'read' | 'write'
|
|
status = Column(String, nullable=False, default="pending")
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
accepted_at = Column(DateTime, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("folder_id", "shared_with_id", name="uq_folder_share"),
|
|
Index("ix_folder_shares_shared_with", "shared_with_id"),
|
|
Index("ix_folder_shares_folder_id", "folder_id"),
|
|
Index("ix_folder_shares_shared_with_status", "shared_with_id", "status"),
|
|
)
|