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:
67
backend/alembic/versions/0014_share_status.py
Normal file
67
backend/alembic/versions/0014_share_status.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Add status + accepted_at to heap_shares and folder_shares
|
||||
|
||||
Revision ID: 0014_share_status
|
||||
Revises: 0013_drop_old_ct
|
||||
Create Date: 2026-04-21
|
||||
|
||||
Shares used to activate instantly on the owner's side. We now want a
|
||||
pending/accepted lifecycle so the recipient gets a notification bell and
|
||||
chooses to accept or decline before the shared item shows up in their
|
||||
sidebar.
|
||||
|
||||
Backfill note: every pre-existing row is treated as `accepted` with
|
||||
accepted_at = created_at. This is a pragmatic fiction — it keeps the
|
||||
sidebar populated after the migration without anyone having to click
|
||||
accept on shares that were already live. Any future "accepted X ago" UI
|
||||
inheriting this backfilled timestamp should be aware it's not a real
|
||||
user-action moment.
|
||||
|
||||
The one-migration trick: we add `status` with `server_default="accepted"`
|
||||
so the backfill happens in-place, then drop the default so new inserts
|
||||
fall through to the Python-side model default ("pending").
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_share_status"
|
||||
down_revision: Union[str, None] = "0013_drop_old_ct"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("heap_shares", "folder_shares"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="accepted",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
f"UPDATE {table} SET accepted_at = created_at "
|
||||
"WHERE accepted_at IS NULL"
|
||||
)
|
||||
# Drop the DB default so new rows inherit the Python-side
|
||||
# model default ("pending") instead of silently auto-accepting.
|
||||
op.alter_column(table, "status", server_default=None)
|
||||
op.create_index(
|
||||
f"ix_{table}_shared_with_status",
|
||||
table,
|
||||
["shared_with_id", "status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("heap_shares", "folder_shares"):
|
||||
op.drop_index(f"ix_{table}_shared_with_status", table_name=table)
|
||||
op.drop_column(table, "accepted_at")
|
||||
op.drop_column(table, "status")
|
||||
Reference in New Issue
Block a user