747303246a
Folders, Sharing, Quotas & Document UX — plans verified (0 blockers, 2 non-blocking warnings). Covers FOLD-01..05, SHARE-01..05, SEC-08/09, ADMIN-06, DOC-01/02. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1251 lines
40 KiB
Markdown
1251 lines
40 KiB
Markdown
# Phase 4: Folders, Sharing, Quotas & Document UX - Pattern Map
|
|
|
|
**Mapped:** 2026-05-25
|
|
**Files analyzed:** 20 (10 new, 10 modified)
|
|
**Analogs found:** 19 / 20
|
|
|
|
---
|
|
|
|
## File Classification
|
|
|
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
|
|---|---|---|---|---|
|
|
| `backend/api/folders.py` | router/controller | CRUD + request-response | `backend/api/documents.py` | exact |
|
|
| `backend/api/shares.py` | router/controller | CRUD + request-response | `backend/api/topics.py` + `backend/api/documents.py` | role-match |
|
|
| `backend/api/audit.py` | router/controller | CRUD + streaming (export) | `backend/api/admin.py` | exact |
|
|
| `backend/services/audit.py` | service/utility | request-response | `backend/services/storage.py` | role-match |
|
|
| `backend/tasks/audit_tasks.py` | task/worker | batch + file-I/O | `backend/tasks/document_tasks.py` | exact |
|
|
| `backend/migrations/versions/0004_phase4_*.py` | migration/config | transform | `backend/migrations/versions/0003_multi_user_isolation.py` | exact |
|
|
| `backend/storage/minio_backend.py` (modify) | service | file-I/O | self (existing file) | self |
|
|
| `backend/celery_app.py` (modify) | config | event-driven | self (existing file) | self |
|
|
| `backend/api/documents.py` (modify) | router/controller | streaming + CRUD | self (existing file) | self |
|
|
| `backend/api/auth.py` (modify) | router/controller | request-response | self (existing file) | self |
|
|
| `backend/api/admin.py` (modify) | router/controller | CRUD + streaming | self (existing file) | self |
|
|
| `frontend/src/stores/folders.js` | store | CRUD + request-response | `frontend/src/stores/topics.js` | exact |
|
|
| `frontend/src/components/documents/ShareModal.vue` | component | request-response | `frontend/src/components/admin/AdminUsersTab.vue` | partial |
|
|
| `frontend/src/components/layout/BreadcrumbNav.vue` | component | request-response | `frontend/src/components/topics/TopicBadge.vue` | partial |
|
|
| `frontend/src/components/admin/AdminAuditLogTab.vue` | component | CRUD + request-response | `frontend/src/components/admin/AdminUsersTab.vue` | exact |
|
|
| `frontend/src/components/layout/AppSidebar.vue` (modify) | component | request-response | self (existing file) | self |
|
|
| `frontend/src/components/documents/DocumentCard.vue` (modify) | component | request-response | self (existing file) | self |
|
|
| `frontend/src/stores/documents.js` (modify) | store | CRUD + request-response | self (existing file) | self |
|
|
| `frontend/src/views/HomeView.vue` (modify) | view | request-response | self (existing file) | self |
|
|
| `frontend/src/views/SettingsView.vue` (modify) | view | request-response | self (existing file) | self |
|
|
|
|
---
|
|
|
|
## Pattern Assignments
|
|
|
|
### `backend/api/folders.py` (router/controller, CRUD)
|
|
|
|
**Analog:** `backend/api/documents.py`
|
|
|
|
**Imports pattern** (lines 17-34 of `backend/api/documents.py`):
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, text, delete
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import Document, Folder, Quota, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
from storage import get_storage_backend
|
|
|
|
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
|
```
|
|
|
|
**Auth/Guard pattern** — `get_regular_user` on every handler (lines 54-58 of `backend/api/documents.py`):
|
|
```python
|
|
@router.post("/")
|
|
async def create_folder(
|
|
body: FolderCreate,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
):
|
|
```
|
|
|
|
**Ownership assertion pattern** (lines 117-119 of `backend/api/documents.py`):
|
|
```python
|
|
doc = await session.get(Document, uid)
|
|
if doc is None or doc.user_id != current_user.id:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
```
|
|
Apply as: `folder = await session.get(Folder, folder_id); if folder is None or folder.user_id != current_user.id: raise HTTPException(404, "Folder not found")`
|
|
|
|
**UUID parse pattern** (lines 113-115 of `backend/api/documents.py`):
|
|
```python
|
|
try:
|
|
uid = uuid.UUID(doc_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
```
|
|
|
|
**IntegrityError / 409 pattern** — new for folders, from RESEARCH.md Pitfall 6:
|
|
```python
|
|
from sqlalchemy.exc import IntegrityError
|
|
try:
|
|
session.add(folder)
|
|
await session.commit()
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
raise HTTPException(409, "A folder with that name already exists here")
|
|
```
|
|
|
|
**Atomic quota decrement pattern** (lines 137-146 of `backend/api/documents.py`):
|
|
```python
|
|
result = await session.execute(
|
|
text(
|
|
"UPDATE quotas "
|
|
"SET used_bytes = used_bytes + :delta "
|
|
"WHERE user_id = :uid "
|
|
" AND (used_bytes + :delta) <= limit_bytes "
|
|
"RETURNING used_bytes, limit_bytes"
|
|
),
|
|
{"delta": size, "uid": str(doc.user_id)},
|
|
)
|
|
```
|
|
For folder cascade-delete, invert: `CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END`
|
|
|
|
**Request models pattern** (lines 46-49 of `backend/api/documents.py`):
|
|
```python
|
|
class UploadUrlRequest(BaseModel):
|
|
filename: str
|
|
content_type: str
|
|
```
|
|
Apply as:
|
|
```python
|
|
class FolderCreate(BaseModel):
|
|
name: str
|
|
parent_id: Optional[str] = None
|
|
|
|
class FolderRename(BaseModel):
|
|
name: str
|
|
|
|
class DocumentMove(BaseModel):
|
|
folder_id: Optional[str] = None
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/api/shares.py` (router/controller, CRUD)
|
|
|
|
**Analog:** `backend/api/documents.py` + `backend/api/admin.py`
|
|
|
|
**Imports pattern:**
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import Document, Share, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
|
|
router = APIRouter(prefix="/api/shares", tags=["shares"])
|
|
```
|
|
|
|
**Ownership assertion for shares** — owner_id variant (from RESEARCH.md Pitfall 4):
|
|
```python
|
|
share = await session.get(Share, share_id)
|
|
if share is None or share.owner_id != current_user.id:
|
|
raise HTTPException(404, "Share not found")
|
|
```
|
|
|
|
**Handle lookup pattern** — exact match, 404 if not found (analogous to admin user lookup in lines 231-233 of `backend/api/admin.py`):
|
|
```python
|
|
result = await session.execute(
|
|
select(User).where(User.handle == body.recipient_handle)
|
|
)
|
|
recipient = result.scalar_one_or_none()
|
|
if recipient is None:
|
|
raise HTTPException(404, "User not found")
|
|
```
|
|
|
|
**Share-recipient list query** (from RESEARCH.md Pattern 4 / D-06):
|
|
```python
|
|
# GET /api/shares/received — "Shared with me" virtual folder
|
|
stmt = (
|
|
select(Document)
|
|
.join(Share, Share.document_id == Document.id)
|
|
.where(Share.recipient_id == current_user.id)
|
|
.order_by(Document.created_at.desc())
|
|
)
|
|
result = await session.execute(stmt)
|
|
shared_docs = result.scalars().all()
|
|
```
|
|
|
|
**IntegrityError / 409 for duplicate share** (same pattern as folder name uniqueness):
|
|
```python
|
|
try:
|
|
session.add(share)
|
|
await session.commit()
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
raise HTTPException(409, "Document already shared with this user")
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/api/audit.py` (router/controller, CRUD + streaming export)
|
|
|
|
**Analog:** `backend/api/admin.py`
|
|
|
|
**Imports pattern** (lines 23-40 of `backend/api/admin.py`):
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import AuditLog, User
|
|
from deps.auth import get_current_admin
|
|
from deps.db import get_db
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
```
|
|
|
|
**Admin guard pattern** (line 144 of `backend/api/admin.py`):
|
|
```python
|
|
@router.get("/audit-log")
|
|
async def list_audit_log(
|
|
session: AsyncSession = Depends(get_db),
|
|
_admin: User = Depends(get_current_admin),
|
|
start: Optional[datetime] = Query(None),
|
|
end: Optional[datetime] = Query(None),
|
|
user_id: Optional[uuid.UUID] = Query(None),
|
|
event_type: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(50, ge=1, le=500),
|
|
) -> dict:
|
|
```
|
|
|
|
**Paginated list + total pattern** (lines 151-155 of `backend/api/admin.py`):
|
|
```python
|
|
result = await session.execute(
|
|
select(User).order_by(User.created_at.desc())
|
|
)
|
|
users = result.scalars().all()
|
|
return {"items": [_user_to_dict(u) for u in users]}
|
|
```
|
|
Apply as:
|
|
```python
|
|
stmt = (
|
|
select(AuditLog)
|
|
.order_by(AuditLog.created_at.desc())
|
|
.limit(per_page)
|
|
.offset((page - 1) * per_page)
|
|
)
|
|
```
|
|
|
|
**Safe response helper / whitelist pattern** (lines 54-69 of `backend/api/admin.py`):
|
|
```python
|
|
def _user_to_dict(user: User) -> dict:
|
|
"""Return a safe subset of User fields — never includes password_hash,
|
|
credentials_enc, totp_secret, or any document content (T-02-27, SEC-07).
|
|
"""
|
|
return {
|
|
"id": str(user.id),
|
|
"handle": user.handle,
|
|
...
|
|
}
|
|
```
|
|
Apply as:
|
|
```python
|
|
def _audit_to_dict(entry: AuditLog) -> dict:
|
|
"""Audit log safe serializer — never includes filename, extracted_text,
|
|
or document content (ADMIN-06, D-15).
|
|
"""
|
|
return {
|
|
"id": entry.id,
|
|
"event_type": entry.event_type,
|
|
"user_id": str(entry.user_id) if entry.user_id else None,
|
|
"actor_id": str(entry.actor_id) if entry.actor_id else None,
|
|
"resource_id": str(entry.resource_id) if entry.resource_id else None,
|
|
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
|
"metadata_": entry.metadata_,
|
|
"created_at": entry.created_at.isoformat(),
|
|
}
|
|
```
|
|
|
|
**CSV StreamingResponse export pattern** (from RESEARCH.md Pattern 7):
|
|
```python
|
|
@router.get("/audit-log/export")
|
|
async def export_audit_log(
|
|
format: str = Query("csv"),
|
|
session: AsyncSession = Depends(get_db),
|
|
_admin: User = Depends(get_current_admin),
|
|
):
|
|
rows = await _query_audit_log(session, ...)
|
|
output = io.StringIO()
|
|
writer = csv.DictWriter(output, fieldnames=[
|
|
"id", "event_type", "user_id", "actor_id",
|
|
"resource_id", "ip_address", "metadata_", "created_at"
|
|
])
|
|
writer.writeheader()
|
|
for row in rows:
|
|
writer.writerow(_audit_to_dict(row))
|
|
output.seek(0)
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/services/audit.py` (service/utility, request-response)
|
|
|
|
**Analog:** `backend/services/storage.py` (pattern for standalone async service functions)
|
|
|
|
**Imports pattern** (from RESEARCH.md Pattern 6):
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import AuditLog
|
|
|
|
logger = logging.getLogger(__name__)
|
|
```
|
|
|
|
**Core write_audit_log function** (RESEARCH.md Pattern 6 — flush-not-commit, never-raises):
|
|
```python
|
|
async def write_audit_log(
|
|
session: AsyncSession,
|
|
event_type: str,
|
|
user_id: Optional[uuid.UUID],
|
|
actor_id: Optional[uuid.UUID],
|
|
resource_id: Optional[uuid.UUID],
|
|
ip_address: Optional[str],
|
|
metadata_: Optional[dict] = None,
|
|
) -> None:
|
|
"""Write an audit log entry. Never raises — audit failure is non-fatal."""
|
|
try:
|
|
entry = AuditLog(
|
|
event_type=event_type,
|
|
user_id=user_id,
|
|
actor_id=actor_id,
|
|
resource_id=resource_id,
|
|
ip_address=ip_address,
|
|
metadata_=metadata_,
|
|
)
|
|
session.add(entry)
|
|
await session.flush() # flush within handler's existing transaction, not commit
|
|
except Exception as exc:
|
|
logger.warning("audit log write failed: %s", exc)
|
|
# Do not re-raise — audit failure must never abort the primary operation
|
|
```
|
|
|
|
**IP extraction pattern** for handlers (from RESEARCH.md Pitfall 5):
|
|
```python
|
|
# In each handler that calls write_audit_log:
|
|
ip_address = request.headers.get("X-Forwarded-For", None) or (
|
|
request.client.host if request.client else None
|
|
)
|
|
await write_audit_log(session, "folder.created", current_user.id, current_user.id, folder.id, ip_address)
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/tasks/audit_tasks.py` (task/worker, batch + file-I/O)
|
|
|
|
**Analog:** `backend/tasks/document_tasks.py`
|
|
|
|
**Task module structure** (lines 1-26 of `backend/tasks/document_tasks.py`):
|
|
```python
|
|
"""
|
|
Celery tasks for audit log export in DocuVault.
|
|
|
|
audit_log_daily_export — called by Celery beat at midnight UTC.
|
|
The task is a plain sync def (Celery workers have no asyncio event loop); it
|
|
bridges into the async body via asyncio.run().
|
|
"""
|
|
import asyncio
|
|
|
|
from celery_app import celery_app
|
|
```
|
|
|
|
**Sync entry-point → asyncio.run() bridge pattern** (lines 22-25 of `backend/tasks/document_tasks.py`):
|
|
```python
|
|
@celery_app.task(name="tasks.document_tasks.extract_and_classify")
|
|
def extract_and_classify(document_id: str) -> dict:
|
|
"""Synchronous Celery entry-point — delegates to async _run via asyncio.run."""
|
|
return asyncio.run(_run(document_id))
|
|
```
|
|
Apply as:
|
|
```python
|
|
@celery_app.task(name="tasks.audit_tasks.audit_log_daily_export")
|
|
def audit_log_daily_export() -> dict:
|
|
return asyncio.run(_run_daily_export())
|
|
```
|
|
|
|
**AsyncSessionLocal usage pattern** (lines 41-43 of `backend/tasks/document_tasks.py`):
|
|
```python
|
|
from db.session import AsyncSessionLocal
|
|
from db.models import Document
|
|
from storage import get_storage_backend
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
doc = await session.get(Document, doc_uuid)
|
|
```
|
|
|
|
**Best-effort error handling in tasks** (lines 90-101 of `backend/tasks/document_tasks.py`):
|
|
```python
|
|
try:
|
|
topics = await classifier.classify_document(session, document_id, ...)
|
|
return {"document_id": document_id, "status": "classified", ...}
|
|
except Exception as e:
|
|
doc.status = "classification_failed"
|
|
await session.commit()
|
|
return {"document_id": document_id, "status": "classification_failed", "error": str(e)}
|
|
```
|
|
|
|
**Deferred imports pattern** (lines 38-40 of `backend/tasks/document_tasks.py`):
|
|
```python
|
|
# All application imports are inside the async function body, not at module top level.
|
|
# This avoids circular imports between Celery worker process and FastAPI application.
|
|
from db.session import AsyncSessionLocal
|
|
from db.models import Document
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/migrations/versions/0004_phase4_*.py` (migration/config)
|
|
|
|
**Analog:** `backend/migrations/versions/0003_multi_user_isolation.py`
|
|
|
|
**Module header pattern** (lines 1-50 of `backend/migrations/versions/0003_multi_user_isolation.py`):
|
|
```python
|
|
"""<description of changes>.
|
|
|
|
Revision ID: 0004
|
|
Revises: 0003
|
|
Create Date: <date>
|
|
|
|
Changes (in order):
|
|
1. Add users.pdf_open_mode column (String, default 'in_app')
|
|
2. Create GIN expression index on documents.extracted_text (tsvector FTS)
|
|
3. Create audit-logs MinIO bucket (gated on MINIO_ENDPOINT env var)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import text
|
|
from alembic import op
|
|
|
|
revision = "0004"
|
|
down_revision = "0003"
|
|
branch_labels = None
|
|
depends_on = None
|
|
```
|
|
|
|
**batch_alter_table for SQLite compat** (lines 95-102 of `backend/migrations/versions/0003_multi_user_isolation.py`):
|
|
```python
|
|
with op.batch_alter_table("users") as batch_op:
|
|
batch_op.add_column(
|
|
sa.Column("pdf_open_mode", sa.String(), nullable=False, server_default="in_app")
|
|
)
|
|
```
|
|
|
|
**GIN expression index — manual SQL, do NOT use Computed()** (from RESEARCH.md Pattern 5):
|
|
```python
|
|
# managed manually — do not autogenerate (Alembic issue #1390)
|
|
op.execute(
|
|
"CREATE INDEX ix_documents_fts ON documents "
|
|
"USING GIN (to_tsvector('english', coalesce(extracted_text, '')))"
|
|
)
|
|
```
|
|
|
|
**MinIO bucket creation gated on env var** (lines 74-88 of `backend/migrations/versions/0003_multi_user_isolation.py`):
|
|
```python
|
|
if os.environ.get("MINIO_ENDPOINT"):
|
|
from minio import Minio
|
|
bucket = "audit-logs"
|
|
client = Minio(
|
|
os.environ.get("MINIO_ENDPOINT", "minio:9000"),
|
|
access_key=os.environ.get("MINIO_ACCESS_KEY", ""),
|
|
secret_key=os.environ.get("MINIO_SECRET_KEY", ""),
|
|
secure=False,
|
|
)
|
|
if not client.bucket_exists(bucket):
|
|
client.make_bucket(bucket)
|
|
```
|
|
|
|
**downgrade() reversal** (lines 117-131 of `backend/migrations/versions/0003_multi_user_isolation.py`):
|
|
```python
|
|
def downgrade() -> None:
|
|
op.execute("DROP INDEX IF EXISTS ix_documents_fts")
|
|
with op.batch_alter_table("users") as batch_op:
|
|
batch_op.drop_column("pdf_open_mode")
|
|
# Note: MinIO bucket creation is NOT reversed — bucket may contain data.
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/storage/minio_backend.py` — add `put_object_raw()` (modify)
|
|
|
|
**Analog:** Existing `MinIOBackend.put_object()` method (lines 62-86 of `backend/storage/minio_backend.py`).
|
|
|
|
**asyncio.to_thread() pattern for sync SDK calls** (lines 78-86 of `backend/storage/minio_backend.py`):
|
|
```python
|
|
await asyncio.to_thread(
|
|
self._client.put_object,
|
|
self._bucket,
|
|
object_key,
|
|
data,
|
|
length=len(file_bytes),
|
|
content_type=content_type,
|
|
)
|
|
```
|
|
|
|
**New `put_object_raw()` method** — for audit-logs bucket (different bucket, caller-supplied key):
|
|
```python
|
|
async def put_object_raw(
|
|
self,
|
|
bucket: str,
|
|
key: str,
|
|
data: io.BytesIO,
|
|
length: int,
|
|
content_type: str,
|
|
) -> None:
|
|
"""Upload bytes to an arbitrary bucket+key (used for audit-logs CSV export).
|
|
|
|
Unlike put_object(), does NOT apply the document key schema — the caller
|
|
supplies the complete key. The main documents bucket is NOT used.
|
|
"""
|
|
await asyncio.to_thread(
|
|
self._client.put_object,
|
|
bucket,
|
|
key,
|
|
data,
|
|
length=length,
|
|
content_type=content_type,
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/celery_app.py` — add beat_schedule entry (modify)
|
|
|
|
**Analog:** Existing `beat_schedule` dict in `backend/celery_app.py` (lines 38-43).
|
|
|
|
**beat_schedule entry pattern** (lines 38-43 of `backend/celery_app.py`):
|
|
```python
|
|
celery_app.conf.beat_schedule = {
|
|
"cleanup-abandoned-uploads": {
|
|
"task": "tasks.document_tasks.cleanup_abandoned_uploads",
|
|
"schedule": _timedelta(minutes=30),
|
|
},
|
|
}
|
|
```
|
|
**Add** (import `crontab` at top alongside `_timedelta`):
|
|
```python
|
|
from celery.schedules import crontab as _crontab
|
|
|
|
celery_app.conf.beat_schedule = {
|
|
"cleanup-abandoned-uploads": {
|
|
"task": "tasks.document_tasks.cleanup_abandoned_uploads",
|
|
"schedule": _timedelta(minutes=30),
|
|
},
|
|
"audit-log-daily-export": {
|
|
"task": "tasks.audit_tasks.audit_log_daily_export",
|
|
"schedule": _crontab(hour=0, minute=0), # midnight UTC
|
|
},
|
|
}
|
|
```
|
|
|
|
**task_routes extension** (lines 32-35 of `backend/celery_app.py`):
|
|
```python
|
|
celery_app.conf.task_routes = {
|
|
"tasks.document_tasks.*": {"queue": "documents"},
|
|
"tasks.email_tasks.*": {"queue": "email"},
|
|
"tasks.audit_tasks.*": {"queue": "documents"}, # reuse documents worker queue
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `backend/api/documents.py` — add streaming proxy + search + audit (modify)
|
|
|
|
**Analog:** Self. Also RESEARCH.md Pattern 3 (streaming proxy) and Pattern 5 (FTS query).
|
|
|
|
**StreamingResponse import** (add to existing imports):
|
|
```python
|
|
from fastapi import Request
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import func
|
|
```
|
|
|
|
**Streaming proxy endpoint pattern** (RESEARCH.md Pattern 3):
|
|
```python
|
|
@router.get("/{doc_id}/content")
|
|
async def stream_document_content(
|
|
doc_id: str,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user), # MUST be get_regular_user (Pitfall 3)
|
|
):
|
|
try:
|
|
uid = uuid.UUID(doc_id)
|
|
except ValueError:
|
|
raise HTTPException(404, "Document not found")
|
|
|
|
doc = await session.get(Document, uid)
|
|
if doc is None:
|
|
raise HTTPException(404, "Document not found")
|
|
|
|
# Access: owner OR active share recipient
|
|
if doc.user_id != current_user.id:
|
|
share_result = await session.execute(
|
|
select(Share).where(
|
|
Share.document_id == doc.id,
|
|
Share.recipient_id == current_user.id,
|
|
)
|
|
)
|
|
if share_result.scalar_one_or_none() is None:
|
|
raise HTTPException(404, "Document not found")
|
|
|
|
file_bytes = await get_storage_backend().get_object(doc.object_key)
|
|
file_size = len(file_bytes)
|
|
|
|
range_header = request.headers.get("range")
|
|
headers = {
|
|
"content-type": doc.content_type,
|
|
"content-disposition": f'inline; filename="{doc.filename}"',
|
|
"accept-ranges": "bytes",
|
|
"content-length": str(file_size),
|
|
}
|
|
|
|
if range_header:
|
|
start, end = _parse_range(range_header, file_size)
|
|
chunk = file_bytes[start:end + 1]
|
|
headers["content-range"] = f"bytes {start}-{end}/{file_size}"
|
|
headers["content-length"] = str(len(chunk))
|
|
return StreamingResponse(iter([chunk]), status_code=206, headers=headers)
|
|
|
|
return StreamingResponse(iter([file_bytes]), status_code=200, headers=headers)
|
|
```
|
|
|
|
**tsvector search query addition to list_documents** (RESEARCH.md Pattern 5):
|
|
```python
|
|
# In list_documents, add q: Optional[str] = Query(None) parameter
|
|
if q and len(q) >= 2:
|
|
stmt = stmt.where(
|
|
func.to_tsvector("english", func.coalesce(Document.extracted_text, "")).op("@@")(
|
|
func.plainto_tsquery("english", q)
|
|
)
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/stores/folders.js` (store, CRUD)
|
|
|
|
**Analog:** `frontend/src/stores/topics.js` — exact same Pinia defineStore + ref() + async action pattern.
|
|
|
|
**Store structure** (full `frontend/src/stores/topics.js` — lines 1-42):
|
|
```javascript
|
|
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
import * as api from '../api/client.js'
|
|
|
|
export const useTopicsStore = defineStore('topics', () => {
|
|
const topics = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
|
|
async function fetchTopics() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const data = await api.listTopics()
|
|
topics.value = data.topics
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function addTopic(payload) {
|
|
const topic = await api.createTopic(payload)
|
|
topics.value.push(topic)
|
|
return topic
|
|
}
|
|
// ...
|
|
return { topics, loading, error, fetchTopics, addTopic, editTopic, removeTopic }
|
|
})
|
|
```
|
|
Apply as `useTopicsStore` → `useFoldersStore`, with state refs: `folders`, `currentFolderId`, `breadcrumb`, `loading`, `error`. Actions: `fetchFolders(parentId)`, `createFolder(name, parentId)`, `renameFolder(id, name)`, `deleteFolder(id)`, `navigateTo(folderId)`, `fetchBreadcrumb(folderId)`.
|
|
|
|
**Action error-catching pattern** (lines 10-21 of `frontend/src/stores/topics.js`):
|
|
```javascript
|
|
async function fetchTopics() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const data = await api.listTopics()
|
|
topics.value = data.topics
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/documents/ShareModal.vue` (component, request-response)
|
|
|
|
**Analog:** `frontend/src/components/admin/AdminUsersTab.vue` — modal with form input + list with action buttons.
|
|
|
|
**Script setup with reactive form state** (lines 216-235 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```javascript
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import * as api from '../../api/client.js'
|
|
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
const submitting = ref(false)
|
|
|
|
const form = reactive({
|
|
handle: '',
|
|
})
|
|
```
|
|
|
|
**Pending action + error pattern** (lines 224-229 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```javascript
|
|
const pendingAction = reactive({})
|
|
const actionError = ref(null)
|
|
const creating = ref(false)
|
|
const createError = ref(null)
|
|
```
|
|
|
|
**Props + emits pattern** — ShareModal is opened from DocumentCard, needs `doc` prop and `close` emit:
|
|
```javascript
|
|
const props = defineProps({
|
|
doc: Object,
|
|
})
|
|
const emit = defineEmits(['close'])
|
|
```
|
|
|
|
**Loading/empty/list states template pattern** (lines 96-108 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```html
|
|
<div v-if="loading" class="...text-center">...</div>
|
|
<div v-else-if="shares.length === 0" class="...text-center">
|
|
<p class="text-sm text-gray-400 italic">Not shared with anyone yet.</p>
|
|
</div>
|
|
<div v-else class="divide-y divide-gray-200">
|
|
<div v-for="share in shares" :key="share.id" class="flex items-center justify-between py-2">
|
|
<span class="text-sm text-gray-700">{{ share.recipient_handle }}</span>
|
|
<button @click="revokeShare(share.id)" class="text-red-600 hover:text-red-700 text-sm">Revoke</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Inline confirm pattern** (lines 151-168 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```html
|
|
<div v-if="confirmDeactivate === user.id" class="space-y-2">
|
|
<p class="text-xs text-gray-700">Deactivate ...? ...</p>
|
|
<div class="flex items-center gap-2">
|
|
<button @click="confirmDoDeactivate(user.id)" class="text-red-600 ...">Deactivate</button>
|
|
<button @click="confirmDeactivate = null" class="text-gray-500 ...">Keep account</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/layout/BreadcrumbNav.vue` (component, request-response)
|
|
|
|
**Analog:** `frontend/src/components/topics/TopicBadge.vue` — lightweight display component, props-driven, no store usage.
|
|
|
|
**Props-only component pattern** (lines 34-46 of `frontend/src/components/documents/DocumentCard.vue`):
|
|
```javascript
|
|
const props = defineProps({
|
|
doc: Object,
|
|
})
|
|
```
|
|
Apply as:
|
|
```javascript
|
|
const props = defineProps({
|
|
// Array of {id, name} objects from root to current folder
|
|
segments: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
})
|
|
const emit = defineEmits(['navigate'])
|
|
```
|
|
|
|
**Truncation logic** (from RESEARCH.md Specifics: depth > 4, show first + "..." + last 2):
|
|
```javascript
|
|
const visibleSegments = computed(() => {
|
|
if (props.segments.length <= 4) return props.segments
|
|
return [
|
|
props.segments[0],
|
|
{ id: null, name: '...' },
|
|
...props.segments.slice(-2),
|
|
]
|
|
})
|
|
```
|
|
|
|
**Tailwind styling pattern from AppSidebar** (lines 128-132 of `frontend/src/components/layout/AppSidebar.vue`):
|
|
```css
|
|
.nav-link {
|
|
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors text-sm font-medium;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/admin/AdminAuditLogTab.vue` (component, CRUD)
|
|
|
|
**Analog:** `frontend/src/components/admin/AdminUsersTab.vue` — exact same paginated table with filters and action buttons.
|
|
|
|
**onMounted fetch pattern** (lines 368-378 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```javascript
|
|
onMounted(async () => {
|
|
loading.value = true
|
|
try {
|
|
const data = await api.adminListUsers()
|
|
users.value = data.items || []
|
|
} catch (e) {
|
|
actionError.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
```
|
|
|
|
**Table with thead/tbody pattern** (lines 110-136 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```html
|
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="bg-gray-50 text-left">
|
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Timestamp</th>
|
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
|
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Action</th>
|
|
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">IP Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200">
|
|
<tr v-for="entry in entries" :key="entry.id" class="text-sm bg-white">
|
|
...
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
```
|
|
|
|
**Export button pattern** — triggers a window.location or fetch for CSV download:
|
|
```javascript
|
|
async function exportCsv() {
|
|
// Use window.location so the browser triggers a download
|
|
const params = new URLSearchParams(filterState)
|
|
window.location.href = `/api/admin/audit-log/export?format=csv&${params}`
|
|
}
|
|
```
|
|
|
|
**Filter state with reactive** (lines 223-232 of `frontend/src/components/admin/AdminUsersTab.vue`):
|
|
```javascript
|
|
const filterState = reactive({
|
|
start: '',
|
|
end: '',
|
|
user_id: '',
|
|
event_type: '',
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/layout/AppSidebar.vue` — extend with folders (modify)
|
|
|
|
**Analog:** Self. Extend the existing Topics section pattern (lines 36-53).
|
|
|
|
**Topics section pattern to replicate for Folders** (lines 36-53 of `frontend/src/components/layout/AppSidebar.vue`):
|
|
```html
|
|
<div class="mt-3">
|
|
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p>
|
|
<div v-if="topicsStore.loading" class="px-3 py-1 text-xs text-gray-400">Loading…</div>
|
|
<div v-else-if="topicsStore.topics.length === 0" class="px-3 py-1 text-xs text-gray-400">No topics yet</div>
|
|
<router-link
|
|
v-for="topic in topicsStore.topics"
|
|
:key="topic.id"
|
|
:to="`/topics/${encodeURIComponent(topic.name)}`"
|
|
class="nav-link text-sm"
|
|
>
|
|
<span class="w-2.5 h-2.5 rounded-full mr-2 shrink-0" :style="{ backgroundColor: topic.color }"></span>
|
|
<span class="truncate">{{ topic.name }}</span>
|
|
<span class="ml-auto text-xs text-gray-400">{{ topic.doc_count }}</span>
|
|
</router-link>
|
|
</div>
|
|
```
|
|
|
|
**"Shared with me" entry** — fixed above folder list, inbox icon, doc count badge:
|
|
```html
|
|
<button
|
|
@click="navigateSharedWithMe"
|
|
class="nav-link text-sm w-full"
|
|
:class="{ 'nav-link-active': isSharedWithMeActive }"
|
|
>
|
|
<!-- inbox icon -->
|
|
<span class="truncate">Shared with me</span>
|
|
<span v-if="sharedCount > 0" class="ml-auto text-xs bg-indigo-100 text-indigo-700 rounded-full px-1.5">{{ sharedCount }}</span>
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/components/documents/DocumentCard.vue` — add share button (modify)
|
|
|
|
**Analog:** Self. Add an inline icon button following existing SVG icon pattern.
|
|
|
|
**Existing SVG icon button pattern** (lines 8-13 of `frontend/src/components/documents/DocumentCard.vue`):
|
|
```html
|
|
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0 mt-0.5">
|
|
<svg class="w-5 h-5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path .../>
|
|
</svg>
|
|
</div>
|
|
```
|
|
|
|
**Share button placement** — add a small icon button in the card's top-right (stop propagation on card click):
|
|
```html
|
|
<button
|
|
@click.stop="showShareModal = true"
|
|
class="ml-auto text-gray-400 hover:text-indigo-500 transition-colors shrink-0"
|
|
title="Share document"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<ShareModal v-if="showShareModal" :doc="doc" @close="showShareModal = false" />
|
|
```
|
|
|
|
**is_shared indicator badge** — add shared badge if `doc.is_shared`:
|
|
```html
|
|
<span v-if="doc.is_shared" class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-600">
|
|
Shared
|
|
</span>
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/stores/documents.js` — extend with folder/search/sharing actions (modify)
|
|
|
|
**Analog:** Self. Follow existing action pattern from `fetchDocuments`, `upload`, `remove`.
|
|
|
|
**fetchDocuments extension** (add `folderId`, `q` params — lines 38-50):
|
|
```javascript
|
|
async function fetchDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null } = {}) {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const data = await api.listDocuments({ topic, page, perPage, folderId, q })
|
|
documents.value = data.items
|
|
total.value = data.total
|
|
} catch (e) {
|
|
error.value = e.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
```
|
|
|
|
**Debounced search pattern** (RESEARCH.md Pattern 10 — no external dependency):
|
|
```javascript
|
|
const searchQuery = ref('')
|
|
let _searchTimer = null
|
|
|
|
watch(searchQuery, (newVal) => {
|
|
clearTimeout(_searchTimer)
|
|
if (newVal.length < 2) {
|
|
fetchDocuments({ folderId: currentFolderId.value })
|
|
return
|
|
}
|
|
_searchTimer = setTimeout(() => {
|
|
fetchDocuments({ q: newVal, folderId: currentFolderId.value })
|
|
}, 300)
|
|
})
|
|
```
|
|
|
|
**New share actions** — follow same try/catch pattern as `remove` (lines 112-119):
|
|
```javascript
|
|
async function shareDocument(docId, recipientHandle) {
|
|
return await api.createShare(docId, recipientHandle)
|
|
}
|
|
|
|
async function revokeShare(shareId) {
|
|
await api.deleteShare(shareId)
|
|
}
|
|
|
|
async function listShares(docId) {
|
|
return await api.listShares(docId)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/views/HomeView.vue` — wire folder navigation + breadcrumb (modify)
|
|
|
|
**Analog:** Self. Follow existing `onMounted` + store composition pattern (lines 27-62).
|
|
|
|
**onMounted store init pattern** (lines 39-40 of `frontend/src/views/HomeView.vue`):
|
|
```javascript
|
|
onMounted(() => docsStore.fetchDocuments())
|
|
```
|
|
Extend to:
|
|
```javascript
|
|
onMounted(async () => {
|
|
await foldersStore.fetchFolders(null) // root-level folders
|
|
await docsStore.fetchDocuments({ folderId: null })
|
|
})
|
|
```
|
|
|
|
**Store composition pattern** (lines 31-37 of `frontend/src/views/HomeView.vue`):
|
|
```javascript
|
|
const docsStore = useDocumentsStore()
|
|
const topicsStore = useTopicsStore()
|
|
const uploadQueue = ref([])
|
|
```
|
|
Add:
|
|
```javascript
|
|
const foldersStore = useFoldersStore()
|
|
const currentFolderId = ref(null)
|
|
const breadcrumb = ref([])
|
|
```
|
|
|
|
---
|
|
|
|
### `frontend/src/views/SettingsView.vue` — add PDF preference toggle (modify)
|
|
|
|
**Analog:** Self (currently a static placeholder). Follow `frontend/src/components/admin/AdminAiConfigTab.vue` for toggle/radio section pattern.
|
|
|
|
**Existing static section pattern** (lines 6-11 of `frontend/src/views/SettingsView.vue`):
|
|
```html
|
|
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
|
<h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
|
|
<p class="text-sm text-gray-600">AI provider and model are managed by your administrator.</p>
|
|
</section>
|
|
```
|
|
|
|
**Add new section for PDF preference:**
|
|
```html
|
|
<section class="bg-white border border-gray-200 rounded-xl p-6 mt-4">
|
|
<h3 class="text-xl font-semibold text-gray-800 mb-2">Document preferences</h3>
|
|
<p class="text-sm text-gray-600 mb-4">Choose how PDF documents open when you click them.</p>
|
|
<div class="space-y-2">
|
|
<label class="flex items-center gap-3 cursor-pointer">
|
|
<input type="radio" v-model="pdfOpenMode" value="in_app" class="..." />
|
|
<span class="text-sm text-gray-700">Open documents in-app</span>
|
|
</label>
|
|
<label class="flex items-center gap-3 cursor-pointer">
|
|
<input type="radio" v-model="pdfOpenMode" value="new_tab" class="..." />
|
|
<span class="text-sm text-gray-700">Open documents in new tab</span>
|
|
</label>
|
|
</div>
|
|
<p v-if="saveError" class="text-xs text-red-600 mt-2">{{ saveError }}</p>
|
|
</section>
|
|
```
|
|
|
|
**Script setup with watch for auto-save:**
|
|
```javascript
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import * as api from '../api/client.js'
|
|
|
|
const pdfOpenMode = ref('in_app')
|
|
const saveError = ref(null)
|
|
|
|
onMounted(async () => {
|
|
const prefs = await api.getMyPreferences()
|
|
pdfOpenMode.value = prefs.pdf_open_mode
|
|
})
|
|
|
|
watch(pdfOpenMode, async (newVal) => {
|
|
try {
|
|
await api.updateMyPreferences({ pdf_open_mode: newVal })
|
|
} catch (e) {
|
|
saveError.value = e.message
|
|
}
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### Authentication / Authorization Guard
|
|
**Source:** `backend/deps/auth.py`
|
|
**Apply to:** All new backend endpoints in `api/folders.py`, `api/shares.py`; streaming proxy in `api/documents.py`
|
|
```python
|
|
# get_regular_user — for all document/folder/share/proxy endpoints
|
|
# Returns 403 for admin role (CLAUDE.md architectural rule)
|
|
current_user: User = Depends(get_regular_user)
|
|
|
|
# get_current_admin — for all audit log endpoints in api/audit.py
|
|
_admin: User = Depends(get_current_admin)
|
|
```
|
|
|
|
### Ownership Assertion (404-not-403)
|
|
**Source:** `backend/api/documents.py` lines 117-119
|
|
**Apply to:** All folder CRUD, document move, share grant/revoke, streaming proxy
|
|
```python
|
|
resource = await session.get(ModelClass, resource_id)
|
|
if resource is None or resource.user_id != current_user.id:
|
|
raise HTTPException(status_code=404, detail="<Resource> not found")
|
|
```
|
|
|
|
### UUID Parse + 404
|
|
**Source:** `backend/api/documents.py` lines 113-115
|
|
**Apply to:** All path-parameter ID handlers in `api/folders.py` and `api/shares.py`
|
|
```python
|
|
try:
|
|
uid = uuid.UUID(resource_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="<Resource> not found")
|
|
```
|
|
|
|
### Atomic Quota UPDATE
|
|
**Source:** `backend/api/documents.py` lines 137-146 (increment) and `backend/services/storage.py` (decrement)
|
|
**Apply to:** `api/folders.py` DELETE cascade-delete (decrement), existing confirm endpoint (increment)
|
|
```python
|
|
# Decrement pattern for folder cascade-delete:
|
|
await session.execute(
|
|
text(
|
|
"UPDATE quotas SET used_bytes = "
|
|
"CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
|
|
"WHERE user_id = :uid"
|
|
),
|
|
{"delta": total_bytes, "uid": str(current_user.id)},
|
|
)
|
|
```
|
|
|
|
### asyncio.to_thread() for Sync MinIO SDK
|
|
**Source:** `backend/storage/minio_backend.py` lines 78-86 and `backend/tasks/document_tasks.py` lines 62-66
|
|
**Apply to:** `backend/storage/minio_backend.py` new `put_object_raw()` method; streaming proxy `get_object()` call
|
|
```python
|
|
await asyncio.to_thread(
|
|
self._client.put_object,
|
|
bucket,
|
|
key,
|
|
data,
|
|
length=length,
|
|
content_type=content_type,
|
|
)
|
|
```
|
|
|
|
### Pydantic Whitelist Response Helper
|
|
**Source:** `backend/api/admin.py` lines 54-69 (`_user_to_dict`)
|
|
**Apply to:** `backend/api/audit.py` (`_audit_to_dict`), `backend/api/admin.py` new `CloudConnectionOut` model (SEC-08)
|
|
```python
|
|
def _audit_to_dict(entry: AuditLog) -> dict:
|
|
# Must NEVER include: filename, extracted_text, file bytes, credentials_enc
|
|
return { "id": entry.id, "event_type": entry.event_type, ... }
|
|
```
|
|
|
|
### write_audit_log Call Site Pattern
|
|
**Source:** `backend/services/audit.py` (new) — called inline after each successful operation
|
|
**Apply to:** `api/folders.py`, `api/shares.py`, `api/documents.py` (upload/delete/proxy), `api/auth.py` (login/logout/etc.), `api/admin.py` (user create/deactivate/quota change)
|
|
```python
|
|
# After session.commit() for the primary operation:
|
|
await write_audit_log(
|
|
session,
|
|
event_type="folder.created",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=folder.id,
|
|
ip_address=request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None),
|
|
metadata_={"name": folder.name, "parent_id": str(folder.parent_id) if folder.parent_id else None},
|
|
)
|
|
```
|
|
|
|
### Pinia Store defineStore Pattern
|
|
**Source:** `frontend/src/stores/topics.js` and `frontend/src/stores/documents.js`
|
|
**Apply to:** `frontend/src/stores/folders.js`
|
|
```javascript
|
|
export const useFoldersStore = defineStore('folders', () => {
|
|
const state = ref([])
|
|
const loading = ref(false)
|
|
const error = ref(null)
|
|
|
|
async function action() {
|
|
loading.value = true
|
|
error.value = null
|
|
try { ... } catch (e) { error.value = e.message } finally { loading.value = false }
|
|
}
|
|
|
|
return { state, loading, error, action }
|
|
})
|
|
```
|
|
|
|
### api/client.js Export Function Pattern
|
|
**Source:** `frontend/src/api/client.js` — `request()` helper with auth header injection and 401 retry
|
|
**Apply to:** All new API functions for folders, shares, audit log, preferences, search
|
|
```javascript
|
|
// Pattern: named export function calling request()
|
|
export function listFolders(parentId = null) {
|
|
const params = new URLSearchParams()
|
|
if (parentId) params.set('parent_id', parentId)
|
|
return request(`/api/folders?${params}`)
|
|
}
|
|
|
|
export function createShare(docId, recipientHandle) {
|
|
return request('/api/shares', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ document_id: docId, recipient_handle: recipientHandle }),
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## No Analog Found
|
|
|
|
| File | Role | Data Flow | Reason |
|
|
|---|---|---|---|
|
|
| `frontend/src/components/layout/BreadcrumbNav.vue` | component | request-response | No existing breadcrumb component; closest analog is TopicBadge (pure display) but breadcrumb has click-navigation behavior. See Shared Patterns for Tailwind style source. |
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:** `backend/api/`, `backend/services/`, `backend/tasks/`, `backend/migrations/`, `backend/storage/`, `frontend/src/stores/`, `frontend/src/components/`, `frontend/src/views/`, `frontend/src/api/`
|
|
**Files scanned:** 35
|
|
**Pattern extraction date:** 2026-05-25
|