---
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 endpointsbackend/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 sectionfrontend/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: `