feat: document delete permissions + three-dots menu portal fix
- Add can_delete column to document_shares (migration 0005) - Inject x-user-is-admin header from backend proxy to doc-service - Add get_user_is_admin() dep in doc-service - Delete endpoint now allows: owner, admin, or group member with can_delete=true - Watch documents (user_id='watch') deletable by admins only - DocumentOut gains viewer_can_delete (computed per-request) - Share UI: 'Allow group members to delete' checkbox + trash badge on shares - RowActionsMenu dropdown portaled to document.body — fixes overflow-hidden clipping - Delete mutation onError handler — no more silent failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,7 @@ features/doc-service/
|
||||
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
|
||||
| `group_id` | String | indexed, NOT NULL | group from backend |
|
||||
| `shared_by_user_id` | String | NOT NULL | owner who shared |
|
||||
| `can_delete` | Boolean | NOT NULL, default=false | whether group members may delete the doc |
|
||||
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||
|
||||
Unique constraint: `(document_id, group_id)`
|
||||
@@ -112,6 +113,7 @@ Unique constraint: `(document_id, group_id)`
|
||||
| `0002` | `add_document_title` |
|
||||
| `0003` | `add_watch_columns` |
|
||||
| `0004` | `add_document_shares` |
|
||||
| `0005` | `add_share_can_delete` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add can_delete to document_shares
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: Union[str, None] = "0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"document_shares",
|
||||
sa.Column(
|
||||
"can_delete",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("document_shares", "can_delete")
|
||||
@@ -21,3 +21,11 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
|
||||
if not x_user_groups:
|
||||
return []
|
||||
return [g.strip() for g in x_user_groups.split(",") if g.strip()]
|
||||
|
||||
|
||||
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
|
||||
"""
|
||||
Extract the admin flag injected by the main backend proxy.
|
||||
Returns True only if the header value is exactly "true" (lowercase).
|
||||
"""
|
||||
return x_user_is_admin.lower() == "true"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -14,6 +14,7 @@ class DocumentShare(Base):
|
||||
document_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
shared_by_user_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
can_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_groups, get_user_id
|
||||
from app.deps import get_user_groups, get_user_id, get_user_is_admin
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
@@ -73,7 +73,26 @@ async def _get_share_counts(doc_ids: list[str], db: AsyncSession) -> dict[str, i
|
||||
return {row[0]: row[1] for row in rows.all()}
|
||||
|
||||
|
||||
def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut:
|
||||
async def _get_deletable_doc_ids(
|
||||
doc_ids: list[str], user_groups: list[str], db: AsyncSession
|
||||
) -> set[str]:
|
||||
"""Return doc IDs for which the user has delete permission via a group share."""
|
||||
if not doc_ids or not user_groups:
|
||||
return set()
|
||||
rows = await db.execute(
|
||||
select(DocumentShare.document_id)
|
||||
.where(
|
||||
DocumentShare.document_id.in_(doc_ids),
|
||||
DocumentShare.group_id.in_(user_groups),
|
||||
DocumentShare.can_delete.is_(True),
|
||||
)
|
||||
)
|
||||
return {row[0] for row in rows.all()}
|
||||
|
||||
|
||||
def _doc_with_categories(
|
||||
doc: Document, share_count: int = 0, viewer_can_delete: bool = False
|
||||
) -> DocumentOut:
|
||||
from app.schemas.document import CategoryOut
|
||||
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
|
||||
return DocumentOut(
|
||||
@@ -95,6 +114,7 @@ def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut:
|
||||
suggested_folder=doc.suggested_folder,
|
||||
suggested_filename=doc.suggested_filename,
|
||||
share_count=share_count,
|
||||
viewer_can_delete=viewer_can_delete,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,6 +229,8 @@ async def list_documents(
|
||||
search: str | None = Query(default=None),
|
||||
category_id: str | None = Query(default=None),
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentPage:
|
||||
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
|
||||
@@ -254,8 +276,19 @@ async def list_documents(
|
||||
)
|
||||
docs = items_result.scalars().all()
|
||||
|
||||
share_counts = await _get_share_counts([d.id for d in docs], db)
|
||||
items = [_doc_with_categories(d, share_counts.get(d.id, 0)) for d in docs]
|
||||
doc_ids = [d.id for d in docs]
|
||||
share_counts = await _get_share_counts(doc_ids, db)
|
||||
|
||||
if is_admin:
|
||||
deletable_ids = set(doc_ids)
|
||||
else:
|
||||
deletable_ids = {d.id for d in docs if d.user_id == user_id}
|
||||
deletable_ids |= await _get_deletable_doc_ids(doc_ids, user_groups, db)
|
||||
|
||||
items = [
|
||||
_doc_with_categories(d, share_counts.get(d.id, 0), viewer_can_delete=d.id in deletable_ids)
|
||||
for d in docs
|
||||
]
|
||||
|
||||
return DocumentPage(
|
||||
items=items,
|
||||
@@ -367,6 +400,7 @@ async def list_shared_with_me(
|
||||
source=doc.source,
|
||||
shared_by_user_id=share.shared_by_user_id if share else "",
|
||||
shared_via_group_id=share.group_id if share else "",
|
||||
viewer_can_delete=bool(share and share.can_delete),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -382,11 +416,19 @@ async def list_shared_with_me(
|
||||
async def get_document(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentOut:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
counts = await _get_share_counts([doc.id], db)
|
||||
return _doc_with_categories(doc, counts.get(doc.id, 0))
|
||||
if is_admin:
|
||||
viewer_can_delete = True
|
||||
elif doc.user_id == user_id:
|
||||
viewer_can_delete = True
|
||||
else:
|
||||
viewer_can_delete = bool(await _get_deletable_doc_ids([doc.id], user_groups, db))
|
||||
return _doc_with_categories(doc, counts.get(doc.id, 0), viewer_can_delete=viewer_can_delete)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/status", response_model=DocumentStatusOut)
|
||||
@@ -479,14 +521,26 @@ async def reprocess_document(
|
||||
async def delete_document(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
is_owner = doc.user_id == user_id
|
||||
if not is_owner and not is_admin:
|
||||
can_delete_via_group = bool(await _get_deletable_doc_ids([doc_id], user_groups, db))
|
||||
if not can_delete_via_group:
|
||||
raise HTTPException(status_code=403, detail="Delete not permitted")
|
||||
|
||||
delete_file(doc.file_path)
|
||||
await db.delete(doc)
|
||||
await db.commit()
|
||||
@@ -718,6 +772,7 @@ async def add_document_share(
|
||||
document_id=doc_id,
|
||||
group_id=body.group_id,
|
||||
shared_by_user_id=user_id,
|
||||
can_delete=body.can_delete,
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
|
||||
@@ -28,6 +28,7 @@ class DocumentOut(BaseModel):
|
||||
suggested_folder: str | None = None
|
||||
suggested_filename: str | None = None
|
||||
share_count: int = 0
|
||||
viewer_can_delete: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ class DocumentShareOut(BaseModel):
|
||||
document_id: str
|
||||
group_id: str
|
||||
shared_by_user_id: str
|
||||
can_delete: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -15,6 +16,7 @@ class DocumentShareOut(BaseModel):
|
||||
|
||||
class DocumentShareCreate(BaseModel):
|
||||
group_id: str
|
||||
can_delete: bool = False
|
||||
|
||||
|
||||
class SharedDocumentOut(BaseModel):
|
||||
@@ -34,6 +36,7 @@ class SharedDocumentOut(BaseModel):
|
||||
categories: list = []
|
||||
source: str = "upload"
|
||||
share_count: int = 0
|
||||
viewer_can_delete: bool = False
|
||||
# Sharing context
|
||||
shared_by_user_id: str
|
||||
shared_via_group_id: str
|
||||
|
||||
Reference in New Issue
Block a user