docs: research milestone v0.3 cloud storage integration

This commit is contained in:
curo1305
2026-06-17 23:19:01 +02:00
parent 0e56c85349
commit 9150d28fe1
5 changed files with 219 additions and 1430 deletions
+63 -480
View File
@@ -1,508 +1,91 @@
# Architecture Patterns
# v0.3 Research: Architecture
**Domain:** v0.2 UI Overhaul and Optimization — integration analysis for existing DocuVault codebase
**Researched:** 2026-06-07
**Milestone:** v0.3 Reimagining Cloud Storage integration
**Date:** 2026-06-17
---
## Target Model
## How the Layout System Currently Works
Cloud files become virtual documents: provider-owned bytes remain in the connected storage, while DocuVault stores metadata, extracted text, topics, embeddings/search data, status, and a bounded cache of temporary bytes.
App.vue is the layout switch. It checks `route.meta.layout` and renders one of two branches:
## Proposed Components
```
route.meta.layout === 'auth' → <AuthLayout /> (renders <router-view /> inside a centered card)
(anything else) → AppSidebar + <main><router-view /></main>
```
### Provider Capability Layer
There are currently only two layouts:
- **AuthLayout.vue** — centered card, no sidebar, used for login/register/password-reset
- **Implicit main layout** — App.vue's `v-else` branch: `<AppSidebar>` + scrollable main
Add a cloud file operation interface separate from the current object-storage `StorageBackend` contract:
The admin panel (`/admin`) currently falls into the `v-else` branch. AdminView.vue renders inside the main user sidebar, inheriting AppSidebar's folder tree, quota bar, and user nav — none of which are relevant to an admin.
- `list_folder`
- `get_metadata`
- `download_bytes` / streaming equivalent
- `upload_file`
- `rename_item`
- `move_item`
- `delete_item`
- `create_folder`
- `get_change_cursor` / `list_changes` where available
- `capabilities()` returning action support and limitations
---
This avoids forcing local/MinIO object semantics onto provider file-manager behavior.
## Integration Point 1: FastAPI Admin Router Decomposition
### Cloud Item Index
### Current state
Add durable tables for cloud-visible items and analysis/index state:
`backend/api/admin.py` is a single file with `router = APIRouter(prefix="/api/admin")`. It is registered in `main.py` as:
- `cloud_items`: user, connection, provider, provider item id/path, parent id/path, name, kind, MIME/content type, size, etag/version, mtime, path snapshot, last_seen_at, deleted_at.
- `cloud_item_analysis`: item, status, extracted_text, topic links, semantic index status, last_analyzed_etag, error, job id, timestamps.
- Optional `cloud_file_cache`: item, etag/version, byte location, size, last_accessed_at, expires_at, pin reason, eviction state.
```python
from api.admin import router as admin_router
app.include_router(admin_router)
```
Keep `documents` for DocuVault-owned local/imported files unless a later design intentionally merges local and cloud into one polymorphic table.
The file mixes four concern areas:
- User management (CRUD on users, status, password reset)
- Quota management (get/patch quota per user)
- AI provider configuration (system-level: get, put, test-connection, get-models)
- Audit log was already extracted to `api/audit.py` in v0.1
### API Shape
### Target structure
Split `backend/api/cloud.py` when implementation starts:
```
backend/api/admin/
__init__.py
users.py — /api/admin/users endpoints
quotas.py — /api/admin/users/{id}/quota endpoints
ai.py — /api/admin/ai-config endpoints
```
- `api/cloud/connections.py`
- `api/cloud/browse.py`
- `api/cloud/actions.py`
- `api/cloud/analysis.py`
- `api/cloud/search.py` or reuse a shared search API
- shared schemas in `api/schemas.py` or `api/cloud/schemas.py`
### How to register without changing URL prefix
Every endpoint must assert `connection.user_id == current_user.id` and `cloud_item.user_id == current_user.id`.
FastAPI supports two-level include: a parent router with the shared prefix, sub-routers without prefix. This is the correct pattern:
### Frontend Shape
```python
# backend/api/admin/__init__.py
from fastapi import APIRouter
from .users import router as users_router
from .quotas import router as quotas_router
from .ai import router as ai_router
`FileManagerView` and `CloudFolderView` remain thin data providers. `StorageBrowser.vue` becomes capability-aware:
router = APIRouter(prefix="/api/admin", tags=["admin"])
router.include_router(users_router)
router.include_router(quotas_router)
router.include_router(ai_router)
```
- accepts `capabilities` prop
- shows action buttons based on capabilities
- emits generic events for open/upload/rename/move/delete/analyze
- displays status badges for cached/indexed/analyzing/stale/error
Each sub-router declares its routes WITHOUT a prefix (no `prefix=` arg):
Cloud-specific stores own provider state and API calls; `StorageBrowser` stays store-agnostic.
```python
# backend/api/admin/users.py
router = APIRouter()
### Byte Cache and Offload
@router.get("/users") # resolves to /api/admin/users
@router.post("/users") # resolves to /api/admin/users
@router.patch("/users/{id}/status")
...
```
Cache flow:
`main.py` changes from:
1. User opens/previews/analyzes cloud item.
2. Backend verifies ownership and provider metadata.
3. Backend downloads bytes into a bounded cache or streams through memory if small.
4. Extraction/preview reads from cache.
5. Cache entry records item id, etag/version, byte size, last access, expiry.
6. Eviction removes bytes once unpinned and stale or over budget.
```python
from api.admin import router as admin_router
app.include_router(admin_router)
```
The cache is not the source of truth. Provider metadata and extracted/indexed data are.
to the identical line — because `api/admin/__init__.py` exports `router` with the same name. **No URL changes. No consumer changes.**
### Analysis Flow
### What moves where
1. User selects file/folder/provider and clicks Analyze.
2. Backend resolves selection into cloud items, stores/updates metadata rows, and enqueues Celery jobs.
3. Jobs hydrate bytes as needed, extract text, classify topics, optionally create embeddings, and persist analysis state.
4. Search reads only persisted text/vector/topic metadata; it should not download cloud bytes during query.
5. Opening a result hydrates bytes on demand if preview/download needs them.
| Endpoints | Target file | Pydantic models that move with it |
|---|---|---|
| GET/POST /users, PATCH /users/{id}/status, POST /users/{id}/password-reset, DELETE /users/{id} | `admin/users.py` | `UserCreate`, `UserStatusUpdate`, `UserDeleteConfirm`, `_user_to_dict` |
| GET/PATCH /users/{id}/quota, PATCH /users/{id}/ai-config | `admin/quotas.py` | `QuotaUpdate`, `UserAiConfigUpdate` |
| GET/PUT /ai-config, POST /ai-config/test-connection, GET /ai-config/models | `admin/ai.py` | `SystemAiConfigUpdate`, `TestConnectionRequest`, `_ai_config_to_dict` |
## Build Order
`POST /topics` (create system topic) stays in `admin/users.py` — it is short and has no unique models. Alternatively it can go in its own `admin/topics.py`; a decision either way is fine.
### Shared helpers
`_DEFAULT_QUOTA_BYTES`, `CloudConnectionOut`, and any imports shared across sub-routers can live in `backend/api/admin/shared.py` and be imported by each sub-router. Do not duplicate them.
### Files changed
| File | Change type |
|---|---|
| `backend/api/admin.py` | DELETED — replaced by package |
| `backend/api/admin/__init__.py` | CREATED — aggregating router |
| `backend/api/admin/users.py` | CREATED |
| `backend/api/admin/quotas.py` | CREATED |
| `backend/api/admin/ai.py` | CREATED |
| `backend/api/admin/shared.py` | CREATED (optional, holds shared constants/helpers) |
| `backend/main.py` | NO CHANGE — import path `api.admin.router` remains valid |
---
## Integration Point 2: Vue Admin Layout
### Current layout switch
`App.vue`:
```html
<AuthLayout v-if="route.meta.layout === 'auth'" />
<div v-else class="flex h-screen overflow-hidden">
<AppSidebar />
<main class="flex-1 overflow-y-auto">
<router-view />
</main>
</div>
```
### How to add AdminLayout without breaking anything
Extend the `v-if` chain to a three-way switch using `route.meta.layout`:
```html
<AuthLayout v-if="route.meta.layout === 'auth'" />
<AdminLayout v-else-if="route.meta.layout === 'admin'" />
<div v-else class="flex h-screen overflow-hidden">
<AppSidebar />
<main class="flex-1 overflow-y-auto">
<router-view />
</main>
</div>
```
`AdminLayout.vue` renders its own sidebar and a `<router-view />`. It is structurally identical in shape to the main layout but with admin-specific nav items.
### Router changes
Current single admin route:
```js
{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } }
```
Replace with a route subtree:
```js
{
path: '/admin',
component: AdminLayout,
meta: { requiresAdmin: true, layout: 'admin' },
children: [
{ path: '', redirect: '/admin/users' },
{ path: 'users', component: () => import('../views/admin/AdminUsersView.vue') },
{ path: 'quotas', component: () => import('../views/admin/AdminQuotasView.vue') },
{ path: 'ai', component: () => import('../views/admin/AdminAiView.vue') },
{ path: 'audit', component: () => import('../views/admin/AdminAuditView.vue') },
],
}
```
### Meta inheritance caveat
Vue Router 4 does NOT automatically merge `meta` from parent to children — `route.meta` on a child route only contains what is declared on that child. `App.vue` currently reads `route.meta.layout` directly. For the child routes to inherit `layout: 'admin'`, the check in App.vue must walk `route.matched`:
```js
// App.vue — layout resolution
const layout = computed(() => route.matched.find(r => r.meta.layout)?.meta.layout)
```
Likewise, the navigation guard in `router/index.js` must change from `to.meta.requiresAdmin` to `to.matched.some(r => r.meta.requiresAdmin)`. The current guard already handles `.meta.public` the same way — so this is a one-line change pattern that's already established in the router.
### AdminLayout.vue structure
```
AdminLayout.vue
└── AdminSidebar.vue (nav links: Users, Quotas, AI Config, Audit Log)
└── <router-view />
```
`AdminSidebar.vue` is NOT `AppSidebar.vue`. Do not reuse or extend `AppSidebar`. The admin sidebar is a new, independent component with admin-specific nav.
### Views created
Each current tab component becomes a thin view wrapper (following the View -> Smart -> Presentational hierarchy):
| New file | Wraps existing component |
|---|---|
| `views/admin/AdminUsersView.vue` | `AdminUsersTab.vue` (kept unchanged as smart component) |
| `views/admin/AdminQuotasView.vue` | `AdminQuotasTab.vue` |
| `views/admin/AdminAiView.vue` | `AdminAiConfigTab.vue` |
| `views/admin/AdminAuditView.vue` | `AuditLogTab.vue` |
The existing tab components (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, etc.) are NOT renamed or restructured in this step. They become the smart components rendered inside the new thin view wrappers.
### Files changed
| File | Change type |
|---|---|
| `frontend/src/App.vue` | MODIFIED — add `v-else-if` for admin layout; meta layout resolution uses `route.matched` |
| `frontend/src/router/index.js` | MODIFIED — /admin replaced with children subtree; guard uses `to.matched` |
| `frontend/src/layouts/AdminLayout.vue` | CREATED |
| `frontend/src/components/layout/AdminSidebar.vue` | CREATED |
| `frontend/src/views/AdminView.vue` | DELETED — replaced by subtree |
| `frontend/src/views/admin/AdminUsersView.vue` | CREATED (thin wrapper) |
| `frontend/src/views/admin/AdminQuotasView.vue` | CREATED (thin wrapper) |
| `frontend/src/views/admin/AdminAiView.vue` | CREATED (thin wrapper) |
| `frontend/src/views/admin/AdminAuditView.vue` | CREATED (thin wrapper) |
| `frontend/src/components/admin/AdminUsersTab.vue` | KEPT (smart component, unchanged) |
| `frontend/src/components/admin/AdminQuotasTab.vue` | KEPT |
| `frontend/src/components/admin/AdminAiConfigTab.vue` | KEPT |
| `frontend/src/components/admin/AuditLogTab.vue` | KEPT |
---
## Integration Point 3: client.js Decomposition
### Current import pattern
Every consumer does one of:
```js
import * as api from '../api/client.js' // namespace import — stores, views, most components
import { specificFn } from '../api/client.js' // named import — a few components
```
35+ files import from `client.js`. A decomposition that changes the import path in all 35 files is a large, purely mechanical refactor with meaningful test/review cost for zero user-visible benefit.
### Minimal, non-breaking approach: barrel re-export
Create domain modules alongside `client.js`, then have `client.js` re-export everything so no consumer changes:
```
frontend/src/api/
client.js — request() transport function + re-exports from domain files
documents.js — document API calls (import request from './client.js')
topics.js
auth.js
admin.js
folders.js
shares.js
cloud.js
```
`client.js` becomes the HTTP transport layer and a re-export barrel:
```js
// client.js (after decomposition) — transport only + re-exports
async function request(path, options = {}) { ... }
export async function fetchDocumentContent(docId, options = {}) { ... }
export * from './documents.js'
export * from './topics.js'
export * from './auth.js'
export * from './admin.js'
export * from './folders.js'
export * from './shares.js'
export * from './cloud.js'
```
Each domain file imports `request` from `client.js`:
```js
// documents.js
import { request } from './client.js'
export function listDocuments(...) { return request('/api/documents?...') }
```
This means `import * as api from '../api/client.js'` continues to work for all 35 existing consumers. The decomposition is fully internal.
### Test mock compatibility
Existing Vitest tests mock `api/client.js` at the module level (`vi.mock('../../api/client.js')`). Because the re-exports are in `client.js`, mocking `client.js` still intercepts all domain functions. No test changes needed.
### Files changed
| File | Change type |
|---|---|
| `frontend/src/api/client.js` | MODIFIED — split out domain functions, keep re-exports |
| `frontend/src/api/documents.js` | CREATED |
| `frontend/src/api/topics.js` | CREATED |
| `frontend/src/api/auth.js` | CREATED (name does not conflict with `stores/auth.js` — different path) |
| `frontend/src/api/admin.js` | CREATED |
| `frontend/src/api/folders.js` | CREATED |
| `frontend/src/api/shares.js` | CREATED |
| `frontend/src/api/cloud.js` | CREATED |
| All 35 consumer files | NO CHANGE |
---
## Integration Point 4: Icon Strategy
### Current state
There are 58 inline `<svg>` blocks in `frontend/src/components/` and 8 more in `frontend/src/views/`. Each is a 3-6 line block with `stroke`, `viewBox`, `class`, and a `<path>`. The same path data appears multiple times for the same icon (e.g., the document icon in the sidebar, document card, and document view are separate copies of the same path string).
### Options assessed
**Option A: SVG sprite sheet**
A `<symbol>`-based sprite in `index.html` with `<use href="#icon-name" />` in components. Minimal bundle impact, no component overhead. Drawback: requires a build step or manual maintenance of the sprite file; poor DX for adding new icons during the visual redesign.
**Option B: `AppIcon.vue` component with a `name` prop**
A Vue component that maps `name` to an inline `<svg>` block. All SVG paths live in one file. Zero external dependencies. Easy to add icons.
**Option C: lucide-vue-next**
Purpose-built icon library for Tailwind projects. Tree-shakeable individual icon components: `<FileText class="w-4 h-4" />`. Maintained, typed, comprehensive. Avoids maintaining path data entirely.
**Recommendation: Option B (`AppIcon.vue`) unless a dependency is explicitly approved.**
The project already has 66 SVG paths hand-chosen to match the visual style. Switching to lucide means auditing that every lucide icon matches the expected visual. During a visual redesign milestone that may be desirable, but it is a separate decision. The minimal refactor that eliminates duplication without changing visual output is Option B.
**Named slots are wrong for this problem.** Named slots solve "inject different markup into different zones of a layout". This problem is "render the correct path data for a given icon name". A `name` prop is the correct API.
**Option B implementation:**
```
frontend/src/components/ui/AppIcon.vue
```
```html
<template>
<svg :class="$attrs.class" fill="none" stroke="currentColor" viewBox="0 0 24 24"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="paths[name]" />
</svg>
</template>
<script>
export default {
inheritAttrs: false,
props: { name: { type: String, required: true } },
data: () => ({
paths: {
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5...',
folder: 'M3 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v...',
// etc.
}
})
}
</script>
```
Usage: `<AppIcon name="document" class="w-4 h-4 mr-2 shrink-0" />`
### Files changed (Option B)
| File | Change type |
|---|---|
| `frontend/src/components/ui/AppIcon.vue` | CREATED |
| ~35 components/views with inline SVGs | MODIFIED — replace `<svg>...</svg>` with `<AppIcon name="..." class="..." />` |
---
## Suggested Build Order
Dependencies between the four changes determine order. A change that other changes depend on must land first.
```
Step 1 — Backend admin router decomposition
Step 2 — API client decomposition (client.js -> domain files)
Step 3 — Admin layout + route subtree (frontend)
Step 4 — Icon refactor
```
### Rationale
**Step 1 (backend) first:**
- No frontend dependencies. All existing tests pass with zero behavior change (same URLs, same auth, same import path in `main.py`).
- Risk: lowest. Pure internal restructuring.
**Step 2 (client.js) second:**
- No dependency on the admin layout.
- New admin view files created in Step 3 can import from `api/admin.js` specifically rather than the monolith.
- Internal-only change. All 35 consumer files unchanged. All existing mock-based tests unchanged.
- Risk: low.
**Step 3 (admin layout + route subtree) third:**
- Highest risk: modifies `App.vue` (central layout switch), `router/index.js` (all navigation), creates new layout files.
- The existing smart components (`AdminUsersTab.vue` etc.) are not touched — they are simply wrapped by new thin view files.
- Navigation guard logic for `requiresAdmin` and `layout` meta must be re-tested after this step.
- Risk: medium.
**Step 4 (icon refactor) last:**
- Pure cosmetic. Zero behavior change.
- Can be done incrementally (file by file) without blocking anything.
- Risk: lowest.
### Parallelism
Steps 1 and 2 are independent and can be executed simultaneously — they touch no shared files.
Steps 3 and 4 overlap on the admin component files (icons are replaced in Step 4, structure is set in Step 3). Complete Step 3 first to avoid merge conflicts.
---
## Component Architecture: Existing vs. Target
### Current (wrong)
```
App.vue
└── AppSidebar (always present)
└── AdminView.vue (tabs rendered in user sidebar context)
└── AdminUsersTab.vue
└── AdminQuotasTab.vue
└── AdminAiConfigTab.vue
└── AuditLogTab.vue
```
### Target (correct)
```
App.vue (layout switch)
└── AuthLayout.vue (for meta.layout === 'auth')
└── AdminLayout.vue (for meta.layout === 'admin')
└── AdminSidebar.vue
└── <router-view />
→ AdminUsersView.vue (thin — passes props to AdminUsersTab)
→ AdminQuotasView.vue
→ AdminAiView.vue
→ AdminAuditView.vue
└── Implicit main layout (v-else)
└── AppSidebar.vue
└── <router-view />
→ FileManagerView, DocumentView, TopicsView, ...
```
The smart components (`AdminUsersTab.vue` etc.) are unchanged. The view layer above them is what changes.
---
## What Is New vs. Modified: Complete Inventory
### New files
```
backend/api/admin/__init__.py
backend/api/admin/users.py
backend/api/admin/quotas.py
backend/api/admin/ai.py
backend/api/admin/shared.py (optional — shared constants/helpers)
frontend/src/layouts/AdminLayout.vue
frontend/src/components/layout/AdminSidebar.vue
frontend/src/views/admin/AdminUsersView.vue
frontend/src/views/admin/AdminQuotasView.vue
frontend/src/views/admin/AdminAiView.vue
frontend/src/views/admin/AdminAuditView.vue
frontend/src/api/documents.js
frontend/src/api/topics.js
frontend/src/api/auth.js
frontend/src/api/admin.js
frontend/src/api/folders.js
frontend/src/api/shares.js
frontend/src/api/cloud.js
frontend/src/components/ui/AppIcon.vue (Option B only)
```
### Modified files
```
backend/main.py (no functional change — import stays identical)
frontend/src/App.vue (add v-else-if; use route.matched for layout)
frontend/src/router/index.js (admin route becomes children subtree; guard uses to.matched)
frontend/src/api/client.js (gutted to transport + re-exports)
All ~35 components with inline SVGs (icons replaced with AppIcon or lucide)
```
### Deleted files
```
backend/api/admin.py (replaced by package)
frontend/src/views/AdminView.vue (replaced by route subtree)
```
---
## Minimal vs. Proper Assessment
| Change | Is the minimal solution also the proper one? |
|---|---|
| Backend decomposition | Yes. Sub-router via `__init__.py` aggregator is both minimal and the standard FastAPI pattern. |
| client.js decomposition | Yes. Re-export barrel means zero consumer churn while achieving internal separation. |
| Admin layout | Yes. Three-way `v-if` in App.vue + new layout file is 20 lines of change in existing files. |
| Icon refactor | The minimal solution (AppIcon.vue) is simpler than lucide but adds maintenance burden for the path map. lucide is more proper but adds a dependency. Call must be made by project owner. |
---
## Sources
- Inspected source: `backend/main.py`, `backend/api/admin.py`, `frontend/src/App.vue`, `frontend/src/router/index.js`, `frontend/src/layouts/AuthLayout.vue`, `frontend/src/api/client.js`, `frontend/src/views/AdminView.vue`, `frontend/src/components/layout/AppSidebar.vue`
- FastAPI sub-router `include_router` nesting: standard FastAPI router composition (HIGH confidence)
- Vue Router 4 `route.matched` for meta inheritance: Vue Router 4 documented behavior (HIGH confidence)
- lucide-vue-next: tree-shakeable Vue 3 icon library (MEDIUM confidence — verified as active, Tailwind-compatible)
1. Capability model + provider action interface + tests.
2. Cloud item metadata/index schema + browse persistence.
3. Unified browser actions for cloud files/folders.
4. Cloud analysis jobs and cache/offload service.
5. Unified smart search over local and analyzed cloud documents.
6. Provider change detection, stale markers, and polish.
+54 -246
View File
@@ -1,257 +1,65 @@
# Feature Landscape — v0.2 UI Overhaul
# v0.3 Research: Features
**Domain:** Self-hosted SaaS document management (DocuVault)
**Researched:** 2026-06-07
**Scope:** UX patterns and UI feature decisions for v0.2. Existing functional features (upload, extraction, sharing, cloud backends, auth) are not re-researched — only their UX surface.
---
**Milestone:** v0.3 Reimagining Cloud Storage integration
**Date:** 2026-06-17
## Table Stakes
Features users expect. Missing = product feels incomplete or broken.
### Cloud-as-Local File Management
| Feature | Why Expected | Complexity | Depends On |
|---------|--------------|------------|------------|
| Standalone admin layout (`/admin/*` routes with own sidebar) | AdminView.vue is currently a tab sheet bolted to the user layout. Admin and user contexts share no chrome. Nextcloud, Mayan EDMS, and every self-hosted SaaS treat admin as a distinct area. | Medium | Router nested routes, new `AdminLayout.vue` |
| Admin sidebar with discrete nav items (Users, Quotas, AI Config, Audit Log) | Each admin tab is already a full standalone component. Route-based nav replaces in-view tabs — enables deep linking, browser back button, and future per-section permissions. | Low | Vue Router `/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit` child routes |
| Empty states for every zero-content context | File list empty, folder empty, search no-results, shared-with-me empty, topics empty — each needs a distinct, actionable state. Current implementation is plain gray text. Zero-content states are the first thing new users see. | Medium | `StorageBrowser.vue`, `SharedView.vue`, `TopicsView.vue` |
| Loading skeletons for document list and sidebar tree | The document list (`StorageBrowser`) and sidebar folder tree currently render a plain "Loading…" text string. Skeletons reduce perceived load time by 3050% on equivalent wait times per usability studies. The `QuotaBar` already uses `animate-pulse` — extend that pattern. | Medium | `StorageBrowser.vue`, `AppSidebar.vue`, new `SkeletonRow.vue` |
| Global search focus shortcut (`/` key) | Table stakes for document management tools. Paperless-ngx, Notion, GitHub, Linear all use `/` to focus search. Users who type `/` and nothing happens feel the app is unpolished. | Low | `SearchBar.vue`, `FileManagerView.vue` global `keydown` listener |
| Escape key closes modals and clears search | Universal browser convention. Escape should: close `ShareModal`, close `FolderDeleteModal`, close `DocumentPreviewModal`, clear the inline folder rename input, and clear search field. Several of these already handle `keydown.escape` in inputs but not at the modal overlay level. | Low | `ShareModal.vue`, `FolderDeleteModal.vue`, `DocumentPreviewModal.vue` |
| Sidebar collapses on mobile (hamburger toggle, overlay drawer) | On viewports below 1024px the fixed 256px sidebar eats 2540% of the viewport. The current layout has no mobile handling — sidebar overlaps content on small screens. Minimum viable mobile: hamburger button + slide-in overlay drawer. | Medium | `AppSidebar.vue`, `App.vue`, new mobile toggle state |
| Responsive document list column hiding | `StorageBrowser` grid already uses `hidden md:block` for Size and `hidden sm:block` for Modified. This is correct — verify it works and document the pattern as intentional. Ensure the icon + name + actions columns always render. | Low | `StorageBrowser.vue` (verify existing classes) |
| Upload trigger keyboard shortcut (`U` key when no input is focused) | Secondary table stakes. Windows Explorer, Nextcloud, and Dropbox support pressing a key to trigger upload when focus is on the file list. Makes keyboard-first workflows viable. | Low | `DropZone.vue` or `FileManagerView.vue` global `keydown` listener |
| Visible drag-and-drop affordance on the drop zone at all times | `DropZone.vue` already exists and handles dragover/drop. The gap: the drop zone is a full-width dashed box inside the content area, which is correct, but there is no whole-page drag overlay when a user drags a file from the OS file manager. Users miss the target and the drop is ignored. | Medium | `FileManagerView.vue` document-level dragenter/dragleave listener |
| Contextual breadcrumb with admin section label | Admin subroutes need breadcrumbs: `Admin > Users`, `Admin > Audit Log`. Without them, admin pages feel unmoored. The user-facing `FolderBreadcrumb` is already built — admin can use the same component with static segments. | Low | `FolderBreadcrumb.vue`, `AdminLayout.vue` |
- Browse connected cloud folders with local-like row actions.
- Open/preview files through DocuVault authorization.
- Upload files into the visible cloud folder.
- Rename files and folders where provider supports it.
- Move files/folders within the same provider connection.
- Delete files/folders with explicit confirmation and audit logging.
- Create folders where provider supports it.
- Show unsupported actions disabled with a provider-aware reason.
---
### Provider Health and Maintenance
- Show connection health per provider.
- Detect expired/revoked credentials and guide reconnect.
- Invalidate folder/index caches after reconnect, delete, move, rename, or upload.
- Preserve credentials encryption and never expose secrets.
- Surface provider errors as actionable user-facing messages.
### Analyze Cloud Documents
- Analyze a selected file, selected folder, or whole provider connection.
- Track analysis progress: queued, downloading/caching, extracting, classifying, indexed, failed.
- Avoid duplicate analysis when provider item version/etag has not changed.
- Allow retry of failed items.
- Keep analysis non-destructive: analyzing a cloud file must not move or rewrite it.
### Smart Search Across Local and Cloud
- Search should cover local documents plus analyzed cloud documents.
- Keyword/sentence search uses extracted text.
- Idea/semantic search should be supported through embeddings if v0.3 includes semantic indexing.
- Search results identify source: local, OneDrive, Google Drive, Nextcloud, WebDAV.
- Opening a cloud result should hydrate bytes on demand if not cached.
### Storage Minimization
- Store provider metadata, extracted text, topics, and embeddings in DocuVault.
- Cache full file bytes only while needed for preview/extraction/open.
- Evict cached bytes by quota/TTL/LRU and never count transient cache bytes as user-owned imported storage.
- Keep an explicit user action for permanent import/copy to local DocuVault storage if implemented later.
## Differentiators
Features that add genuine value beyond the baseline. Not expected by most users, but valued when present.
- Virtual-local status indicators: online-only, cached, analyzing, indexed, stale, error.
- Folder-level analyze action with recursive selection preview.
- Change detection via provider deltas where available, falling back to scheduled metadata refresh for WebDAV.
- Unified local/cloud search with filters for source, provider, folder, topics, status, and analyzed/not analyzed.
- Conflict-aware operations when provider etag/version changes between list and mutate.
| Feature | Value Proposition | Complexity | Depends On |
|---------|-------------------|------------|------------|
| Whole-page drop zone (drag files anywhere on the screen to upload) | Removes the requirement to aim at the DropZone rectangle. Paperless-ngx supports this. Implementation: listen for `dragenter` at the document level, show a full-screen overlay with the drop target, then pipe the `drop` event into the existing upload emit chain. | Medium | `FileManagerView.vue`, new `PageDropOverlay.vue`, existing `DropZone` emit chain |
| Skeleton placeholders shaped like the real content (5-column grid rows) | The QuotaBar has a simple pulse bar. The document list skeleton should be 5-column grid rows matching the actual layout — icon placeholder, name placeholder, size placeholder, date placeholder. This makes the transition from loading to content feel instantaneous rather than a layout jump. | Medium | New `SkeletonRow.vue`, `StorageBrowser.vue` |
| Admin dashboard overview page (`/admin` root — stats at a glance) | User count, storage total across all users, recent audit log entries. Gives admins a reason to visit the admin panel rather than jumping straight to sub-sections. Self-hosted tools consistently provide this at the admin root. | Medium | New `AdminOverviewView.vue`, read-only aggregate API endpoints (may require backend work) |
| Inline toast/notification system for upload completion and errors | Currently errors appear in the UploadProgress inline list and are never dismissed. A toast system enables transient success/error messages for any action (rename, delete, share) — not just upload. Constraint: keep it simple, not a full notification center. | Medium | New `ToastManager.vue` in `App.vue`, composable `useToast()` |
| Document count badge polish on topic sidebar links | Already has `{{ topic.doc_count }}` in the sidebar. What's missing: show 0 counts as dimmed rather than hidden (users want to know the topic exists even if empty), and truncate to "9+" at double digits. | Low | `AppSidebar.vue` (minor change) |
| Upload from keyboard shortcut `U` with accessible file picker trigger | The keyboard shortcut fires `triggerInput()` on DropZone. Accessibility requirement: this is equivalent to clicking the hidden file input — screen readers announce the file dialog opening. Already partially supported by `DropZone.vue`'s `triggerInput()` — wire up the global shortcut. | Low | `DropZone.vue`, `FileManagerView.vue` |
| Tablet landscape layout: sidebar + content, no collapse needed | At 1024px (lg breakpoint), the sidebar is fully visible alongside content. This is the natural Tailwind `lg:flex` pattern. Verify it and make the breakpoint logic explicit in the layout wrapper. | Low | `App.vue` layout wrapper |
## Anti-Features for v0.3
---
## Anti-Features
Features to explicitly NOT build in v0.2.
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Folder reorder by drag-and-drop (drag folder A above folder B to change sidebar order) | Requires a persistent `position` column in the DB, a backend migration, and drag state tracking across the sidebar tree. The sidebar is already collapsible. Payoff is marginal. | Keep alphabetical or creation-time ordering. Users can rename folders to impose their own order (e.g., "1. Contracts"). |
| Reorderable sidebar sections (drag "Folders" above "Cloud Storage") | Same DB persistence problem. Section order is fixed and meaningful — user folders first, then external cloud. No self-hosted document manager reorders sections. | Fixed section order in `AppSidebar.vue`. |
| Virtual scrolling for the document list | DocuVault is quota-capped at 100 MB per user. At typical document sizes (10500 KB each), a user has at most a few hundred documents before hitting quota. Virtual scrolling adds significant complexity (dynamic row height, scroll restoration) for an edge case that the quota cap prevents. | Standard `v-for`. Add server-side pagination in a later milestone if needed. |
| Rich text document annotations or in-app editing | Already scoped out in `PROJECT.md`. Mentioned here because UX redesigns often attract "what if we added..." scope creep. The preview modal shows PDF; that is the limit. | Stay read-only. Document content is immutable after upload. |
| Dark mode toggle | Requires a systematic color token system across all components plus full testing of the inverted palette. The current light-mode palette is inconsistent — fix that first. Tailwind makes dark mode mechanically possible but the v0.2 payoff is low relative to scope. | Design tokens and CSS variables in v0.2 that would support dark mode in a future milestone. |
| Drag-and-drop to reorder documents within a folder | Requires a persistent sort order column per user per folder. Documents are naturally sorted by creation date or name — both useful orderings. Custom ordering has high complexity and low frequency of use in a document vault. | Keep the existing sort controls (created_at, name, size). Server-side sort is already implemented. |
| Full-screen modal for file upload | The DropZone is embedded in the file browser content area, which is the correct placement. A modal upload workflow adds navigation overhead with no benefit for a simple file picker. | The whole-page drag overlay (differentiator above) is the right scope. |
| Notification center / inbox for async events | Building a polling-based notification center (classification done, quota warning) is a full feature, not UX polish. Celery classification runs in the background and its result is visible when the user next opens the document. | Toast on upload completion. Badge count on the document list is sufficient for v0.2. |
| Multi-select with batch operations (select 10 files, delete all) | Requires a selection state layer across the file list, a selection-aware toolbar, and confirmed batch-delete UX. Medium-to-high complexity for a feature that is rarely needed in a personal document vault. | Address in a future milestone if user feedback identifies it as a gap. |
| Drag documents from file list to sidebar folder entries | Cross-component drag state requires either a Pinia drag store or provide/inject. The sidebar `FolderTreeItem` and `StorageBrowser` have no shared parent that can coordinate state without significant refactoring. The "Move to folder" picker button already covers this use case. | The existing folder picker button in the file row actions menu. |
---
## Admin Panel: Specific Pattern Decisions
### Route structure
```
/admin → AdminLayout.vue wrapper (persistent sidebar + router-view)
/admin/ → AdminOverviewView.vue (stats at a glance)
/admin/users → AdminUsersView.vue (wraps AdminUsersTab.vue)
/admin/quotas → AdminQuotasView.vue (wraps AdminQuotasTab.vue)
/admin/ai → AdminAiConfigView.vue (wraps AdminAiConfigTab.vue)
/admin/audit → AdminAuditView.vue (wraps AuditLogTab.vue)
```
The existing tab components (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, `AdminAiConfigTab.vue`, `AuditLogTab.vue`) are promoted to route-level views. The `AdminLayout.vue` replaces `AdminView.vue`. The tab strip becomes a sidebar with router-link active styling. No logic changes inside the tab components — only the container changes. `AdminView.vue` is deleted after the migration.
Vue Router 4 nested routes pattern (HIGH confidence — verified against official docs):
```js
{
path: '/admin',
component: AdminLayout, // contains <router-view />
meta: { requiresAdmin: true },
children: [
{ path: '', component: AdminOverviewView },
{ path: 'users', component: AdminUsersView },
{ path: 'quotas', component: AdminQuotasView },
{ path: 'ai', component: AdminAiConfigView },
{ path: 'audit', component: AdminAuditView },
],
}
```
The `requiresAdmin` guard moves from the child route to the parent. All children inherit it automatically.
### Admin sidebar nav items (in order)
1. Overview
2. Users
3. Quotas
4. AI Config
5. Audit Log
A "Back to app" link at the bottom of the admin sidebar returns to `/` — admin should not feel like a dead end.
### Admin sidebar does NOT share code with `AppSidebar.vue`
`AdminSidebar.vue` is a separate component. It has no folder tree, no cloud tree, no topics list, no quota bar. Those belong to the user interface. Sharing the sidebar component would create coupling between two logically separate contexts and conflict with the "thin view + smart component" architecture described in CLAUDE.md.
### Breadcrumb
Use the existing `FolderBreadcrumb.vue` or a simpler static variant: `Admin > [Section Name]`. No dynamic nesting required. The admin sidebar's active link already signals location; the breadcrumb adds skimmability for users who navigate via URL.
---
## Empty State Patterns: Specific Decisions
Each zero-content context gets a distinct implementation. Generic "No items" text is the anti-pattern.
| Context | Headline | Subtext | CTA |
|---------|----------|---------|-----|
| Root folder (no folders yet) | "Start organizing your documents" | "Create a folder to group related files together." | "New folder" button |
| Folder is empty (exists but has no contents) | "This folder is empty" | "Upload files here or move documents into this folder." | "Upload files" link (triggers DropZone click) |
| Search returns no results | "No matches for '{query}'" | "Try a different search term or clear your search." | "Clear search" button |
| Shared with me (no shares yet) | "Nothing shared with you yet" | "When someone shares a document with your handle, it appears here." | None (passive state) |
| Topics list empty | "No topics yet" | "Topics are created automatically when you upload and classify a document." | None |
| Audit log (admin, no entries yet) | "No audit events recorded" | "Events are recorded when users log in, upload documents, or make changes." | None |
| Cloud storage — no connections | "No cloud storage connected" | "Connect OneDrive, Google Drive, Nextcloud, or WebDAV in Settings." | "Open Settings" link |
Rule: every empty state that the user can resolve with an action gets exactly one CTA. Empty states that are passive (shared-with-me before anyone shares, topics before first upload, audit before any events) get explanatory subtext only — no CTA that leads nowhere useful.
Implementation: extract a shared `EmptyState.vue` presentational component that accepts `headline`, `subtext`, and an optional `action` slot. Use it in all seven contexts above instead of duplicating the layout.
---
## Loading State Patterns: Specific Decisions
| Component | Current | Target | Rationale |
|-----------|---------|--------|-----------|
| Document list (`StorageBrowser`) | `<div>Loading…</div>` text | 68 skeleton rows (5-col grid, `animate-pulse`) | List loads on every folder navigation. Skeletons match the grid layout exactly, preventing layout jump. |
| Sidebar folder tree | `<div>Loading…</div>` text | 3 skeleton nav items (indent + placeholder bar) | Sidebar loads once on mount. Short skeleton is sufficient. |
| Sidebar topics list | `<div>Loading…</div>` text | 4 skeleton topic pills (color dot + bar) | Same rationale as folder tree. |
| Admin user table | No loading state | Skeleton table rows (35 rows, matching column count) | Admin tables are data-heavy; skeleton preserves table chrome during load. |
| Audit log table | No loading state | Skeleton rows matching timestamp/user/action columns | Same. |
| Upload progress item | Spinner + progress bar (correct) | Keep as-is | Upload is a blocking action, not a content load. Spinners are correct for actions. |
| QuotaBar | `animate-pulse` bar (correct) | No change | Already implemented correctly. Document as the canonical skeleton pattern. |
| Document preview modal | None | Spinner centered in modal body | PDF proxy fetch is a single-request action. Spinner is correct. |
Rule: if the loading UI represents content the user will read (list, table, tree), use a skeleton. If the loading UI represents a system action (save, upload, submit, process), use a spinner. Never show a loading state that will be visible for less than 300ms — add a minimum display delay.
---
## Drag-and-Drop: Scope Boundaries
### Build in v0.2
1. **Whole-page drop overlay for file upload** — listen for `dragenter` at the document level, show a full-screen overlay, delegate the `drop` event to the existing DropZone emit chain. The drop must only activate when files are being dragged (check `e.dataTransfer.types.includes('Files')`).
2. **Move a document to a folder by dragging the file row onto a folder row** — already implemented in `StorageBrowser.vue`. Verify it functions correctly and polish the drop highlight: `ring-2 ring-inset ring-amber-300` on the folder row is correct. Ensure the file row shows reduced opacity during drag (`opacity-50` already applied).
### Do Not Build in v0.2
- Dragging files from the file list onto sidebar folder entries (requires cross-component drag state via Pinia or provide/inject — the "Move to folder" picker already covers this)
- Touch drag-and-drop on mobile (HTML5 drag events do not fire on iOS Safari without a polyfill library; defer to the move picker button)
- Drag folders to reorder them
### Required for every drag interaction
- Visual affordance on dragover (already done on DropZone; add to whole-page overlay)
- Non-drag alternative for every drag action: the "Move to folder" picker button covers the drag-to-move case
- Drop must be clearly confirmed: file row disappears from source location and appears in target on success
---
## Keyboard Shortcuts: Final List
| Shortcut | Action | Condition |
|----------|--------|-----------|
| `/` | Focus search bar | Not in an input, textarea, or contenteditable |
| `Escape` | Close modal or clear active inline input | Modal open, or search focused with content |
| `U` | Trigger file picker (upload) | Not in any input, on a folder/file manager view |
| `N` | Start new folder inline input | Not in any input, on a folder/file manager view |
### Do Not Build
- `J`/`K` for row-by-row navigation — requires focus management, ARIA selected state, and keyboard event bubbling control. Over-engineered for a vault where opening a document is secondary to browsing and uploading.
- `Ctrl+A` / `Cmd+A` for select all — requires multi-select, which is an anti-feature.
- Arrow key tree navigation in the sidebar — screen readers handle this natively; custom arrow-key navigation would conflict with browser defaults.
- `?` shortcut for a shortcut reference modal — too few shortcuts to warrant a help overlay.
---
## Responsive Layout: Breakpoint Decisions
| Viewport | Tailwind breakpoint | Sidebar | Document list | Admin |
|----------|---------------------|---------|--------------|-------|
| Mobile portrait | `< 640px` (below `sm`) | Hidden; hamburger button + overlay drawer | Icon + name only; Size (`hidden md:block`) and Modified (`hidden sm:block`) hidden | Same drawer |
| Mobile landscape / small tablet | `640767px` (`sm`) | Hidden; hamburger still active | Icon + name + Modified visible; Size hidden | Same |
| Tablet portrait | `7681023px` (`md`) | Hidden; hamburger still active | Full 5-col grid | Full columns |
| Tablet landscape / small desktop | `1024px+` (`lg`) | Always visible (256px fixed) | Full 5-col grid | Full admin sidebar + content |
| Desktop | `1280px+` (`xl`) | Always visible | Full grid + comfortable padding | Full layout |
Key decision: sidebar collapses at the `lg` breakpoint (1024px), using `hidden lg:flex` on the sidebar and `lg:hidden` on the hamburger button. At 1024px the sidebar (256px) leaves 768px for content — sufficient for the 5-column grid with room for padding.
Touch targets: all inline action icon buttons are currently `p-1.5` (approximately 30px). Increase to `p-2` on `sm:` and below using responsive padding classes. 44px is the WCAG target — not achievable for inline buttons without breaking the grid layout, but 36px is a reasonable minimum.
---
## Feature Dependencies (summary)
```
Admin route subtree
→ AdminLayout.vue (new) contains AdminSidebar.vue (new) + <router-view>
→ Child views wrap existing AdminUsersTab, AdminQuotasTab, AdminAiConfigTab, AuditLogTab
→ AdminView.vue deleted after migration
Empty states
→ New EmptyState.vue (presentational, accepts headline/subtext/action slot)
→ Used in StorageBrowser.vue, SharedView.vue, TopicsView.vue, CloudStorageView.vue, AuditLogTab.vue
Skeleton rows
→ New SkeletonRow.vue (5-col grid, animate-pulse)
→ Used in StorageBrowser.vue (document list), AppSidebar.vue (folder tree, topics list)
Keyboard shortcuts
→ Global keydown listener in FileManagerView.vue
→ SearchBar.vue exposes focus() method
→ DropZone.vue exposes triggerInput() (already exists as triggerInput())
→ StorageBrowser.vue exposes startNewFolder() (already defineExposed)
Mobile sidebar
→ App.vue holds sidebarOpen boolean state
→ AppSidebar.vue accepts isOpen prop + emits close
→ Hamburger button in App.vue header (mobile only, hidden lg:hidden)
Whole-page drop overlay
→ New PageDropOverlay.vue (full-screen overlay, accepts active prop)
→ FileManagerView.vue listens for document dragenter/dragleave
→ On drop, delegates to existing upload flow via onFilesSelected
```
---
## Sources
- Vue Router 4 nested routes: https://router.vuejs.org/guide/essentials/nested-routes
- Skeleton screens vs. spinners UX: https://www.onething.design/post/skeleton-screens-vs-loading-spinners
- Skeleton loading screen design: https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/
- SaaS empty state patterns: https://pixxen.com/saas-empty-state-design/
- IBM Carbon Design System empty states: https://carbondesignsystem.com/patterns/empty-states-pattern/
- Drag-and-drop SaaS UX: https://www.eleken.co/blog-posts/drag-and-drop-ui
- Drag-and-drop NN/g guidelines: https://www.nngroup.com/articles/drag-drop/
- Responsive SaaS breakpoints: https://tryhoverify.com/blog/responsive-design-breakpoints-2024-guide/
- Keyboard shortcut design for web apps: https://sashika.medium.com/j-k-or-how-to-choose-keyboard-shortcuts-for-web-applications-a7c3b7b408ee
- Nextcloud layout patterns: https://docs.nextcloud.com/server/latest/developer_manual/design/layout.html
- Paperless-ngx frontend architecture: https://deepwiki.com/paperless-ngx/paperless-ngx/2-frontend-architecture
- Native desktop sync client.
- Offline-first web app for entire cloud drives.
- Cross-provider move/copy as a seamless operation.
- Real-time collaborative editing.
- Public link sharing.
- Automatic whole-cloud analysis without explicit user opt-in and cost warning.
+34 -258
View File
@@ -1,290 +1,66 @@
# Domain Pitfalls — v0.2 UI Overhaul and Optimization
# v0.3 Research: Pitfalls
**Project:** DocuVault
**Milestone:** v0.2 — UI Overhaul and Optimization on existing FastAPI + Vue 3 app
**Researched:** 2026-06-07
**Confidence:** HIGH (all pitfalls grounded in actual source files: router/index.js, App.vue, main.py, client.js, StorageBrowser.vue, DocumentCard.vue, DropZone.vue, AppSidebar.vue, and all api/*.py router files)
**Milestone:** v0.3 Reimagining Cloud Storage integration
**Date:** 2026-06-17
---
## Major Risks
## Critical Pitfalls
### Full Mirror by Accident
Mistakes that cause regressions, security gaps, or rewrites.
If analysis downloads every cloud file into MinIO, user quota and infrastructure cost explode. Treat bytes as temporary cache unless the user explicitly imports a file.
---
Prevention: separate cloud item metadata/index records from local `documents`; add cache limits and eviction tests early.
### Pitfall 1: FastAPI Router Splitting — Prefix Already Embedded in APIRouter, Not in include_router
### Provider Capability Mismatch
**Phase:** Backend refactoring (split large router files)
OneDrive, Google Drive, Nextcloud, and generic WebDAV do not expose identical semantics. Google Workspace files, WebDAV path IDs, OneDrive item IDs, etag behavior, move/rename semantics, and preview support differ.
**What goes wrong:** Every existing router in this codebase declares its prefix on the `APIRouter` instance itself — `router = APIRouter(prefix="/api/documents", tags=["documents"])` — NOT on the `include_router(router, prefix=...)` call in `main.py`. When splitting a large file (e.g. `documents.py` at 852 lines, `admin.py` at 934 lines) into sub-modules, the new sub-routers pick up their endpoints via an `include_router` call inside the original module, or they are wired directly in `main.py`. Either way, if you add a prefix on `include_router` in addition to the prefix on the inner `APIRouter`, all URLs double-prefix to `/api/documents/api/documents/...`. Likewise, if you strip the prefix from the inner router and forget to add it to `include_router`, all URLs return 404.
Prevention: model provider capabilities explicitly and disable unsupported actions with a clear reason.
**Why it happens:** The original pattern (`router = APIRouter(prefix=...)` in the module + `app.include_router(router)` without a prefix in `main.py`) is unusual but consistent in this project. Developers splitting a file expect the standard pattern where prefixes go on `include_router`, not on the router definition.
### Search That Downloads on Query
**Consequences:** 404 on all endpoints in the split module; existing tests and the frontend `client.js` (which hard-codes `/api/documents`, `/api/admin`, etc.) silently break.
Smart search must query persisted extracted text/embeddings. Downloading cloud bytes during search would be slow, expensive, and fragile.
**Prevention:** When splitting, create sub-routers with NO prefix: `sub = APIRouter(tags=["documents-upload"])`. Register them inside the parent module with `router.include_router(sub)` so the parent's prefix propagates. The parent module keeps its existing prefix and its existing `app.include_router(parent)` call in `main.py` is unchanged.
Prevention: search only analyzed/indexed cloud item records; show unanalyzed items as out of search scope until analyzed.
**Detection warning signs:** Any test that calls `/api/documents/*` returns 404 immediately after the split; Swagger UI shows doubled path segments.
### Stale or Wrong Results
---
Cloud files can change outside DocuVault. Search results become misleading if etags/versions are ignored.
### Pitfall 2: FastAPI Router Splitting — Circular Import via Shared Dependencies
Prevention: store last analyzed etag/version, mark stale when provider metadata changes, and re-analyze changed items.
**Phase:** Backend refactoring
### Destructive Action Surprise
**What goes wrong:** `folders.py` already exports a `document_move_router = APIRouter(prefix="/api/documents")` — a cross-cutting concern documented in the file (line 439-442). When splitting `documents.py`, if the new sub-module imports anything from `api/folders.py` (e.g. a shared validator or Pydantic schema), Python raises `ImportError: cannot import name X` at startup because both modules are in partial initialization state.
Delete/move/rename in cloud mode changes the user's real provider storage, not a DocuVault copy.
**Why it happens:** FastAPI modules that share models and validators tend to grow cross-references. The `document_move_router` in `folders.py` under the `/api/documents` prefix is the existing example — any new import in the opposite direction closes the import cycle.
Prevention: clear confirmations for destructive actions, audit logs, ownership checks, and provider-specific error handling.
**Consequences:** Application fails to start. Docker container exits immediately.
### Credential and Token Drift
**Prevention:** Shared Pydantic models and validators must live in `db/models.py` or a dedicated `schemas/` module — not in router files. Before splitting, grep for any `from api.X import` inside the router files being split and move the shared symbols to a neutral module first.
OAuth tokens expire or are revoked. WebDAV passwords/app tokens change. Background jobs may fail long after the user connected the provider.
**Detection warning signs:** `ImportError` or `cannot import name` in the uvicorn startup log.
Prevention: connection health state, reconnect flows, token refresh persistence, and failure states that stop retry storms.
---
### Blocking Event Loop or Worker Exhaustion
### Pitfall 3: AdminLayout Vue Router Integration — beforeEach Guard Misses /admin/* Children
Provider SDKs may be synchronous and cloud downloads can be large.
**Phase:** Admin panel rearchitecture
Prevention: keep sync SDK calls in thread wrappers, stream/chunk large files, use Celery queues/rate limits, and retry external API failures with backoff.
**What goes wrong:** The current guard in `router/index.js` (lines 81-93) checks `to.meta.requiresAdmin` on the matched route object. The current `/admin` route is a flat route with that meta set directly. When `/admin` becomes a parent route with nested children (`/admin/users`, `/admin/audit`, etc.), the children do NOT automatically inherit `meta: { requiresAdmin: true }` from the parent in Vue Router 4 unless the guard iterates `to.matched`. If the children are added without repeating the meta, the guard's `to.meta.requiresAdmin` check evaluates to `undefined` (falsy) for every child route — non-admin users can navigate to `/admin/users` directly.
### SSRF and URL Handling Regression
**Why it happens:** Vue Router 4 does NOT merge parent `meta` into child `meta` by default. `to.meta` reflects only the deepest matched route's own meta, not the parent's.
WebDAV/Nextcloud URLs are user-supplied. v0.1 already added SSRF guards; new action endpoints must preserve them.
**Consequences:** Security regression — the admin guard that currently works correctly stops protecting all admin child routes. Non-admin users can access `/admin/users`, `/admin/audit`, etc.
Prevention: all WebDAV outbound paths go through existing validation utilities; never join raw user path strings into URLs without provider helper methods.
**Prevention:** Update the guard to check `to.matched.some(r => r.meta.requiresAdmin)` instead of `to.meta.requiresAdmin`. This is the correct Vue Router 4 idiom for inherited meta and requires one change to the guard. Apply the same fix to the `!to.meta.public` check if any auth routes gain children.
### Cache Privacy Leak
```javascript
// router/index.js — change:
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
// to:
if (to.matched.some(r => r.meta.requiresAdmin) && authStore.user?.role !== 'admin') {
```
Cached bytes are sensitive document content. A shared cache path without user/item scoping could leak across users.
---
Prevention: cache keys include user id, connection id, item id, and etag; cache content is never served without current-user ownership checks.
### Pitfall 4: AdminLayout Integration — App.vue Layout Switch Breaks for /admin/* Routes
### Runaway AI Cost
**Phase:** Admin panel rearchitecture
"Analyze all cloud documents" could enqueue thousands of files.
**What goes wrong:** `App.vue` currently switches between `AuthLayout` (when `route.meta.layout === 'auth'`) and the main app shell (sidebar + `<router-view>`) for everything else. The admin panel needs a third layout (`AdminLayout`) with its own nav. The naive approach — `v-else-if="route.meta.layout === 'admin'"` in `App.vue` rendering `AdminLayout` which then renders `<router-view />` — creates double rendering: `App.vue`'s `<router-view>` already resolved the full route. The inner `<router-view>` inside `AdminLayout` gets nothing because the route is already consumed.
**Consequences:** Admin layout renders with an empty content area, or the admin page renders without the admin chrome.
**Prevention:** The correct structure for a standalone admin layout in Vue Router 4:
```javascript
// router/index.js
{
path: '/admin',
component: AdminLayout, // resolves to the layout shell, which contains <router-view />
meta: { requiresAdmin: true },
children: [
{ path: '', redirect: '/admin/users' },
{ path: 'users', component: AdminUsersView },
{ path: 'quotas', component: AdminQuotasView },
{ path: 'ai', component: AdminAiView },
{ path: 'audit', component: AdminAuditView },
]
}
```
`AdminLayout.vue` contains `<router-view />` where page content renders. `App.vue` remains unchanged — it renders the component that the route resolves to, which is now `AdminLayout`. No third branch in `App.vue` is needed.
---
### Pitfall 5: client.js Splitting — noRefreshPaths Breaks When Paths Move to Sub-modules
**Phase:** Frontend refactoring (client.js split)
**What goes wrong:** `client.js` has a hardcoded `noRefreshPaths` list inside `request()` (line 26) that guards against infinite refresh loops. If `request()` is moved to a shared `api/core.js` module and the path list stays there, it works. But the three blob-download functions (`adminExportAuditLogCsv`, `adminDownloadDailyExport`, `fetchDocumentContent`) each contain a duplicated copy of the 401-refresh-retry logic. When splitting `client.js` into domain files, those duplicates will be copied into new sub-files. This is the correct moment to eliminate them — but if missed, the bug surface multiplies.
**Additionally:** If components are changed to import from sub-files directly (e.g. `import { listDocuments } from '../api/documents.js'`) instead of the barrel, every other component that imports from `client.js` still works — but a future refactor that renames a function in the sub-file will miss the barrel re-export and break components that still use the barrel. Both import paths exist simultaneously and diverge.
**Prevention:** Split in this order:
1. Extract `request()` + `noRefreshPaths` into `api/core.js`. Export `request` from there.
2. Extract the 401-retry pattern from the three blob-download functions into a single `fetchWithRetry(url, headers)` helper also in `api/core.js`.
3. Create domain files (`api/documents.js`, `api/admin.js`, `api/auth.js`, etc.) that import from `api/core.js`.
4. Keep `api/client.js` as a re-export barrel: `export * from './documents.js'; export * from './admin.js'` etc. This preserves all existing `import { listDocuments } from '../api/client.js'` calls without touching any component. Never change component imports to point at sub-files directly.
---
### Pitfall 6: Drag-and-Drop on DocumentCard — dragstart vs @click Conflict
**Phase:** UX improvements (drag-and-drop)
**What goes wrong:** `DocumentCard.vue` navigates on `@click="$router.push('/document/' + doc.id)"` (line 4). Browsers fire `click` after `dragend` when the drag distance is below the browser's drag-initiation threshold — typically ~4px. A user who clicks and releases without moving far enough triggers both `dragstart` and then `click`: the card navigates to the document even though the user intended to start a drag.
**Also:** The action buttons inside `DocumentCard` use `@click.stop`. If a drag handle overlaps an action button area, `@click.stop` prevents the click from propagating but does not prevent `dragstart` from bubbling — behavior becomes inconsistent depending on which pixel the user starts from.
**Note:** `StorageBrowser.vue` already implements drag-and-drop on list rows correctly (lines 141-145: `draggable`, `@dragstart`, `@dragend`) and does not have this problem because the row's `@click` emits `file-open` rather than navigating imperatively. The risk is specific to adding drag to `DocumentCard.vue` (card grid used in `TopicsView`).
**Prevention:**
1. Track drag state with a component-level `dragging` ref. Set `dragging = true` in `@dragstart`, reset to `false` in `@dragend`. Guard `@click`: `if (this.dragging) return`.
2. Preferred: use a dedicated drag handle element (grip icon, `cursor-grab`) and set `draggable="true"` only on that element. Add `@click.stop` on the handle. The rest of the card retains click-to-navigate.
---
### Pitfall 7: Virtual Scrolling — StorageBrowser DOM Assumptions Break
**Phase:** Frontend performance (virtual scrolling)
**What goes wrong:**
`StorageBrowser.vue` renders its item list with `divide-y divide-gray-100` (line 76). The Tailwind `divide-y` utility inserts borders using adjacent-sibling CSS (`& > * + * { border-top-width: 1px }`). This only works when all children are present in the DOM simultaneously. Virtual scroll libraries keep only visible rows in the DOM — `divide-y` produces borders only between the visible subset, leaving gaps at virtual boundaries.
The folder picker dropdown inside file rows (lines 185-213) is `absolute right-0 top-full` — positioned relative to its parent row. Virtual scroll repositions rows via `transform: translateY()`. Absolutely positioned children escape their transformed parent and appear at wrong viewport positions.
CSS transitions on rows (`transition-all`, `transition-colors`) work with `v-for` because Vue animates DOM insertion/removal. Virtual scroll does not insert/remove — it repositions. Vue's `<transition-group>` cannot wrap a virtually scrolled list and will throw warnings.
**Prevention:**
1. Replace `divide-y` with `border-b border-gray-100` on each individual row element. Rows become self-contained and do not depend on DOM adjacency.
2. The folder picker dropdown must use `<Teleport to="body">` with `position: fixed` coordinates computed from `getBoundingClientRect()`. This is a prerequisite for virtual scroll adoption, not optional.
3. Remove any `<transition-group>` wrapping a virtually scrolled list. Use per-row fade-in via a CSS class added on first mount instead.
---
## Moderate Pitfalls
---
### Pitfall 8: Responsive Layout — w-64 Sidebar vs Flex-1 Content on Mobile
**Phase:** Responsive / mobile layout
**What goes wrong:** `App.vue` uses `flex h-screen overflow-hidden` with `AppSidebar` fixed at `w-64 shrink-0`. On screens narrower than ~320px the content area collapses below usable width. `StorageBrowser`'s 5-column grid `grid-cols-[2rem_1fr_6rem_8rem_6rem]` has `hidden sm:block` and `hidden md:block` to hide size and date columns — but the fixed `2rem` icon and `6rem` action columns still consume 128px on either side of the filename, leaving minimal space on a 375px screen.
**Prevention:**
1. On mobile (`< lg`), hide the sidebar. Use a hamburger button in a mobile-only header bar. Toggle with `translate-x-0 / -translate-x-full` transition on the sidebar element.
2. The sidebar open/closed state needs to be in a Pinia store ref (or provided from `App.vue`) so the hamburger button in the header, the sidebar overlay, and the main content area all react to it. Do not put it in a component's `data()`.
3. On mobile, simplify the grid to `grid-cols-[2rem_1fr_4rem]` — filename and a single action column. Test at 375px explicitly.
---
### Pitfall 9: Admin Panel + Responsive — Double Padding After Extracting Tab Content
**Phase:** Admin panel rearchitecture + responsive layout (interaction pitfall)
**What goes wrong:** `AdminView.vue` wraps all content in `p-8 max-w-5xl mx-auto`. Each tab component (`AdminUsersTab.vue`, `AdminQuotasTab.vue`, etc.) has its own internal padding. When tab components are extracted into standalone page views (`AdminUsersView.vue`, etc.), the `p-8 max-w-5xl mx-auto` wrapper moves to `AdminLayout.vue`. If the extracted page views retain their own inner padding, content is double-padded. If `AdminLayout` does not include the constraint, the new admin pages render full-bleed against the viewport.
**Prevention:** When extracting tab components into page views, strip all top-level padding and `max-w` from the extracted component — those belong to `AdminLayout`'s content area. The page components should fill 100% of the space provided by the layout slot.
---
### Pitfall 10: Options API Refactoring — Prop Mutation via v-model
**Phase:** Frontend refactoring (Vue component decomposition)
**What goes wrong:** If a component receives a prop and binds it directly to `<input v-model="propName">`, Vue emits a prop mutation warning in development. In production (no warnings), the mutation silently fails to propagate: the component's displayed value updates (local vdom), but the parent never receives the change because no `update:propName` event was emitted.
**Specific risk in this codebase:** `AdminUsersTab`, `AdminQuotasTab`, and `AuditLogTab` currently own their filter/search state internally. When extracted into standalone views, these may receive initial state from route query params and accidentally bind those props directly to inputs.
**Prevention:** Any input the component controls must bind to a local `data()` copy initialized from the prop: `data() { return { localFilter: this.initialFilter } }`. Emit `update:initialFilter` on change. Never write `v-model="$props.X"`.
---
### Pitfall 11: Options API Refactoring — data() vs computed() in Decomposed Components
**Phase:** Frontend refactoring
**What goes wrong:** When extracting a large component, logic that was a `computed()` in the parent often gets copy-pasted into a child's `data()`. A `data()` value initializes once at mount — it does not react to prop changes. If the parent later passes a different prop value (e.g. the document list changes after a search), the child's stale `data()` copy never updates.
**Prevention:** For every value in a newly created component, ask: "If the parent passes a different value for this prop, should the child update?" Yes → `computed()`. No → `data()`. Initialized from prop but locally modified afterward → `data()` with a `watch` on the prop to reset when it changes externally.
---
### Pitfall 12: Keyboard Shortcuts — Conflict with Text Inputs and Browser Defaults
**Phase:** UX improvements (keyboard shortcuts)
**What goes wrong:** A global `keydown` listener on `window` fires regardless of focused element. Shortcuts like `/` (focus search), `n` (new folder), `Delete` (delete selected) are also typed characters. They must not fire when any `<input>`, `<textarea>`, `<select>`, or `[contenteditable]` element has focus. `Escape` conflicts with native `<dialog>` cancel events if `DocumentPreviewModal` uses a native dialog — both fire, causing a double-close bug.
**Prevention:**
```javascript
function onKeydown(e) {
const tag = document.activeElement?.tagName
const isEditing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)
|| document.activeElement?.isContentEditable
if (isEditing) return
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
searchInputRef.value?.focus()
}
}
// Options API: register in mounted(), remove in beforeUnmount()
// Omitting the removeEventListener is the most common bug in this pattern.
```
Never call `preventDefault()` on `F5`, `Backspace`, `Ctrl+F`, or `Cmd+F` — these are browser-reserved. Use a Pinia store to track which shortcut context is active (file manager, document view, modal) so modal shortcuts suppress file manager shortcuts rather than both firing.
---
## Minor Pitfalls
---
### Pitfall 13: Loading Skeletons — Count Mismatch Causes Layout Shift
**Phase:** UX improvements (loading skeletons)
**What goes wrong:** `StorageBrowser.vue` already has a `loading` prop (line 244) that shows "Loading…" text. Replacing this with skeleton rows requires a skeleton count. Hardcoding 5 rows when `per_page=20` causes a jump; using `per_page` as the skeleton count when only 3 items exist also jars. If skeleton rows are different heights from actual rows, the layout shifts when real content loads — this is a Core Web Vitals issue.
**Prevention:** Render skeletons at the previous page's actual item count (stored in the parent store). Default to 6-8 on first load. Skeleton rows must match the height of `StorageBrowser`'s actual grid rows exactly — use the same `px-4 py-2.5 grid-cols-[2rem_1fr_6rem_8rem_6rem]` structure with gray placeholder blocks.
---
### Pitfall 14: Tailwind Purging — Dynamic Class Names in Refactored Components
**Phase:** Visual redesign (Tailwind component system)
**What goes wrong:** Vite + Tailwind purges unused CSS at build time by scanning template strings for class names. Dynamic class names constructed at runtime (e.g. `'text-' + color + '-500'`) are invisible to the purger. In development all classes are present; in production the dynamic classes are stripped and topics/provider badges render without color.
**Specific risk:** `formatters.js` functions `providerColor`, `providerBg`, `providerLabel` return class strings. If those strings are not present as complete literals anywhere in the scanned source, they are purged.
**Prevention:** Add a `safelist` in `tailwind.config.js`:
```javascript
safelist: [
{ pattern: /bg-(blue|green|purple|orange|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|green|purple|orange|gray|indigo|red)-(500|600|700)/ },
]
```
Alternatively, use a lookup object of complete class strings rather than string concatenation — Tailwind can scan complete strings in object literals.
---
## Integration Pitfalls (Where Two Changes Interact Badly)
---
### Integration Pitfall A: Admin Rearchitecture + client.js Splitting — Wrong Order Causes Test Blindness
**Phases:** Admin panel rearchitecture + frontend refactoring
**What goes wrong:** If `client.js` is split into domain sub-files before the admin routes are moved to `/admin/*`, the new `api/admin.js` sub-file gets created. Components are then updated to point at the new route structure. But if the split happened first and components were simultaneously moved to import from `api/admin.js` directly (bypassing the barrel), the existing tests (which mock `client.js` imports) no longer cover the new import path. Tests pass, bugs hide.
**Prevention:** Split `client.js` using the re-export barrel pattern first. Verify all existing tests still pass against the barrel. Then rearchitect admin routes. The barrel acts as a compatibility shim throughout both phases. Only remove the barrel after all tests are updated to import from sub-files.
---
### Integration Pitfall B: Virtual Scrolling + Drag-and-Drop Cannot Be Added Simultaneously
**Phases:** Frontend performance + UX improvements
**What goes wrong:** Virtual scroll and HTML5 drag-and-drop between rows both depend on the same DOM elements. Virtual scroll keeps only visible rows in the DOM. A user dragging a file toward a folder that is currently scrolled out of the virtual window will never trigger `dragover` on that folder row — it is not in the DOM. `StorageBrowser.vue`'s existing drag-into-folder implementation (lines 88-90) relies on `@dragover.prevent` on folder rows. In a virtual list, off-screen folder rows do not receive this event.
**Prevention:** Decide the list interaction model before implementing either feature. If virtual scrolling is added, redesign drag-to-folder: the sidebar folder tree (always fully rendered, outside the virtual list) becomes the drop target. This is a UX design decision that must be made before either feature is implemented, not a detail to resolve mid-phase.
---
### Integration Pitfall C: Responsive Sidebar + Admin Guard Race on Mobile Page Load
**Phases:** Responsive layout + admin panel rearchitecture
**What goes wrong:** The current `beforeEach` guard correctly awaits `authStore.refresh()` before checking `authStore.user?.role` (router lines 82-88). This is correct. The risk emerges when adding nested admin child routes: a developer adding a component-level `created()` or `onMounted()` auth check in an admin page component, trying to guard against unauthorized access "just in case." If that component check does not await the refresh (because the token is memory-only and gone on mobile page reload), `authStore.user` is null at that point, the component check redirects the user to `/login`, and the correct guard never gets to run. The result is that admins are bounced to login on every mobile page reload.
**Prevention:** The single `beforeEach` guard is the only place that should call `refresh()` or check roles. Page components must not independently re-check auth. If a component needs the current user, it reads from `authStore.user` — which is guaranteed to be populated by the time the component mounts, because the guard has already awaited the refresh.
Prevention: preflight count/size estimate, explicit confirmation, quotas/rate limits, progress UI, cancellation, and retry controls.
+33 -307
View File
@@ -1,322 +1,48 @@
# Technology Stack — DocuVault v0.2: UI Overhaul and Optimization
# v0.3 Research: Stack
**Milestone:** v0.2 — UI Overhaul and Optimization
**Researched:** 2026-06-07
**Scope:** New additions only. The v0.1 stack (FastAPI, Vue 3, Pinia, Vue Router 4, Vite, Tailwind CSS, PostgreSQL, MinIO) is unchanged and not re-researched here.
**Milestone:** v0.3 Reimagining Cloud Storage integration
**Date:** 2026-06-17
---
## Recommendation
## Existing Stack Baseline (Confirmed, Do Not Change)
Keep the existing FastAPI + SQLAlchemy + PostgreSQL + MinIO + Celery + Vue stack. Add a first-class cloud metadata/index layer and a bounded byte cache instead of adding a separate sync server, filesystem mount, or full local mirror.
| Component | Current Version (package.json / requirements.txt) | Notes |
|-----------|--------------------------------------------------|-------|
| Vue | `^3.4.0` | Must bump to `^3.5.0` (see VueUse note below) |
| Vite | `^5.2.0` | Must bump to `^6.0.0` — Vite 8 is latest; skip 8 (too new) |
| Tailwind CSS | `^3.4.0` | Stay on v3; v4 is out but requires config rewrite |
| Pinia | `^2.1.0` | No change needed |
| Vue Router | `^4.3.0` | No change needed |
## Existing Pieces to Extend
---
- `backend/storage/base.py`: current `StorageBackend` is object-storage shaped. v0.3 needs a second interface or capability extension for file-manager operations: list, open/download stream, upload to path, rename, move, delete, mkdir, stat, and provider change cursors where available.
- `backend/api/cloud.py`: currently owns connect/list folders. It should split into focused modules as v0.2 did for admin/documents/auth once behavior expands.
- `backend/services/cloud_cache.py`: current TTL folder-listing cache is metadata-only. Keep it for short folder listings, but add a durable DB-backed cloud item index and a separate byte-cache service for extraction/preview.
- `backend/tasks/document_tasks.py`: extraction/classification already retrieves bytes from cloud backends for cloud-backed `Document` rows. v0.3 should add dedicated cloud analysis jobs that create/update index records from existing provider files without requiring upload into MinIO.
- `frontend/src/components/storage/StorageBrowser.vue`: already unifies local/cloud browsing. v0.3 should make actions capability-aware instead of `mode === "local"` gated.
## New Frontend Dependencies
## Provider APIs
### 1. VueUse — `@vueuse/core` + `@vueuse/integrations`
- Microsoft Graph `driveItem` supports list children, update item, upload, download, delete, move, copy, search, preview, and delta tracking. It exposes eTags/cTags and short-lived download URLs that must not be cached. Source: https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0
- Google Drive API supports resumable uploads and file metadata operations. v0.3 should normalize Drive file IDs, parent IDs, MIME types, and Workspace-document export limitations. Source: https://developers.google.com/workspace/drive/api/guides/manage-uploads
- Nextcloud/WebDAV supports standard `PROPFIND`, `PUT`, `GET`, `DELETE`, `MOVE`, `COPY`, and metadata response headers such as `OC-Etag` and `OC-FileId`. Source: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html
**Recommended:** `@vueuse/core@^14.3.0`, `@vueuse/integrations@^14.3.0`
**Confidence:** HIGH (verified from Context7 docs and npm registry)
## Cache and Index Stack
**Why:** VueUse provides three composables this milestone needs, each saving 30-100 lines of custom code that would otherwise require careful browser-compatibility testing:
- Store cloud item metadata in PostgreSQL, not Redis: provider, connection, provider item id/path, parent id/path, name, MIME/content type, size, etag/content hash, mtime, folder/file flag, provider capabilities, analysis/index status, last seen at, and error state.
- Store extracted text and classification/search metadata in PostgreSQL, linked to either local `documents` or cloud item records. Avoid copying full bytes to MinIO unless the user explicitly imports or provider capability requires it.
- Use MinIO or a local temp directory only as a bounded byte cache for preview/extraction. Cache entries need owner, provider item version, byte size, last access, and eviction status.
- Redis can remain a coordination/cache primitive. If used for byte-cache metadata or job locks, configure memory limits and eviction intentionally; Redis recommends choosing policies such as `allkeys-lru` for cache-like workloads. Source: https://redis.io/docs/latest/develop/reference/eviction/
- `useDropZone` — drop-target zones for drag-and-drop file upload (handles `dragenter`/`dragleave`/`drop`, exposes `isOverDropZone` reactive ref, correct `dataTypes` filtering, Safari limitations documented)
- `onKeyStroke` — keyboard shortcut registration without manual `addEventListener`/`removeEventListener` lifecycle management
- `useSortable` (from `@vueuse/integrations`) — thin wrapper over SortableJS for list/folder reordering, auto-syncs to reactive array
## Background Jobs
**Bundle impact:** `@vueuse/core` unpacked size is ~870 KB but it is fully tree-shakeable — only imported composables are included in the bundle. Each composable is typically 1-3 KB gzipped. This is the correct way to import:
- Use Celery for analysis, indexing, cache hydration, cache eviction, and provider refresh jobs. Celery supports autoretry with exponential backoff and jitter for external services. Source: https://docs.celeryq.dev/en/stable/userguide/tasks.html
- Jobs should be idempotent by `(connection_id, provider_item_id, version/etag)` to survive retries, duplicate selections, and provider race conditions.
```js
import { useDropZone, onKeyStroke } from '@vueuse/core'
import { useSortable } from '@vueuse/integrations/useSortable'
```
## Search Stack
**Critical version constraint:** VueUse v14.0+ requires `vue@^3.5.0`. The current `package.json` pins `^3.4.0`. Vue 3.5 has no breaking changes for Options API components (confirmed from official Vue blog — purely additive release: reactivity improvements, -56% memory usage). Bump Vue to `^3.5.0` as part of this milestone's first phase.
- Keep PostgreSQL full-text search for keyword/sentence search over extracted text.
- Add semantic search as a scoped phase using `pgvector` if the project wants idea search in v0.3. pgvector keeps vectors in Postgres, supports exact/approximate nearest-neighbor search, and avoids introducing a separate vector database. Source: https://github.com/pgvector/pgvector
- Semantic indexing should be optional per analyzed item and provider-independent.
**`@vueuse/integrations` peer deps:** `useSortable` requires `sortablejs@^1` as a peer dependency. Install `sortablejs` alongside.
## What Not to Add
```bash
npm install @vueuse/core@^14.3.0 @vueuse/integrations@^14.3.0 sortablejs@^1.15.7
npm install -D @types/sortablejs
```
**Do NOT use VueUse as a global plugin** (`app.use(VueUse)`) — it doesn't have one. Import composables individually, always.
---
### 2. vue-virtual-scroller — `vue-virtual-scroller`
**Recommended:** `vue-virtual-scroller@^3.0.4`
**Confidence:** HIGH (verified from Context7 docs and npm registry; published 2026-05-20, actively maintained)
**Why:** Document lists can reach hundreds or thousands of entries per user. Without virtual scrolling, rendering all `DocumentCard` components simultaneously degrades scroll performance significantly on mobile. `vue-virtual-scroller` is the canonical Vue 3 virtual list library by Guillaume Chau (Akryum, Vue core team contributor). It ships ESM-only, is Vite-native, and requires Vue 3.3+ (compatible with our 3.5 bump).
**Components used:**
- `<RecycleScroller>` — fixed item height, highest performance. Use for the main document grid where item height is deterministic.
- `<DynamicScroller>` — variable item height. Use only if needed (e.g. list view with text previews of different lengths).
**Bundle size:** Unpacked ~461 KB; ESM-only means Rollup tree-shakes unused components. Requires importing its CSS: `import 'vue-virtual-scroller/index.css'`.
**Integration note:** Because `RecycleScroller` pools and reuses DOM nodes, every item component must be fully reactive from props alone — no internal `mounted()` side effects that assume a fresh component per item. Options API components that follow the props-down / events-up pattern (as required by the existing architecture) are fully compatible.
```bash
npm install vue-virtual-scroller@^3.0.4
```
---
### 3. Drag-and-Drop for File Upload — Native HTML5 + VueUse `useDropZone`
**Recommended:** No additional library. Use `useDropZone` from `@vueuse/core` (already added above).
**Confidence:** HIGH
**Why not a dedicated library:** The drag-and-drop requirement for this milestone is drop-zone file upload (files dragged from OS to a browser area). `useDropZone` covers this completely. Dedicated upload libraries like `dropzone-vue` or `vue-file-uploader` are either poorly maintained, opinionated about UI, or duplicate what the existing upload flow already does. The existing backend upload endpoint and progress tracking are already implemented — adding a library that wraps both drop detection and upload would require replacing working code.
**For folder/list reordering** (drag document into a different folder), use `useSortable` from `@vueuse/integrations` (already added above via SortableJS). SortableJS handles this precisely: it is a low-level, pure-DOM drag-sort library with no Vue-specific assumptions.
**Do NOT use `vue-draggable-plus` (0.6.1, last published 2026-01-11):** It wraps SortableJS but adds a Vue component abstraction layer that conflicts with the project's data-provider view pattern. It also adds bundle overhead that `useSortable` avoids. The last publish date of January 2026 is acceptable for a stable library, but there is no reason to prefer it over the VueUse integration that is already in the bundle.
---
### 4. Keyboard Shortcuts — VueUse `onKeyStroke`
**Recommended:** `onKeyStroke` from `@vueuse/core` (already added). No additional library.
**Confidence:** HIGH
**Why no dedicated library:** Libraries like `vue-shortkey` or `hotkeys-js` exist but are unnecessary overhead. `onKeyStroke` from VueUse handles:
- Single key listening: `onKeyStroke('/', handler)` (focus search)
- Modifier combos: check `event.ctrlKey` / `event.metaKey` inside the handler
- Automatic cleanup when the component is unmounted (it wraps `addEventListener`/`removeEventListener` with `onUnmounted`)
For a document manager, typical shortcuts are: `/` (search focus), `Escape` (close modal/panel), `Delete` (delete selected), `Enter` (open/confirm). All fit `onKeyStroke` with no wrapper library.
**Implementation pattern:**
```js
// In setup() or a composable
import { onKeyStroke } from '@vueuse/core'
onKeyStroke('/', (e) => {
e.preventDefault()
searchInput.value?.focus()
})
```
---
### 5. Loading Skeletons — Pure Tailwind CSS
**Recommended:** No library. Use `animate-pulse` utility with placeholder shapes.
**Confidence:** HIGH
**Why no library:** Skeleton loaders in this app are simple rectangular placeholders — cards, list rows, and a few text lines. Tailwind's `animate-pulse` combined with `bg-gray-200 dark:bg-gray-700` rounded shapes is sufficient and produces zero additional bundle weight. A skeleton library (`vue3-skeleton`, etc.) would add a dependency for something that is 5 lines of Tailwind HTML per component.
**Recommended pattern for a document card skeleton:**
```html
<div class="animate-pulse rounded-lg border border-gray-200 p-4">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
```
For a shimmer effect (more polished than pulse), use a CSS `@keyframes` background-position animation — still zero JS dependency. Only add a library if shimmer is required across 10+ distinct skeleton shapes that need consistent management.
---
### 6. Tailwind CSS Plugins
**Recommended:** `@tailwindcss/forms@^0.5.11`
**Confidence:** HIGH (official Tailwind Labs plugin, 4,500+ GitHub stars, supports both v3 and v4)
**Why `@tailwindcss/forms`:** DocuVault has several form-heavy screens (login, registration, upload dialog, admin user management, cloud credential entry). Browser defaults for `<input>`, `<select>`, `<textarea>`, `<checkbox>`, and `<radio>` are inconsistent across platforms and resist Tailwind utility styling. `@tailwindcss/forms` provides a minimal, accessible reset that makes all form elements style-consistent and fully overridable with utilities. Unpacked size is 55 KB; it is a PostCSS plugin with zero runtime JS.
```bash
npm install -D @tailwindcss/forms@^0.5.11
```
```js
// tailwind.config.js
plugins: [require('@tailwindcss/forms')]
```
**Container queries (`@tailwindcss/container-queries`):** Do NOT add. In Tailwind v3, this would be the `@tailwindcss/container-queries` plugin. However, the v0.2 responsive layout target is standard viewport-based breakpoints (`sm:`, `md:`, `lg:`). Container queries are useful when the same component is rendered in contexts of different widths (sidebar vs. main content), but the DocuVault component architecture uses the data-provider view pattern where layout is controlled by the view, not by individual components. This eliminates the primary use case for container queries in this codebase. If a future phase introduces a component genuinely embedded in variable-width containers, add the plugin then.
**Typography plugin (`@tailwindcss/typography`):** Do NOT add. There is no long-form rich text content in this app — document content is displayed in a preview iframe, not rendered as styled HTML.
---
### 7. Bundle Analysis — `rollup-plugin-visualizer`
**Recommended:** `rollup-plugin-visualizer@^7.0.1` (dev dependency, build-time only)
**Confidence:** MEDIUM-HIGH (standard tool, actively maintained, Vite-native)
**Why:** The bundle size reduction goal requires knowing what is currently in the bundle. `rollup-plugin-visualizer` generates an interactive treemap that shows each module's contribution. Run once at the start of v0.2 to establish baseline, and again after refactoring to verify improvement. It does not affect the production build.
```bash
npm install -D rollup-plugin-visualizer@^7.0.1
```
```js
// vite.config.js — enable only when analyzing, not in every build
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
process.env.ANALYZE ? visualizer({ open: true, filename: 'dist/stats.html' }) : null,
].filter(Boolean),
})
```
Run with: `ANALYZE=1 npm run build`
---
## Vite Upgrade
**Recommended:** Bump from `^5.2.0` to `^6.4.3` (the stable Vite 6 release; avoid Vite 7/8 beta)
**Confidence:** HIGH (Vite 6 released 2024-11; Vite 7 and 8 are in beta as of 2026-06)
**Why Vite 6 over staying on Vite 5:** Vite 6 is a stable, minimal-breaking-change major release. Breaking changes for this project are:
1. **Node.js ≥ 20.0.0 required** — Docker Compose service should already use Node 20+ (verify `FROM node:20-alpine` or equivalent in any Dockerfile that runs the frontend build).
2. **No other breaking changes apply** to this project (CSS library output file rename only affects library authors, not app builds; SSR CSS change is not applicable).
Vite 6 delivers improved dependency pre-bundling and module runner improvements relevant to the performance goals. The built-in code splitting (`import()` dynamic imports for lazy routes) and tree-shaking require no config changes — they are on by default. The manual chunking config for vendor splitting (Pinia, Vue Router) in `build.rollupOptions.output.manualChunks` can be used if the baseline analysis shows large vendor chunks being repeated across async routes.
**Do NOT upgrade to Vite 7 or 8** — both are in beta as of this research date and are not appropriate for a production milestone.
```bash
npm install -D vite@^6.4.3 @vitejs/plugin-vue@^5.0.0
```
---
## Vue Version Bump
**Required:** `^3.4.0``^3.5.0`
**Confidence:** HIGH (no breaking changes for Options API; confirmed from official Vue 3.5 blog post)
This is required by `@vueuse/core@^14.0`. Vue 3.5 is a drop-in upgrade for Options API components — the release is purely additive (reactivity performance improvements, `useTemplateRef()` for Composition API). No component changes are needed.
```bash
npm install vue@^3.5.0
```
---
## Backend — No New Python Packages Needed
**Confidence:** HIGH
FastAPI's `APIRouter` (already used) is the correct and sufficient tool for router decomposition. Splitting large router files into smaller modules uses only `APIRouter(prefix=..., tags=[...])` and `app.include_router()` — both already in the project. No new library is warranted.
**Pattern for large router decomposition:**
```
backend/routers/
documents/
__init__.py # from .upload import router as upload_router; etc.
upload.py # APIRouter for POST /documents
search.py # APIRouter for GET /documents
share.py # APIRouter for share endpoints
admin/
__init__.py
users.py
audit.py
settings.py
```
Include in `main.py`:
```python
from routers.documents import router as documents_router
from routers.admin import router as admin_router
app.include_router(documents_router, prefix="/documents", tags=["documents"])
app.include_router(admin_router, prefix="/admin", tags=["admin"])
```
This is a pure refactor — no new dependencies, no behavior change, no test changes except updating import paths.
---
## Full v0.2 Dependency Diff
### `npm install` (production dependencies)
```bash
npm install \
vue@^3.5.0 \
@vueuse/core@^14.3.0 \
@vueuse/integrations@^14.3.0 \
sortablejs@^1.15.7 \
vue-virtual-scroller@^3.0.4
```
### `npm install -D` (dev dependencies)
```bash
npm install -D \
vite@^6.4.3 \
@vitejs/plugin-vue@^5.0.0 \
@tailwindcss/forms@^0.5.11 \
rollup-plugin-visualizer@^7.0.1 \
@types/sortablejs@^1.15.0
```
### Python backend additions
**None.** All backend work (router decomposition, service extraction) uses FastAPI primitives already present.
---
## Alternatives Considered and Rejected
| Category | Rejected | Reason |
|----------|----------|--------|
| Drag-and-drop file upload | `dropzone-vue`, `vue-file-uploader` | Opinionated UI, poor maintenance, duplicates existing backend upload flow |
| Drag-and-drop sorting | `vue-draggable-plus@0.6.1` | Adds Vue component abstraction over SortableJS that conflicts with data-provider view pattern; VueUse `useSortable` covers the same need with less surface area |
| Virtual scrolling | `@tanstack/vue-virtual` | Lower-level API requiring more integration code than `vue-virtual-scroller`; no meaningful bundle advantage; vue-virtual-scroller is Vue-native |
| Keyboard shortcuts | `hotkeys-js`, `vue-shortkey` | Unnecessary wrapper over `addEventListener`; VueUse `onKeyStroke` is sufficient and already in the bundle |
| Loading skeletons | `vue3-skeleton` | 5-line Tailwind pattern produces identical results; no justification for an additional dependency |
| Tailwind container queries | `@tailwindcss/container-queries` | Viewport breakpoints are sufficient for this app's layout; container queries solve a problem this codebase does not have |
| Tailwind typography | `@tailwindcss/typography` | No prose HTML content in this app |
| Vite 7 / 8 | Latest beta | Both in beta as of research date; not appropriate for a production milestone |
---
## Compatibility Matrix (v0.2 additions)
| Package | Version | Vue Requirement | Vite Compatibility | Tree-Shakeable |
|---------|---------|-----------------|-------------------|----------------|
| `@vueuse/core` | ^14.3.0 | ^3.5.0 | Vite 5+ | Yes — import individually |
| `@vueuse/integrations` | ^14.3.0 | ^3.5.0 | Vite 5+ | Yes |
| `sortablejs` | ^1.15.7 | — (plain JS) | Any | Yes |
| `vue-virtual-scroller` | ^3.0.4 | ^3.3.0 | Vite (ESM) | Partial (register components individually) |
| `@tailwindcss/forms` | ^0.5.11 | — (PostCSS) | Any | N/A (CSS plugin) |
| `rollup-plugin-visualizer` | ^7.0.1 | — (build tool) | Vite 5+ | N/A (dev only) |
| `vue` | ^3.5.0 | — | — | — |
| `vite` | ^6.4.3 | — | — | — |
---
## Sources
- VueUse v14.3.0 docs and peer dependencies: Context7 `/vueuse/vueuse`, verified via npm registry (2026-06-07)
- vue-virtual-scroller v3.0.4: Context7 `/akryum/vue-virtual-scroller`, npm registry (last published 2026-05-20)
- vue-draggable-plus v0.6.1: npm registry (last published 2026-01-11) — rejected
- sortablejs v1.15.7: npm registry (2026-06-07)
- Tailwind CSS container queries: official Tailwind CSS announcement — built into v4 core; plugin for v3 would be `@tailwindcss/container-queries` — rejected as out of scope
- @tailwindcss/forms: official Tailwind Labs GitHub (tailwindlabs/tailwindcss-forms), 4,500+ stars, supports v3 and v4
- rollup-plugin-visualizer: npm registry, v7.0.1 (2026-06-07)
- Vue 3.5 no-breaking-changes confirmation: https://blog.vuejs.org/posts/vue-3-5
- Vite 6 migration guide: https://v6.vite.dev/guide/migration
- FastAPI router decomposition: https://fastapi.tiangolo.com/tutorial/bigger-applications/ (built-in, no new deps)
---
*Stack research for: DocuVault v0.2 — UI Overhaul and Optimization*
*Researched: 2026-06-07*
- Do not mount user cloud drives into the container filesystem.
- Do not build a full Dropbox/Nextcloud sync daemon in v0.3.
- Do not mirror entire cloud accounts into MinIO by default.
- Do not create a second cloud file grid. Extend `StorageBrowser.vue`.
- Do not expose provider download URLs directly to the browser unless proxied and authorized.
+35 -139
View File
@@ -1,150 +1,46 @@
# DocuVault — Research Synthesis
# v0.3 Research Summary
_Last updated: 2026-05-21_
**Milestone:** v0.3 Reimagining Cloud Storage integration
**Date:** 2026-06-17
## Executive Summary
## Stack Additions
DocuVault is a brownfield migration of a functional single-user document scanner into a privacy-first, multi-user SaaS platform. The existing system already handles document upload, text extraction, and AI-based topic classification via a well-designed provider abstraction. This milestone replaces the flat-file JSON + filesystem persistence layer with PostgreSQL + MinIO, adds full multi-user authentication (JWT with httpOnly cookies, TOTP 2FA, refresh token rotation), per-user quota enforcement, folder organization, document sharing, and pluggable cloud storage backends — following the same adapter pattern already used for AI providers.
- Keep the current stack and add a provider capability/action layer rather than replacing storage.
- Add PostgreSQL-backed cloud item metadata and analysis/index state.
- Add a bounded byte-cache/offload service for preview/extraction/open flows.
- Use Celery for cloud analysis, cache hydration, retries, and provider refresh jobs.
- Use PostgreSQL full-text search for keyword/sentence search; consider `pgvector` for idea/semantic search without a separate vector DB.
---
## Feature Table Stakes
## Confirmed Stack
- Cloud files behave locally for open/preview/upload/move/delete/rename/create-folder where provider capabilities allow.
- Users can analyze selected files, folders, or whole connections.
- Analyzed cloud documents become classified and smart-searchable alongside local documents.
- Search never downloads bytes at query time; it reads persisted extracted/indexed data.
- DocuVault minimizes local storage by caching bytes only when needed and evicting them safely.
### Use
## Architecture Direction
| Package | Version | Purpose |
|---|---|---|
| `pyjwt[crypto]` | ≥2.12.1 | JWT — current FastAPI docs recommendation; replaces python-jose |
| `pwdlib[argon2]` | ≥0.2.0 | Password hashing — Argon2 is memory-hard (OWASP 2025) |
| `pyotp` | ≥2.9.0 | TOTP 2FA — RFC 6238 reference |
| `cryptography` (Fernet) | ≥44.0.0 | Credential encryption — AES-128-CBC + HMAC-SHA256 |
| `sqlalchemy[asyncio]` | ≥2.0.36 | ORM — async-native; better brownfield fit than SQLModel |
| `psycopg[asyncio,binary]` | ≥3.2.0 | PostgreSQL driver — single driver for async FastAPI + sync Alembic |
| `alembic` | ≥1.14.0 | DB migrations |
| `minio` | ≥7.2.0 | Object storage — presigned URL flow (FastAPI never proxies bytes) |
| `msgraph-sdk` + `azure-identity` | ≥1.0.0 / ≥1.19.0 | OneDrive — official Microsoft SDK |
| `google-api-python-client` + `google-auth-oauthlib` | ≥2.150.0 / ≥1.2.0 | Google Drive v3 |
| `webdav4` | ≥0.9.8 | Nextcloud + generic WebDAV |
- Keep provider-owned cloud bytes as source of truth.
- Store metadata, text, topics, embeddings, and statuses in DocuVault.
- Separate local/imported `documents` from cloud item index records unless a later phase intentionally unifies them.
- Extend `StorageBrowser.vue` with capability-aware actions instead of creating a second file manager.
- Split `backend/api/cloud.py` as behavior grows.
### Do NOT Use
## Watch Out For
- `python-jose` — FastAPI dropped it; use PyJWT
- `passlib[bcrypt]` for new hashes — maintenance mode; keep only for migrating existing hashes
- `tiangolo/uvicorn-gunicorn-fastapi` Docker image — deprecated; use `python:3.12-slim`
- `localStorage` for any auth token — XSS-accessible; httpOnly cookie for refresh, Pinia memory for access token
- Single platform Fernet key for all users — HKDF per-user derivation required (catastrophic blast radius otherwise)
- `SQLModel` for this migration — async story is thin; SQLAlchemy 2.0 async is better for brownfield
- Do not accidentally build a full cloud mirror.
- Treat destructive cloud actions as real provider mutations.
- Model provider capability differences explicitly.
- Track etags/versions so analyzed search data can become stale instead of silently wrong.
- Add preflight estimates and confirmation before large "analyze all" jobs.
- Preserve SSRF, credential encryption, ownership checks, and admin privacy boundaries.
---
## Key Sources
## Table-Stakes Features for v1
### Confirmed (from PROJECT.md)
- Email + password registration + JWT sessions with refresh tokens
- TOTP 2FA + backup codes *(see gap below)*
- Password reset via email
- Per-user isolated storage (100 MB free tier)
- Quota tracking, enforcement at upload, visible indicator
- Folder CRUD, move documents, "Shared with me" folder
- Share by handle, view-only default, immediate revoke
- Cloud OAuth2 connect flow + credential encryption
- Admin: user management, quota adjustment, AI provider assignment
- Audit log (append-only, metadata only) + admin viewer
- In-browser PDF preview
### Gaps — Items PROJECT.md Missed
1. **TOTP backup codes** — Every competitor ships these. Without them, a lost phone permanently locks users out. 810 single-use codes, stored hashed, acknowledged by user before TOTP is activated.
2. **Quota warnings at 80% and 95%** — PROJECT.md specifies rejection at 100% only. Pre-emptive warnings are table stakes (Google Drive, Dropbox both do this). In-app banner at 80% (amber) and 95% (red), plus a specific error at 100% showing current usage, rejected file size, and a link to storage settings.
3. **"Sign out all devices" / session revocation** — Users who believe their account is compromised need forced logout everywhere. Already handled by the `refresh_tokens` table — requires only an endpoint and a UI control.
4. **Breadcrumb navigation** — Folder CRUD is in PROJECT.md but not the navigation UX. Required for nested folder usability.
5. **Cloud storage connection status indicator** — PROJECT.md doesn't specify what happens when cloud storage is unreachable. Silent failure = data loss. Must show `ACTIVE | REQUIRES_REAUTH | ERROR` state and fall back to local storage with a clear message.
6. **Admin impersonation is an explicit architectural exclusion** — Must be documented as excluded, not just left unbuilt. Directly contradicts the privacy-first core value.
---
## Critical Architectural Decisions (Lock Before Building)
These cannot be safely retrofitted:
**1. JWT in httpOnly cookies**
Refresh token: `httpOnly; Secure; SameSite=Strict` cookie. Access token: Pinia memory only. Never `localStorage`. Vue Router guard silently refreshes before redirecting to login. Axios `withCredentials: true`.
**2. HKDF per-user key derivation for cloud credentials**
`HKDF(master_key, salt=user_id_bytes, info=b"cloud-credentials")`. Master key in `CLOUD_CREDS_KEY` env var only. Salt in users table. Design before writing the first line of credential storage — cannot be added later without re-encrypting everything.
**3. Presigned MinIO URL flow**
FastAPI generates signed PUT URL → browser uploads directly to MinIO → FastAPI confirms object and commits quota atomically. FastAPI handles metadata only; bytes never pass through the API layer. Object keys: `{user_id}/{document_id}/{uuid4()}{ext}`. Human-readable filename in DB only.
**4. Atomic PostgreSQL quota enforcement**
`UPDATE quotas SET used_bytes = used_bytes + $delta WHERE user_id = $uid AND (used_bytes + $delta) <= limit_bytes RETURNING used_bytes`. If 0 rows returned, delete the MinIO object and return 413. Never perform quota arithmetic in Python between two DB statements.
**5. BackgroundTasks replacement before horizontal scaling**
FastAPI `BackgroundTasks` is per-instance — classification tasks cannot distribute across containers. Replace with Celery + Redis or pgqueuer (PostgreSQL-backed, no Redis dependency) before scaling to N instances. Decide during Phase 3 planning.
**Additional locked decisions:**
- Refresh tokens are opaque UUIDs stored hashed in DB (not JWTs); access tokens are short-lived JWTs (15 min).
- `refresh_tokens` table has `family_id` — on reuse of a rotated token, revoke entire family and emit security alert.
- Audit log uses `BIGSERIAL` PK; app DB user has INSERT + SELECT only (no UPDATE/DELETE).
- Admin endpoints for cloud connections return only `provider, display_name, connected_at, status` — never `credentials_enc`.
- Every document/folder endpoint asserts `resource.user_id == current_user.id` via centralized `assert_document_access()`.
---
## 5-Phase Migration Sequence
### Phase 1 — Infrastructure Foundation
Wire PostgreSQL + MinIO into Docker Compose. Create `db/models.py` with full schema. Alembic initial migration. Async session dependency. No API changes — flat-file code still runs. Gate: all services boot cleanly; migrations apply; no behavior change.
### Phase 2 — Users and Authentication
Users, refresh_tokens, quotas tables. Auth endpoints (register, login, refresh, TOTP, password reset, forced logout). TOTP with backup codes. Password reset does NOT auto-login (routes through TOTP gate). `get_current_user` + `get_current_admin` FastAPI dependencies. Admin user management endpoints. Vue auth store (Pinia memory + httpOnly cookie), Router guard, Axios interceptors. Gate: admin JWT returns 403 on document endpoints; backup codes issued and acknowledged at enrollment.
### Phase 3 — Document Migration to PostgreSQL + MinIO
Dual-write window: new uploads write to both stores. Migration script copies historical flat-file data to PostgreSQL + MinIO. Count reconciliation assertion (go/no-go gate). Flip read source to PostgreSQL. Remove JSON write path. Presigned URL flow for all uploads/downloads. `asyncio.to_thread()` wrapping all MinIO SDK calls. Gate: concurrent upload test at 99% quota — only one succeeds.
### Phase 4 — Multi-User Isolation, Quotas, Folders, Sharing
All queries gain `WHERE user_id = current_user.id`. Quota bar (80%/95% warnings). Folder CRUD + breadcrumbs. Document move + sort. Share by handle + "Shared with me" folder. Audit log wired to all events. Admin audit viewer. In-browser PDF preview. Gate: negative-access test (admin cannot retrieve any document content); quota reconciliation drift <1%.
### Phase 5 — Cloud Storage Backends
`StorageBackend` ABC + factory (mirrors `ai/` pattern). `MinIOBackend`, `OneDriveBackend`, `GoogleDriveBackend`, `NextcloudBackend`, `WebDAVBackend`. OAuth2 connect/disconnect flows. Connection status UX. HKDF key derivation for all credentials. `delete_user_files()` on account deletion. Gate: mock `invalid_grant` → REQUIRES_REAUTH (not 500); account deletion asserts `delete_user_files()` per connection.
---
## Top 5 Pitfalls by Risk
| # | Pitfall | Severity | Fix |
|---|---|---|---|
| 1 | JWT in localStorage — XSS bypasses TOTP entirely | CRITICAL | httpOnly cookie for refresh, Pinia memory for access token |
| 2 | Quota race condition — concurrent uploads bypass limit | DATA INTEGRITY | Atomic PostgreSQL `UPDATE ... RETURNING` |
| 3 | TOTP bypass via password reset — full 2FA bypass via email compromise | SECURITY | Reset issues `password_reset_pending` state, not a full session |
| 4 | Single Fernet key for all cloud credentials — catastrophic on key leak | CATASTROPHIC | HKDF per-user derivation before first credential is stored |
| 5 | Path traversal in MinIO keys — cross-user data access | SECURITY | UUID-only MinIO keys; human filename in DB only; never reconstruct key from request parameters |
---
## Confidence Assessment
| Area | Confidence | Notes |
|---|---|---|
| Stack | MEDIUM-HIGH | Core libraries confirmed from FastAPI official release notes (PyJWT, pwdlib, SQLAlchemy 2.0, psycopg v3). Cloud SDK minor versions — verify on PyPI before pinning. |
| Features | MEDIUM | Based on Google Drive, Dropbox, Box, Paperless-ngx knowledge through Aug 2025. |
| Architecture | HIGH | FastAPI DI pattern from official docs; S3 presigned URLs and atomic PostgreSQL quota update are industry standards. |
| Pitfalls | HIGH | OWASP cheat sheets; RFC 9700 refresh token rotation; GDPR Article 17 stable regulatory text. |
**Overall: MEDIUM-HIGH**
---
## Gaps to Resolve During Planning
- Verify cloud SDK minor versions on PyPI before pinning
- Confirm PyOTP `valid_window` default in current docs (recommend `valid_window=1` for ±30s clock drift)
- Decide Celery + Redis vs pgqueuer during Phase 3 (depends on Redis availability in deployment target)
- Audit existing codebase for any existing bcrypt hashes before removing `passlib`
- Validate MinIO Docker Compose public endpoint in Phase 3 acceptance testing (presigned URLs must use host-accessible address, not internal Docker network name)
- Microsoft Graph `driveItem`: https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0
- Google Drive uploads: https://developers.google.com/workspace/drive/api/guides/manage-uploads
- Nextcloud WebDAV operations: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html
- Celery task retries: https://docs.celeryq.dev/en/stable/userguide/tasks.html
- Redis cache eviction: https://redis.io/docs/latest/develop/reference/eviction/
- pgvector: https://github.com/pgvector/pgvector