# 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 """. Revision ID: 0004 Revises: 0003 Create 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
...

Not shared with anyone yet.

{{ share.recipient_handle }}
``` **Inline confirm pattern** (lines 151-168 of `frontend/src/components/admin/AdminUsersTab.vue`): ```html

Deactivate ...? ...

``` --- ### `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
...
Timestamp User Action IP Address
``` **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

Topics

Loading…
No topics yet
{{ topic.name }} {{ topic.doc_count }}
``` **"Shared with me" entry** — fixed above folder list, inbox icon, doc count badge: ```html ``` --- ### `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
``` **Share button placement** — add a small icon button in the card's top-right (stop propagation on card click): ```html ``` **is_shared indicator badge** — add shared badge if `doc.is_shared`: ```html Shared ``` --- ### `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

AI configuration

AI provider and model are managed by your administrator.

``` **Add new section for PDF preference:** ```html

Document preferences

Choose how PDF documents open when you click them.

{{ saveError }}

``` **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=" 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=" 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