# T04 — DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Share Links)

## Project Root
/a0/usr/workdir/dev-projects/leocrm

## Context Files (READ FIRST)
- `app/plugins/base.py` — BasePlugin abstract class
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef
- `app/plugins/builtins/tags/` — Reference plugin (subdirectory pattern)
- `app/plugins/builtins/permissions/` — Already has share links + file permissions
- `app/plugins/builtins/entity_links/` — Links files to companies/contacts
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
- `app/models/company.py` — Model pattern reference
- `app/routes/companies.py` — Route pattern reference
- `app/schemas/company.py` — Schema pattern reference
- `architecture.md` — Architecture decisions
- `requirements.md` — F-DMS-*, F-FILE-*, F-FILEUI-* requirements

## Overview
Implement DMS (Document Management System) plugin as `app/plugins/builtins/dms/`.

## Plugin Structure
```
app/plugins/builtins/dms/
├── __init__.py          # Export DmsPlugin
├── plugin.py            # DmsPlugin(BasePlugin) with manifest
├── models.py            # Folder, File models
├── schemas.py           # Pydantic schemas for all endpoints
├── routes.py            # FastAPI APIRouter with all endpoints
└── migrations/
    └── 0001_initial.sql # Create folders + files tables
```

## Models

### Folder
- id (UUID, PK)
- name (str, not null)
- parent_id (UUID, FK to folders.id, nullable — null = root)
- tenant_id (UUID, not null)
- created_by (UUID, not null)
- deleted_at (datetime, nullable — soft delete)
- created_at, updated_at (TimestampMixin)
- **Unique constraint**: (name, parent_id, tenant_id) where deleted_at IS NULL

### File
- id (UUID, PK)
- name (str, not null)
- folder_id (UUID, FK to folders.id, nullable — null = root)
- tenant_id (UUID, not null)
- uploaded_by (UUID, not null)
- mime_type (str, not null)
- size_bytes (int, not null)
- storage_path (str, not null — relative path on disk)
- deleted_at (datetime, nullable — soft delete)
- created_at, updated_at (TimestampMixin)

## File Storage
- Store files at: `/data/dms/{tenant_id}/{file_uuid}` (configurable via plugin config)
- Use `shutil.copyfileobj` for upload streaming
- Generate UUID for filename on disk, keep original name in DB
- Create directory with `os.makedirs(path, exist_ok=True)`

## Endpoints (19 ACs)

### Folders
1. `GET /api/v1/dms/folders` → 200, folder tree (recursive tree structure)
   - Query param `parent_id` (optional, null = root level)
   - Returns list of folders with children nested
2. `POST /api/v1/dms/folders` → 201, create folder
   - Body: `{name, parent_id?}`
   - Returns created folder with full path
3. `PATCH /api/v1/dms/folders/{id}` → 200, rename/move folder
   - Body: `{name?, parent_id?}`
4. `DELETE /api/v1/dms/folders/{id}` → 204, soft-delete (set deleted_at)
   - Cascade: soft-delete all child folders and files

### Files
5. `POST /api/v1/dms/files/upload` → 201, multipart upload
   - Form fields: `file` (UploadFile), `folder_id?` (optional)
   - Store file on disk, create metadata record
   - Max file size: 100MB (configurable)
6. `GET /api/v1/dms/files/{id}` → 200, file metadata
7. `PATCH /api/v1/dms/files/{id}` → 200, rename/move
   - Body: `{name?, folder_id?}`
8. `DELETE /api/v1/dms/files/{id}` → 204, soft-delete
9. `POST /api/v1/dms/files/{id}/restore` → 200, restore from trash

### Preview & Edit
10. `GET /api/v1/dms/files/{id}/preview` → 200, PDF stream
    - Only for PDF files (mime_type == application/pdf)
    - Return `StreamingResponse` with `media_type='application/pdf'`
    - Non-PDF files: return 400
11. `POST /api/v1/dms/files/{id}/edit-session` → 200, OnlyOffice config
    - Return JSON config for OnlyOffice editor:
      ```json
      {
        "document": {"fileType": "docx", "key": "<uuid>", "title": "<filename>", "url": "<download_url>"},
        "editorConfig": {"mode": "edit", "callbackUrl": "<callback_url>", "user": {"id": "<user_id>", "name": "<user_name>"}}
      }
      ```
    - Only for Office files (docx, xlsx, pptx)
    - Non-Office files: return 400

### Sharing (INTERNAL — different from T11 permissions plugin)
**NOTE**: T11 permissions plugin already handles:
- `POST /api/v1/dms/files/{id}/share-link` — public share links with token
- `GET /api/public/share/{token}` — public access
- `POST /api/v1/dms/files/{id}/permissions` — grant permissions

T04 DMS plugin handles INTERNAL sharing (different endpoints):
12. `POST /api/v1/dms/files/{id}/share` → 200, internal share
    - Body: `{user_ids?: [uuid], group_ids?: [uuid], access_level: 'read'|'write'}`
    - Creates permission records (reuse permissions plugin Permission model OR DMS-specific)
