# 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
