From 7e9fd4f66e33e3dc75f6c93ece76b372d08c48f2 Mon Sep 17 00:00:00 2001 From: Gordon YUEN Date: Tue, 28 Jul 2026 22:34:24 +0800 Subject: [PATCH] feat: list namespaces endpoint Add GET /api/v1/documents/namespaces returning the authenticated user's namespaces with active document counts. Per-user isolation and archived doc exclusion follow the existing list_documents conventions; query is covered by idx_documents_user_namespace_status. Previously users had no way to discover namespaces they had uploaded to since namespace is a free-form string label on document creation. --- apps/api/app/api/v1/routes/documents.py | 11 ++++ .../app/repositories/document_repository.py | 15 +++++ .../services/documents/lifecycle_service.py | 16 +++++ .../tests/contract/test_documents_contract.py | 63 +++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/apps/api/app/api/v1/routes/documents.py b/apps/api/app/api/v1/routes/documents.py index 16a17a93..771a2d99 100644 --- a/apps/api/app/api/v1/routes/documents.py +++ b/apps/api/app/api/v1/routes/documents.py @@ -60,6 +60,17 @@ async def list_documents( return response +@router.get("/namespaces") +async def list_namespaces( + current_user: CurrentUser = Depends(with_current_user), + db: AsyncSession = Depends(get_db), +): + return await _document_service.list_namespaces( + db, + user_id=current_user.user_id, + ) + + @router.get("/{document_id}") async def get_document( document_id: str, diff --git a/apps/api/app/repositories/document_repository.py b/apps/api/app/repositories/document_repository.py index 865f1230..d2d34d0a 100644 --- a/apps/api/app/repositories/document_repository.py +++ b/apps/api/app/repositories/document_repository.py @@ -54,6 +54,21 @@ async def count_by_user_namespace( ) return int(result.scalar_one()) + async def list_namespace_counts_for_user( + self, + db: AsyncSession, + *, + user_id: str, + ) -> Sequence[tuple[str, int]]: + result = await db.execute( + select(Document.namespace, func.count(Document.document_id)) + .where(Document.user_id == user_id) + .where(Document.status != "archived") + .group_by(Document.namespace) + .order_by(Document.namespace.asc()) + ) + return [(row[0], int(row[1])) for row in result.all()] + async def get_document( self, db: AsyncSession, diff --git a/apps/api/app/services/documents/lifecycle_service.py b/apps/api/app/services/documents/lifecycle_service.py index 1bd5ae3a..55adebc8 100644 --- a/apps/api/app/services/documents/lifecycle_service.py +++ b/apps/api/app/services/documents/lifecycle_service.py @@ -200,6 +200,22 @@ async def list_documents( }, } + async def list_namespaces( + self, + db: AsyncSession, + *, + user_id: str, + ) -> dict[str, Any]: + rows = await self._repository.list_namespace_counts_for_user( + db, + user_id=user_id, + ) + namespaces = [ + {"namespace": namespace, "document_count": count} + for namespace, count in rows + ] + return {"namespaces": namespaces} + async def list_document_chunks( self, db: AsyncSession, diff --git a/apps/api/tests/contract/test_documents_contract.py b/apps/api/tests/contract/test_documents_contract.py index 6b8e33b0..7623f1c6 100644 --- a/apps/api/tests/contract/test_documents_contract.py +++ b/apps/api/tests/contract/test_documents_contract.py @@ -1334,3 +1334,66 @@ async def test_should_archive_a_document_via_the_legacy_archive_route( assert response_json["archived_at"] assert persisted_document["status"] == "archived" assert persisted_document["archived_at"] is not None + + +@pytest.mark.asyncio +async def test_should_list_namespaces_with_document_counts_for_the_authenticated_user( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], +) -> None: + other_user_id = f"contract-user-{uuid4().hex[:12]}" + + async with developer_api_client_factory() as api_client: + await ContractDatabase.insert_user(user_id=other_user_id) + + await _insert_document(document_id=f"doc_{uuid4().hex[:12]}", namespace="alpha") + await _insert_document(document_id=f"doc_{uuid4().hex[:12]}", namespace="alpha") + await _insert_document(document_id=f"doc_{uuid4().hex[:12]}", namespace="beta") + await _insert_document( + document_id=f"doc_{uuid4().hex[:12]}", + namespace="other-user-ns", + user_id=other_user_id, + ) + await _insert_document( + document_id=f"doc_{uuid4().hex[:12]}", + namespace="archived-ns", + status="archived", + ) + + response = await api_client.get("/api/v1/documents/namespaces") + + assert response.status_code == 200 + + response_json = cast(dict[str, object], response.json()) + namespaces = cast(list[dict[str, object]], response_json["namespaces"]) + + assert namespaces == [ + {"namespace": "alpha", "document_count": 2}, + {"namespace": "beta", "document_count": 1}, + ] + + +@pytest.mark.asyncio +async def test_should_return_empty_namespace_list_when_user_has_no_documents( + developer_api_client_factory: Callable[ + [], AbstractAsyncContextManager[AsyncClient] + ], +) -> None: + async with developer_api_client_factory() as api_client: + other_user_id = f"contract-user-{uuid4().hex[:12]}" + await ContractDatabase.insert_user(user_id=other_user_id) + await _insert_document( + document_id=f"doc_{uuid4().hex[:12]}", + namespace="not-mine", + user_id=other_user_id, + ) + + response = await api_client.get("/api/v1/documents/namespaces") + + assert response.status_code == 200 + + response_json = cast(dict[str, object], response.json()) + namespaces = cast(list[dict[str, object]], response_json["namespaces"]) + + assert namespaces == []