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>
40 KiB
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):
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):
@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):
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):
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:
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):
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):
class UploadUrlRequest(BaseModel):
filename: str
content_type: str
Apply as:
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:
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):
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):
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):
# 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):
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):
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):
@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):
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:
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):
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:
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):
@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):
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):
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):
# 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):
"""
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):
@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:
@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):
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):
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):
# 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):
"""<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):
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):
# 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):
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):
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):
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):
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):
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):
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):
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):
from fastapi import Request
from fastapi.responses import StreamingResponse
from sqlalchemy import func
Streaming proxy endpoint pattern (RESEARCH.md Pattern 3):
@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):
# 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):
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):
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):
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):
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:
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):
<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):
<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):
const props = defineProps({
doc: Object,
})
Apply as:
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):
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):
.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):
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):
<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:
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):
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):
<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:
<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):
<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):
<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:
<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):
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):
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):
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):
onMounted(() => docsStore.fetchDocuments())
Extend to:
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):
const docsStore = useDocumentsStore()
const topicsStore = useTopicsStore()
const uploadQueue = ref([])
Add:
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):
<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:
<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:
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
# 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
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
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)
# 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
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)
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)
# 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
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
// 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