Redesign doc service UX for scale + add group-based document sharing
- Three-column layout: Sidebar + SourcePanel (views + searchable category tree) + main - DocumentSlideOver (480px right panel): inline editing, type picker, AI suggestion confirm/reject, categories combobox, tags editor, sharing section, raw text, re-analyse/delete actions - ManageCategoriesDialog: inline rename, delete with confirm, search filter - DocumentsPage rewrite: filter chip system, multi-file upload queue, drag-and-drop overlay, bulk actions bar (share/delete), smart TanStack Query polling, URL-driven view state - Sidebar simplified: per-category NavLinks removed; Documents = single NavLink under Apps - Backend: document_shares table (migration 0004), share CRUD endpoints, shared-with-me view, N+1-safe share_count via GROUP BY, recipient download access, X-User-Groups header enforcement - Gateway proxy: injects X-User-Groups header into all document + category proxy requests - Backend users: GET /api/users/me/groups endpoint for share picker combobox - CLAUDE.md, STATUS.md files, and changelog updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,23 @@ Controlled via plugin settings (UI accessible to superusers and `doc-service-adm
|
||||
|
||||
On startup scan, the watcher walks the watch directory and ingests any PDFs not already in the database (idempotency check by `watch_path`). Subfolders are automatically mapped to categories (e.g. `watch/invoices/bill.pdf` → category "invoices"). No-remove policy: deleting a file from the watch directory does not delete the document record.
|
||||
|
||||
### Document sharing (`document_shares`)
|
||||
|
||||
Group-based sharing allows a document owner to share a document with all members of any group they belong to. Recipients can view and download the shared document; they cannot edit, re-analyse, delete, or re-share it.
|
||||
|
||||
The gateway injects `X-User-Groups: <group_id1>,<group_id2>,...` alongside the existing `X-User-Id` header, so doc-service can evaluate group access without querying the backend DB.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/documents/shared-with-me` | Any user | Documents shared with the user via their groups; excludes own docs |
|
||||
| `GET` | `/documents/{id}/shares` | Owner only | List all groups the document is shared with |
|
||||
| `POST` | `/documents/{id}/shares` | Owner only | Share with a group (`{group_id}` in body); group must be in X-User-Groups |
|
||||
| `DELETE` | `/documents/{id}/shares/{group_id}` | Owner only | Stop sharing with that group |
|
||||
|
||||
`DocumentOut` now includes `share_count: int` — the number of groups the document is shared with.
|
||||
|
||||
`GET /documents/{id}/file` also allows access to shared documents (recipients can download).
|
||||
|
||||
### Database migrations
|
||||
|
||||
| Revision | Description |
|
||||
@@ -130,6 +147,7 @@ On startup scan, the watcher walks the watch directory and ingests any PDFs not
|
||||
| 0001 | Initial schema (documents, categories, category_assignments) |
|
||||
| 0002 | Add `title` column to documents |
|
||||
| 0003 | Add `source`, `watch_path`, `suggested_folder`, `suggested_filename` columns |
|
||||
| 0004 | Add `document_shares` table (document_id, group_id, shared_by_user_id, created_at) |
|
||||
|
||||
Run automatically on container start via `alembic upgrade head`.
|
||||
|
||||
@@ -168,8 +186,8 @@ file_watcher.py (watchdog Observer, daemon thread)
|
||||
|
||||
- **Re-process** — no endpoint to re-trigger AI extraction on an existing document (e.g. after changing the AI model or prompt)
|
||||
- **Advanced field-level search** — `search` param matches text fields via ILIKE but does not query into `extracted_data` JSON (e.g. filter by `vendor` or `due_date`)
|
||||
- **Bulk operations** — no bulk category assign/remove, no bulk delete
|
||||
- **Document sharing** — documents are strictly per-user; no group sharing yet
|
||||
- **Bulk operations** — no bulk category assign/remove endpoint (frontend handles bulk delete/share individually)
|
||||
- **Advanced field-level search** — `search` matches text fields via ILIKE but does not query into `extracted_data` JSON
|
||||
- **Pagination in categories** — categories are returned as a full list (no pagination)
|
||||
- **File type** — only PDF supported
|
||||
- **Concurrent uploads** — no rate limiting per user
|
||||
@@ -183,9 +201,10 @@ file_watcher.py (watchdog Observer, daemon thread)
|
||||
- [x] Plugin manifest endpoint (`/plugin/manifest`, `/plugin/settings`) for generic settings UI
|
||||
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount) — requires PostgreSQL `jsonb` column or indexed virtual columns
|
||||
- [ ] Bulk operations endpoint
|
||||
- [ ] Document sharing via groups (blocked on groups/permissions system in backend)
|
||||
- [x] Document sharing via groups — `document_shares` table + share endpoints + shared-with-me view
|
||||
- [x] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons in slide-over)
|
||||
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount)
|
||||
- [ ] Support additional file types (images via OCR, DOCX)
|
||||
- [ ] Rate limiting on upload endpoint
|
||||
- [ ] Soft delete with restore
|
||||
- [ ] Category rename / delete with cascade handling
|
||||
- [ ] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons)
|
||||
- [ ] Edit rights for shared recipients (V2)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add document_shares table
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: Union[str, None] = "0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"document_shares",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("document_id", sa.String(), nullable=False),
|
||||
sa.Column("group_id", sa.String(), nullable=False),
|
||||
sa.Column("shared_by_user_id", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
|
||||
)
|
||||
op.create_index("ix_document_shares_document_id", "document_shares", ["document_id"])
|
||||
op.create_index("ix_document_shares_group_id", "document_shares", ["group_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_document_shares_group_id", table_name="document_shares")
|
||||
op.drop_index("ix_document_shares_document_id", table_name="document_shares")
|
||||
op.drop_table("document_shares")
|
||||
@@ -10,3 +10,14 @@ async def get_user_id(x_user_id: str = Header(...)) -> str:
|
||||
if not x_user_id:
|
||||
raise HTTPException(status_code=400, detail="Missing X-User-Id header")
|
||||
return x_user_id
|
||||
|
||||
|
||||
async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
|
||||
"""
|
||||
Extract the group IDs injected by the main backend proxy.
|
||||
Comma-separated list of group UUIDs the current user belongs to.
|
||||
Returns an empty list if the header is absent or empty.
|
||||
"""
|
||||
if not x_user_groups:
|
||||
return []
|
||||
return [g.strip() for g in x_user_groups.split(",") if g.strip()]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.document import Document
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document_share import DocumentShare
|
||||
|
||||
__all__ = ["Document", "DocumentCategory", "CategoryAssignment"]
|
||||
__all__ = ["Document", "DocumentCategory", "CategoryAssignment", "DocumentShare"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DocumentShare(Base):
|
||||
__tablename__ = "document_shares"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
|
||||
)
|
||||
@@ -13,11 +13,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_id
|
||||
from app.deps import get_user_groups, get_user_id
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
from app.schemas.document import DocumentOut, DocumentPage, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate, TitleUpdate
|
||||
from app.models.document_share import DocumentShare
|
||||
from app.schemas.document import (
|
||||
DocumentOut,
|
||||
DocumentPage,
|
||||
DocumentStatusOut,
|
||||
DocumentTypeUpdate,
|
||||
TagsUpdate,
|
||||
TitleUpdate,
|
||||
)
|
||||
from app.schemas.share import DocumentShareCreate, DocumentShareOut, SharedDocumentOut
|
||||
from app.services.ai_client import AIServiceError, classify_document
|
||||
from app.services.config_reader import load_doc_config
|
||||
from app.services.storage import delete_file, get_upload_path, save_upload
|
||||
@@ -52,7 +61,19 @@ async def _get_user_doc(doc_id: str, user_id: str, db: AsyncSession) -> Document
|
||||
return doc
|
||||
|
||||
|
||||
def _doc_with_categories(doc: Document) -> DocumentOut:
|
||||
async def _get_share_counts(doc_ids: list[str], db: AsyncSession) -> dict[str, int]:
|
||||
"""Return a mapping of doc_id → share count for the given document IDs."""
|
||||
if not doc_ids:
|
||||
return {}
|
||||
rows = await db.execute(
|
||||
select(DocumentShare.document_id, func.count(DocumentShare.id))
|
||||
.where(DocumentShare.document_id.in_(doc_ids))
|
||||
.group_by(DocumentShare.document_id)
|
||||
)
|
||||
return {row[0]: row[1] for row in rows.all()}
|
||||
|
||||
|
||||
def _doc_with_categories(doc: Document, share_count: int = 0) -> 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(
|
||||
@@ -73,6 +94,7 @@ def _doc_with_categories(doc: Document) -> DocumentOut:
|
||||
watch_path=doc.watch_path,
|
||||
suggested_folder=doc.suggested_folder,
|
||||
suggested_filename=doc.suggested_filename,
|
||||
share_count=share_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,8 +183,6 @@ async def upload_document(
|
||||
background_tasks.add_task(process_document, doc_id)
|
||||
|
||||
# Re-query with selectinload so category_assignments is eagerly loaded.
|
||||
# A new doc has no categories yet, but we need the relationship populated
|
||||
# to avoid MissingGreenlet in the async session.
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
return _doc_with_categories(doc)
|
||||
|
||||
@@ -194,7 +214,6 @@ async def list_documents(
|
||||
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
|
||||
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
|
||||
|
||||
# Build filter conditions once and reuse for both count + items queries.
|
||||
# Watch-ingested documents (user_id = "watch") are visible to all users.
|
||||
conditions = [or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID)]
|
||||
if status:
|
||||
@@ -233,7 +252,10 @@ async def list_documents(
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
)
|
||||
items = [_doc_with_categories(d) for d in items_result.scalars().all()]
|
||||
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]
|
||||
|
||||
return DocumentPage(
|
||||
items=items,
|
||||
@@ -243,6 +265,119 @@ async def list_documents(
|
||||
)
|
||||
|
||||
|
||||
# NOTE: This route must be registered BEFORE /{doc_id} to avoid path collision.
|
||||
@router.get("/shared-with-me", response_model=DocumentPage)
|
||||
async def list_shared_with_me(
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
sort: str = Query(default="created_at"),
|
||||
order: str = Query(default="desc", pattern="^(asc|desc)$"),
|
||||
search: str | None = Query(default=None),
|
||||
document_type: str | None = Query(default=None),
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentPage:
|
||||
"""Return documents shared with the current user via any of their groups.
|
||||
Excludes documents the user owns (those appear in their regular list).
|
||||
"""
|
||||
if not user_groups:
|
||||
return DocumentPage(items=[], total=0, page=page, pages=1)
|
||||
|
||||
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
|
||||
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
|
||||
|
||||
shared_doc_ids_subq = (
|
||||
select(DocumentShare.document_id)
|
||||
.where(DocumentShare.group_id.in_(user_groups))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
conditions = [
|
||||
Document.id.in_(shared_doc_ids_subq),
|
||||
Document.user_id != user_id, # exclude own docs
|
||||
]
|
||||
if document_type:
|
||||
conditions.append(Document.document_type == document_type)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Document.title.ilike(like),
|
||||
Document.filename.ilike(like),
|
||||
Document.tags.ilike(like),
|
||||
Document.document_type.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(*conditions)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
items_result = await db.execute(
|
||||
select(Document)
|
||||
.where(*conditions)
|
||||
.options(
|
||||
selectinload(Document.category_assignments)
|
||||
.selectinload(CategoryAssignment.category)
|
||||
)
|
||||
.order_by(sort_expr)
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
)
|
||||
docs = items_result.scalars().all()
|
||||
|
||||
# For each doc, find which share (group) brought it in (pick first match)
|
||||
share_rows_result = await db.execute(
|
||||
select(DocumentShare)
|
||||
.where(
|
||||
DocumentShare.document_id.in_([d.id for d in docs]),
|
||||
DocumentShare.group_id.in_(user_groups),
|
||||
)
|
||||
)
|
||||
share_rows = share_rows_result.scalars().all()
|
||||
# Map doc_id → first share row found
|
||||
share_map: dict[str, DocumentShare] = {}
|
||||
for share in share_rows:
|
||||
if share.document_id not in share_map:
|
||||
share_map[share.document_id] = share
|
||||
|
||||
from app.schemas.document import CategoryOut
|
||||
|
||||
items: list[SharedDocumentOut] = []
|
||||
for doc in docs:
|
||||
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
|
||||
share = share_map.get(doc.id)
|
||||
items.append(
|
||||
SharedDocumentOut(
|
||||
id=doc.id,
|
||||
user_id=doc.user_id,
|
||||
filename=doc.filename,
|
||||
title=doc.title,
|
||||
file_size=doc.file_size,
|
||||
status=doc.status,
|
||||
document_type=doc.document_type,
|
||||
extracted_data=doc.extracted_data,
|
||||
tags=doc.tags,
|
||||
error_message=doc.error_message,
|
||||
created_at=doc.created_at,
|
||||
processed_at=doc.processed_at,
|
||||
categories=cats,
|
||||
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 "",
|
||||
)
|
||||
)
|
||||
|
||||
return DocumentPage(
|
||||
items=items, # type: ignore[arg-type]
|
||||
total=total,
|
||||
page=page,
|
||||
pages=max(1, math.ceil(total / per_page)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentOut)
|
||||
async def get_document(
|
||||
doc_id: str,
|
||||
@@ -250,7 +385,8 @@ async def get_document(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentOut:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
return _doc_with_categories(doc)
|
||||
counts = await _get_share_counts([doc.id], db)
|
||||
return _doc_with_categories(doc, counts.get(doc.id, 0))
|
||||
|
||||
|
||||
@router.get("/{doc_id}/status", response_model=DocumentStatusOut)
|
||||
@@ -360,12 +496,22 @@ async def delete_document(
|
||||
async def download_file(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
# Allow access if: owner, watch doc, or shared with any of user's groups
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
or_(
|
||||
Document.user_id == user_id,
|
||||
Document.user_id == _WATCH_USER_ID,
|
||||
Document.id.in_(
|
||||
select(DocumentShare.document_id).where(
|
||||
DocumentShare.group_id.in_(user_groups)
|
||||
)
|
||||
) if user_groups else False,
|
||||
),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
@@ -393,7 +539,6 @@ async def assign_category(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# Verify the document is accessible (own or watch-ingested)
|
||||
doc_result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
@@ -411,7 +556,6 @@ async def assign_category(
|
||||
if cat_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
# Upsert — ignore if already assigned
|
||||
existing = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
@@ -443,8 +587,6 @@ async def remove_category(
|
||||
|
||||
|
||||
# ── AI suggestion confirmation ────────────────────────────────────────────────
|
||||
# These endpoints allow users to confirm or reject AI suggestions on
|
||||
# watch-ingested documents. No disk mutations — suggestions only update the DB.
|
||||
|
||||
@router.post("/{doc_id}/suggestions/folder/confirm", status_code=204)
|
||||
async def confirm_folder_suggestion(
|
||||
@@ -456,7 +598,6 @@ async def confirm_folder_suggestion(
|
||||
if not doc.suggested_folder:
|
||||
raise HTTPException(status_code=400, detail="No folder suggestion pending")
|
||||
|
||||
# Find or create the suggested category under the watch sentinel user
|
||||
cat_result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.user_id == _WATCH_USER_ID,
|
||||
@@ -470,7 +611,6 @@ async def confirm_folder_suggestion(
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
|
||||
# Assign if not already assigned
|
||||
exists = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
@@ -518,3 +658,94 @@ async def reject_filename_suggestion(
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
doc.suggested_filename = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Document sharing ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}/shares", response_model=list[DocumentShareOut])
|
||||
async def list_document_shares(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentShare]:
|
||||
"""List all group shares for a document. Owner only."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
shares_result = await db.execute(
|
||||
select(DocumentShare).where(DocumentShare.document_id == doc_id)
|
||||
)
|
||||
return shares_result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{doc_id}/shares", response_model=DocumentShareOut, status_code=201)
|
||||
async def add_document_share(
|
||||
doc_id: str,
|
||||
body: DocumentShareCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentShare:
|
||||
"""Share a document with a group. The sharing user must own the document
|
||||
and must be a member of the target group."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if body.group_id not in user_groups:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only share with groups you belong to",
|
||||
)
|
||||
|
||||
# Idempotent — return existing share if already shared with this group
|
||||
existing = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id == body.group_id,
|
||||
)
|
||||
)
|
||||
share = existing.scalar_one_or_none()
|
||||
if share is not None:
|
||||
return share
|
||||
|
||||
share = DocumentShare(
|
||||
document_id=doc_id,
|
||||
group_id=body.group_id,
|
||||
shared_by_user_id=user_id,
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
await db.refresh(share)
|
||||
return share
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/shares/{group_id}", status_code=204)
|
||||
async def remove_document_share(
|
||||
doc_id: str,
|
||||
group_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Remove a group share. Owner only."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
share_result = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id == group_id,
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if share:
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
|
||||
@@ -27,6 +27,7 @@ class DocumentOut(BaseModel):
|
||||
watch_path: str | None = None
|
||||
suggested_folder: str | None = None
|
||||
suggested_filename: str | None = None
|
||||
share_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DocumentShareOut(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
group_id: str
|
||||
shared_by_user_id: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentShareCreate(BaseModel):
|
||||
group_id: str
|
||||
|
||||
|
||||
class SharedDocumentOut(BaseModel):
|
||||
"""DocumentOut fields plus sharing context for the 'Shared with me' view."""
|
||||
id: str
|
||||
user_id: str
|
||||
filename: str
|
||||
title: str | None
|
||||
file_size: int
|
||||
status: str
|
||||
document_type: str | None
|
||||
extracted_data: str | None
|
||||
tags: str | None
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None
|
||||
categories: list = []
|
||||
source: str = "upload"
|
||||
share_count: int = 0
|
||||
# Sharing context
|
||||
shared_by_user_id: str
|
||||
shared_via_group_id: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user