13. `DELETE /api/v1/dms/files/{id}/share` → 204, remove share
    - Body: `{user_id?: uuid, group_id?: uuid}`

**IMPORTANT**: For `GET /api/public/share/{token}` (AC14, AC15) — T11 permissions plugin ALREADY implements this endpoint. Do NOT create a duplicate. If T11's endpoint already handles password-protected links (401 without password), then AC14 and AC15 are already satisfied. Verify by reading `app/plugins/builtins/permissions/routes.py`.

### Search & Bulk
14. `GET /api/v1/dms/search?q=text` → 200, matching files
    - Case-insensitive filename search with ILIKE
    - Search across all files in tenant (not deleted)
15. `GET /api/v1/dms/shared-with-me` → 200, shared files list
    - Files where user has been granted permission (via permissions plugin)
16. `POST /api/v1/dms/files/bulk-move` → 200
    - Body: `{file_ids: [uuid], target_folder_id: uuid?}`
17. `POST /api/v1/dms/files/bulk-delete` → 200
    - Body: `{file_ids: [uuid]}` — soft-delete all

## Migration SQL
```sql
CREATE TABLE IF NOT EXISTS folders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL,
    created_by UUID NOT NULL,
    deleted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_folders_parent ON folders(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_tenant ON folders(tenant_id) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS files (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
    tenant_id UUID NOT NULL,
    uploaded_by UUID NOT NULL,
    mime_type VARCHAR(255) NOT NULL,
    size_bytes BIGINT NOT NULL,
    storage_path VARCHAR(1024) NOT NULL,
    deleted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_tenant ON files(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_name ON files USING gin (to_tsvector('simple', name));
```

## Register Plugin
Add to `app/plugins/builtins/__init__.py`:
```python
from app.plugins.builtins.dms import DmsPlugin
__all__.append("DmsPlugin")
```

## Test File
Create `tests/test_dms.py` with tests for ALL 19 ACs.

### Test Patterns
- Follow existing test patterns in `tests/test_tags.py`, `tests/test_permissions.py`
- Use async test client via `httpx.AsyncClient` with `ASGITransport`
- Use existing fixtures from `tests/conftest.py`
- For file upload tests: use `httpx.AsyncClient.post` with `files={'file': ('test.pdf', b'%PDF-1.4...', 'application/pdf')}`
- For preview tests: verify response status 200 + content-type application/pdf
- For OnlyOffice: verify config structure returned
- For bulk operations: create multiple files, then bulk-move/bulk-delete

## Verification Commands
```bash
cd /a0/usr/workdir/dev-projects/leocrm
python -m pytest tests/test_dms.py -v --tb=short
python -m pytest tests/test_dms.py --cov=app/plugins/builtins/dms --cov-report=term-missing
```

## Acceptance Criteria (19 — ALL must pass)
1. GET /api/v1/dms/folders → 200 + folder tree
2. POST /api/v1/dms/folders → 201, folder created with path
3. PATCH /api/v1/dms/folders/{id} → 200, folder renamed/moved
4. DELETE /api/v1/dms/folders/{id} → 204, soft-delete
5. POST /api/v1/dms/files/upload (multipart) → 201, file stored + metadata
6. GET /api/v1/dms/files/{id} → 200 + file metadata
7. PATCH /api/v1/dms/files/{id} → 200, renamed/moved
8. DELETE /api/v1/dms/files/{id} → 204, soft-delete
9. POST /api/v1/dms/files/{id}/restore → 200, restored from trash
10. GET /api/v1/dms/files/{id}/preview → 200 + PDF stream
11. POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config
12. POST /api/v1/dms/files/{id}/share → 200, internal share created
13. DELETE /api/v1/dms/files/{id}/share → 204, share removed
14. GET /api/public/share/{token} → 200 (no auth, public access) — MAY already exist via T11
15. GET /api/public/share/{token} mit password → 401 ohne password — MAY already exist via T11
16. GET /api/v1/dms/search?q=text → 200 + matching files
17. GET /api/v1/dms/shared-with-me → 200 + shared files list
18. POST /api/v1/dms/files/bulk-move → 200, files moved
19. POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted

## Rules
- No placeholder code. No Lorem Ipsum.
- Follow existing patterns exactly (SQLAlchemy 2.0, FastAPI APIRouter, Pydantic v2)
- All routes must have tenant_id scoping
- Use `# noqa: F401` for __init__.py re-exports
- Use `from None` in except blocks (B904)
- File storage path: `/data/dms/{tenant_id}/{file_uuid}`
- OnlyOffice: generate config only, don't run OnlyOffice server
- For AC14/AC15: check if T11 permissions plugin already satisfies these. If yes, write tests that verify existing endpoint. If no, implement in DMS plugin.

## Deliverables
- All plugin files created
- Plugin registered in builtins __init__.py
- tests/test_dms.py with all 19 ACs tested
- All tests passing
- Coverage ≥80%
- Report: files created, test count + pass/fail, coverage %
