--- phase: "06.2" plan: "04" type: execute wave: 2 depends_on: - "06.2-02" - "06.2-03" files_modified: - backend/api/audit.py - frontend/src/components/admin/AuditLogTab.vue - frontend/src/api/client.js - backend/tests/test_audit.py autonomous: true requirements: - ADMIN-06 must_haves: truths: - "Audit log JSON viewer returns user_handle and actor_handle alongside user_id and actor_id" - "GET /api/admin/audit-log?user_handle=X filters to entries for that user" - "GET /api/admin/audit-log?user_handle=nonexistent returns empty items list, not 422" - "CSV export button in AuditLogTab downloads a file via fetch+Blob (not window.location.href)" - "GET /api/admin/audit-log/daily-exports returns sorted list of available export dates" - "GET /api/admin/audit-log/daily-exports/{date} streams the CSV for that date" - "Daily exports section in AuditLogTab shows date dropdown + Download button" - "Date path parameter validated against YYYY-MM-DD regex before MinIO key construction" artifacts: - path: "backend/api/audit.py" provides: "handle-enriched query; user_handle filter; two daily-export endpoints" contains: "_audit_to_dict_with_handles" - path: "frontend/src/api/client.js" provides: "adminExportAuditLogCsv(), adminListDailyExports(), adminDownloadDailyExport()" contains: "adminExportAuditLogCsv" - path: "frontend/src/components/admin/AuditLogTab.vue" provides: "fixed exportCsv(), daily exports section, user_handle filter label" contains: "Daily exports" key_links: - from: "backend/api/audit.py list_audit_log" to: "User table (aliased twice)" via: "outerjoin on user_id and actor_id FKs" pattern: "outerjoin.*UserSubject|outerjoin.*UserActor" - from: "backend/api/audit.py list_daily_exports" to: "MinIO audit-logs bucket" via: "asyncio.to_thread(_list)" pattern: "asyncio.to_thread" - from: "frontend/src/components/admin/AuditLogTab.vue:exportCsv" to: "adminExportAuditLogCsv() in client.js" via: "fetch() + Blob URL — no window.location.href" pattern: "adminExportAuditLogCsv" --- Close the ADMIN-06 gaps in a single vertical slice: user handles in audit log responses, handle-based filter, fixed CSV export download, and a new daily-export listing + download UI. Purpose: Admins can now see who performed actions by name (not UUID), filter by handle without 422 errors, download exports that actually arrive (not a 401 from window.location.href), and access the Celery-generated daily export files from the admin panel. Output: Modified audit.py (handle JOIN, user_handle filter, two new endpoints), modified AuditLogTab.vue (filter label, fetch+Blob exportCsv, daily-export section), new client.js functions, five promoted test stubs. @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md @/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-CONTEXT.md From backend/api/audit.py (current state): def _audit_to_dict(entry: AuditLog) -> dict: # Returns: id, event_type, user_id, actor_id, resource_id, ip_address, metadata_, created_at # Does NOT return user_handle or actor_handle def _build_filtered_query(start, end, user_id: Optional[uuid.UUID], event_type): # Accepts user_id as UUID type — FastAPI validates this via Query(Optional[uuid.UUID]) # This type annotation causes FastAPI to 422 on non-UUID strings @router.get("/audit-log") async def list_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: must change to Optional[str] ... ) @router.get("/audit-log/export") async def export_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: same fix needed ... ) # Both endpoints must be updated to accept user_handle: Optional[str] From backend/db/models.py (User model — key fields): User.id: UUID User.handle: str (unique, indexed) From backend/tasks/audit_tasks.py line 79: key = f"audit-logs/{yesterday.isoformat()}.csv" # MinIO bucket: "audit-logs" # Key pattern: "audit-logs/YYYY-MM-DD.csv" From backend/storage/__init__.py: def get_storage_backend() -> StorageBackend: # Returns MinIOBackend; has ._client attribute (Minio SDK instance) From backend/storage/minio_backend.py: # _client: Minio SDK instance # _client.list_objects(bucket, prefix, recursive) → synchronous iterator # _client.get_object(bucket, key) → response with .read() and .release_conn() From frontend/src/api/client.js (existing patterns): # request() wrapper: always calls res.json() — NOT for CSV responses # fetchDocumentContent() at lines 399-428: raw fetch() pattern with Authorization header # export async function fetchDocumentContent(docId, options = {}) { ... } From frontend/src/components/admin/AuditLogTab.vue (current state): # filters reactive object: { start, end, user_id, event_type } # exportCsv() at lines 185-192: uses window.location.href (broken) # fetchLog() sends user_id: filters.user_id to adminListAuditLog() # Table renders: entry.user_handle || entry.user_id || '—' (line 89 — already expects handle) From .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md: C-4: Daily Exports Section — below pagination block, border-t separator C-5: User filter label change from "User" to "User handle" Copywriting: section label "Daily exports", dropdown label "Select date", button "Download" Task 1: Backend — handle enrichment, user_handle filter, two daily-export endpoints backend/api/audit.py, backend/tests/test_audit.py - backend/api/audit.py — read the full file; understand _audit_to_dict(), _build_filtered_query(), both existing endpoints and their exact Query parameter signatures; understand how both endpoints share _build_filtered_query - backend/db/models.py — search for "class User" and "class AuditLog" to confirm handle field and user_id/actor_id FK field names - backend/storage/__init__.py — read lines 32-50 (get_storage_backend factory) to understand how to get the MinIOBackend instance for the daily-export endpoints; confirm _client attribute - backend/tasks/audit_tasks.py — read lines 78-86 to confirm the MinIO bucket name ("audit-logs") and key pattern ("audit-logs/YYYY-MM-DD.csv") - backend/tests/test_audit.py — read the full file to understand _seed_audit helper, admin_user fixture, and existing test patterns before promoting stubs - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 3 (aliased double-JOIN), Pattern 4 (handle-to-UUID resolution), Pattern 6 (list_objects), Pattern 7 (daily export streaming), Pitfall 4 (COUNT query breaks after JOIN), Pitfall 6 (date regex), Pitfall 7 (both endpoints must use enriched function) - test_audit_log_includes_user_handle: Seed an audit entry for admin_user. GET /api/admin/audit-log. Assert each item in items has keys "user_handle" and "actor_handle". Assert the first item's user_handle matches admin_user["user"].handle (not None for a seeded entry). - test_audit_log_filter_by_handle: Seed one entry for admin_user. Seed one entry for a second distinct user. GET /api/admin/audit-log?user_handle={admin_user.handle}. Assert items contains only entries matching admin_user (user_handle == admin_user.handle). Seeded second entry must not appear. - test_audit_log_filter_unknown_handle: GET /api/admin/audit-log?user_handle=definitely_does_not_exist. Assert status 200. Assert response body items == []. Assert total == 0. Assert no 422 error. - test_daily_exports_list: Mock MinIOBackend._client.list_objects to return fake objects (or patch get_storage_backend and its _client). GET /api/admin/audit-log/daily-exports. Assert status 200. Assert response has "items" key. Items sorted descending by date. - test_daily_export_download: Mock MinIOBackend._client.get_object to return fake CSV bytes. GET /api/admin/audit-log/daily-exports/2026-05-30. Assert status 200. Assert Content-Type: text/csv. Assert Content-Disposition header contains "2026-05-30". Also test GET /api/admin/audit-log/daily-exports/invalid-date returns 404. Make these changes to backend/api/audit.py: CHANGE 1 — Add SQLAlchemy aliased imports and User import check: Add `from sqlalchemy.orm import aliased` to the imports if not already present. Confirm `User` is already imported from `db.models`. CHANGE 2 — New helper _audit_to_dict_with_handles(): Add a new function `_audit_to_dict_with_handles(entry: AuditLog, user_handle: Optional[str], actor_handle: Optional[str]) -> dict` that returns the same dict as `_audit_to_dict(entry)` PLUS two additional keys: `"user_handle": user_handle or None` and `"actor_handle": actor_handle or None`. Do NOT remove or rename `_audit_to_dict` — preserve it as a fallback. CHANGE 3 — New query builder _build_filtered_query_with_handles(): Add function `_build_filtered_query_with_handles(start, end, user_uuid, event_type)` that builds a multi-column select: ``` UserSubject = aliased(User) UserActor = aliased(User) stmt = ( select(AuditLog, UserSubject.handle.label("user_handle"), UserActor.handle.label("actor_handle")) .outerjoin(UserSubject, UserSubject.id == AuditLog.user_id) .outerjoin(UserActor, UserActor.id == AuditLog.actor_id) .order_by(AuditLog.created_at.desc()) ) ``` Apply the same start/end/user_uuid/event_type filters as the original `_build_filtered_query`. Return the statement. This is a standalone function, NOT replacing `_build_filtered_query` (the old function stays for the count query — see Pitfall 4). CHANGE 4 — Update list_audit_log endpoint: Change `user_id: Optional[uuid.UUID] = Query(default=None)` to `user_handle: Optional[str] = Query(default=None)`. Add handle-to-UUID resolution logic before executing the main query (Pattern 4 from RESEARCH.md): ```python user_uuid: Optional[uuid.UUID] = None if user_handle: result = await session.execute(select(User.id).where(User.handle == user_handle)) uid = result.scalar_one_or_none() if uid is None: return {"items": [], "total": 0, "page": page, "per_page": per_page} user_uuid = uid ``` For the count query, use the ORIGINAL `_build_filtered_query(start, end, user_uuid, event_type)` to avoid the COUNT subquery problem (Pitfall 4). Count query is unchanged. For the data query, use `_build_filtered_query_with_handles(start, end, user_uuid, event_type)`. Add `.limit(per_page).offset((page - 1) * per_page)`. Execute. Iterate `result.all()` as tuples: `for row in rows: entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]`. Build each item with `_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)`. CHANGE 5 — Update export_audit_log endpoint: Apply the same user_handle→user_uuid resolution (identical block as above). Use `_build_filtered_query_with_handles` for the data query. Iterate rows as tuples. Use `_audit_to_dict_with_handles` for CSV serialization. Add `"user_handle"` and `"actor_handle"` to the `fields` list for the CSV DictWriter. This satisfies Pitfall 7 (both endpoints must use enriched function). CHANGE 6 — Add two new endpoints for daily exports: Before the existing endpoints, add necessary imports: `import asyncio`, `import re`. The `StreamingResponse` import should already be present. Add endpoint `@router.get("/audit-log/daily-exports")`: - Auth: `_admin: User = Depends(get_current_admin)` - No session param needed (MinIO call only) - Body: get the MinIO backend via `from storage import get_storage_backend; from storage.minio_backend import MinIOBackend; backend = get_storage_backend()`. If not MinIOBackend, return `{"items": []}`. - Define inner `_list() -> list[dict]` function (synchronous) that calls `backend._client.list_objects("audit-logs", prefix="audit-logs/", recursive=False)`, iterates objects, filters `.endswith(".csv")`, extracts date from `obj.object_name.removeprefix("audit-logs/").removesuffix(".csv")`, builds `{"date": date_str, "key": obj.object_name}`, sorts by date descending. - Execute: `items = await asyncio.to_thread(_list)` - Return `{"items": items}` Add endpoint `@router.get("/audit-log/daily-exports/{date}")`: - Auth: `_admin: User = Depends(get_current_admin)` - Path param: `date: str` - Date validation (Pitfall 6 / D-16): `if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): raise HTTPException(404, "Invalid date format")` - Get backend, construct key = `f"audit-logs/{date}.csv"` - Define inner `_get() -> bytes` (synchronous): `response = backend._client.get_object("audit-logs", key); try: return response.read(); finally: response.close(); response.release_conn()` - Execute: wrap in `try: csv_bytes = await asyncio.to_thread(_get); except Exception: raise HTTPException(404, "Export not found")` - Return `StreamingResponse(iter([csv_bytes]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'})` CRITICAL: The two new endpoints must be placed BEFORE the existing `@router.get("/audit-log/export")` and `@router.get("/audit-log")` in the file, because FastAPI routes are matched in registration order. The path `/audit-log/daily-exports` is more specific than `/audit-log` and must be registered first. Or, at minimum, place them before the `@router.get("/audit-log")` GET handler. Then in backend/tests/test_audit.py: promote all five xfail stubs. Use `unittest.mock.patch` to mock `storage.get_storage_backend` for the daily-export endpoint tests, returning a mock MinIOBackend with a `_client` mock. cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_audit.py::test_audit_log_includes_user_handle tests/test_audit.py::test_audit_log_filter_by_handle tests/test_audit.py::test_audit_log_filter_unknown_handle tests/test_audit.py::test_daily_exports_list tests/test_audit.py::test_daily_export_download -x -v 2>&1 | tail -25 - All five promoted tests pass - `grep "_audit_to_dict_with_handles" backend/api/audit.py` returns at least 2 matches (definition + both endpoint usages — Pitfall 7) - `grep "user_handle" backend/api/audit.py` returns at least 4 matches - `grep "daily-exports" backend/api/audit.py` returns 2 matches (two new endpoints) - `grep "fullmatch" backend/api/audit.py` returns a match (date regex validation) - `pytest tests/test_audit.py -x -q` exits 0 Task 2: Frontend — user_handle filter, fetch+Blob export, daily-export section frontend/src/components/admin/AuditLogTab.vue, frontend/src/api/client.js - frontend/src/components/admin/AuditLogTab.vue — read the full file; understand filters reactive object (filters.user_id must become filters.user_handle), fetchLog() which passes params to adminListAuditLog(), exportCsv() (broken window.location.href on lines 185-192), pagination block location (where to add the new daily-export section below it) - frontend/src/api/client.js — read lines 395-435 (fetchDocumentContent — the fetch+Blob reference pattern); search for "adminListAuditLog" to find its current implementation; note that request() wrapper always calls res.json() and must NOT be used for CSV responses - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 5 (fetch+Blob URL for CSV), Pattern 6 (adminListDailyExports signature), Pattern 7 (adminDownloadDailyExport) - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md — C-4 (daily exports section markup), C-5 (user filter label), Copywriting Contract (section copy), State Inventory (loading/empty/populated states) Make two file changes: CHANGE 1 — frontend/src/api/client.js: add three new functions Follow the exact fetch+Blob pattern from fetchDocumentContent (lines 399-428) — NOT using the request() wrapper. Add `adminExportAuditLogCsv(params = {})`: - Import useAuthStore lazily (same pattern as fetchDocumentContent) - Build URLSearchParams with format=csv; add start, end, event_type if provided; add user_handle if provided (NOT user_id — the backend param is now user_handle) - Raw fetch to `/api/admin/audit-log/export?${searchParams}` with Authorization Bearer header and credentials: 'include' - On !res.ok: throw Error(`Export failed: ${res.status}`) - `const text = await res.text()` (NOT res.json()) - Create Blob([text], { type: 'text/csv' }), URL.createObjectURL, create `` element, set href + download='audit-export.csv', click, URL.revokeObjectURL Add `adminListDailyExports()`: - Raw fetch to `/api/admin/audit-log/daily-exports` with Authorization Bearer header - On !res.ok: throw Error - Return `await res.json()` — this endpoint returns JSON Add `adminDownloadDailyExport(date)`: - Raw fetch to `/api/admin/audit-log/daily-exports/${date}` with Authorization Bearer header and credentials: 'include' - On !res.ok: throw Error(`Download failed: ${res.status}`) - `const text = await res.text()` - Blob + URL.createObjectURL + `` click with download=`audit-${date}.csv` + revokeObjectURL CHANGE 2 — frontend/src/components/admin/AuditLogTab.vue: three UI changes CHANGE 2a — User filter label and binding (per D-12, C-5): In the filters reactive object, rename `user_id: ''` to `user_handle: ''`. In the fetchLog() function, change `user_id: filters.user_id || undefined` to `user_handle: filters.user_handle || undefined`. In the template filter bar, change the label text from "User" to "User handle". Change `v-model="filters.user_id"` to `v-model="filters.user_handle"`. Update adminListAuditLog() call to pass `user_handle` not `user_id` (check the existing call signature in fetchLog). CHANGE 2b — Fix exportCsv() (per D-13): Replace the entire body of `function exportCsv()` with an async call to `api.adminExportAuditLogCsv({...})`. Change the function declaration to `async function exportCsv()`. Pass current filter values: `start: filters.start || undefined, end: filters.end || undefined, user_handle: filters.user_handle || undefined, event_type: filters.event_type || undefined`. Add a ref `exportingCsv` (boolean, default false) and set it true/false around the call. On error: show an alert or set an error ref with "Export failed. Please try again." CHANGE 2c — Add daily exports section (per D-17, C-4 from UI-SPEC): Add new reactive state in script setup: - `dailyExports` ref (Array, default []) - `loadingExports` ref (boolean, default false) - `selectedExportDate` ref (string, default '') - `downloadingExport` ref (boolean, default false) - `exportsError` ref (string, default null) Add `loadDailyExports()` async function that calls `await api.adminListDailyExports()` and populates `dailyExports.value` from `data.items`. Set `loadingExports` accordingly. Call `loadDailyExports()` inside `onMounted()` alongside the existing `fetchLog()` call. Add `downloadDailyExport()` async function that calls `await api.adminDownloadDailyExport(selectedExportDate.value)`. Set `downloadingExport` true/false. On error: set `exportsError.value = "Download failed. Please try again."`. In the template, add the daily-export section below the pagination block, following C-4 markup from UI-SPEC: - Section separator: `
` - Section label: `

Daily exports

` - Loading state: `

Loading exports…

` - Empty state: `

No daily exports available.

` - Controls row (v-else): `
` - `