Skip to content

Commit d8333cc

Browse files
committed
feat(root): merge relations pagination and worker retry
- Relations GET /api/v1/relations returns PaginatedResponse - Worker jobs use @with_retry() for transient HTTP error resilience - OpenAPI schema and mobile client regenerated
2 parents b87b4ef + e14977f commit d8333cc

30 files changed

Lines changed: 752 additions & 176 deletions

apps/api/openapi.json

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,29 @@
320320
"default": true,
321321
"title": "Active Only"
322322
}
323+
},
324+
{
325+
"name": "page",
326+
"in": "query",
327+
"required": false,
328+
"schema": {
329+
"type": "integer",
330+
"minimum": 1,
331+
"default": 1,
332+
"title": "Page"
333+
}
334+
},
335+
{
336+
"name": "limit",
337+
"in": "query",
338+
"required": false,
339+
"schema": {
340+
"type": "integer",
341+
"maximum": 100,
342+
"minimum": 1,
343+
"default": 20,
344+
"title": "Limit"
345+
}
323346
}
324347
],
325348
"responses": {
@@ -328,11 +351,7 @@
328351
"content": {
329352
"application/json": {
330353
"schema": {
331-
"type": "array",
332-
"items": {
333-
"$ref": "#/components/schemas/CareRelationResponse"
334-
},
335-
"title": "Response List Care Relations Api V1 Relations Get"
354+
"$ref": "#/components/schemas/PaginatedResponse_CareRelationResponse_"
336355
}
337356
}
338357
}
@@ -2025,6 +2044,26 @@
20252044
"title": "OAuthLoginRequest",
20262045
"description": "OAuth login request."
20272046
},
2047+
"PaginatedResponse_CareRelationResponse_": {
2048+
"properties": {
2049+
"data": {
2050+
"items": {
2051+
"$ref": "#/components/schemas/CareRelationResponse"
2052+
},
2053+
"type": "array",
2054+
"title": "Data"
2055+
},
2056+
"meta": {
2057+
"$ref": "#/components/schemas/PaginationMeta"
2058+
}
2059+
},
2060+
"type": "object",
2061+
"required": [
2062+
"data",
2063+
"meta"
2064+
],
2065+
"title": "PaginatedResponse[CareRelationResponse]"
2066+
},
20282067
"PaginatedResponse_MedicationResponse_": {
20292068
"properties": {
20302069
"data": {

apps/api/src/lib/ai/tools/log_wellness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async def execute(
9898

9999
# Alert caregivers on warning/emergency via worker
100100
if status_val in {"warning", "emergency"}:
101-
relations = await relations_repo.find_by_host(db, host_id)
101+
relations, _ = await relations_repo.find_by_host(db, host_id, limit=1000)
102102
all_tokens: list[str] = []
103103
for rel in relations:
104104
tokens = await get_user_token_strings(db, rel.caregiver_id)

apps/api/src/relations/repository.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import uuid
44

5-
from sqlalchemy import select
5+
from sqlalchemy import func, select
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from src.relations.model import CareRelation
@@ -35,31 +35,41 @@ async def find_by_host(
3535
host_id: uuid.UUID,
3636
*,
3737
active_only: bool = True,
38-
) -> list[CareRelation]:
39-
"""Find all care relations for a given host."""
40-
stmt = select(CareRelation).where(
41-
CareRelation.host_id == host_id,
42-
)
38+
limit: int = 20,
39+
offset: int = 0,
40+
) -> tuple[list[CareRelation], int]:
41+
"""Find care relations for a given host with pagination."""
42+
base = select(CareRelation).where(CareRelation.host_id == host_id)
4343
if active_only:
44-
stmt = stmt.where(CareRelation.is_active.is_(True))
44+
base = base.where(CareRelation.is_active.is_(True))
45+
46+
count_stmt = select(func.count()).select_from(base.subquery())
47+
total = (await db.execute(count_stmt)).scalar_one()
48+
49+
stmt = base.order_by(CareRelation.created_at.desc()).limit(limit).offset(offset)
4550
result = await db.execute(stmt)
46-
return list(result.scalars().all())
51+
return list(result.scalars().all()), total
4752

4853

4954
async def find_by_caregiver(
5055
db: AsyncSession,
5156
caregiver_id: uuid.UUID,
5257
*,
5358
active_only: bool = True,
54-
) -> list[CareRelation]:
55-
"""Find all care relations for a given caregiver."""
56-
stmt = select(CareRelation).where(
57-
CareRelation.caregiver_id == caregiver_id,
58-
)
59+
limit: int = 20,
60+
offset: int = 0,
61+
) -> tuple[list[CareRelation], int]:
62+
"""Find care relations for a given caregiver with pagination."""
63+
base = select(CareRelation).where(CareRelation.caregiver_id == caregiver_id)
5964
if active_only:
60-
stmt = stmt.where(CareRelation.is_active.is_(True))
65+
base = base.where(CareRelation.is_active.is_(True))
66+
67+
count_stmt = select(func.count()).select_from(base.subquery())
68+
total = (await db.execute(count_stmt)).scalar_one()
69+
70+
stmt = base.order_by(CareRelation.created_at.desc()).limit(limit).offset(offset)
6171
result = await db.execute(stmt)
62-
return list(result.scalars().all())
72+
return list(result.scalars().all()), total
6373

6474

6575
async def save(

apps/api/src/relations/router.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from fastapi import APIRouter, HTTPException, Query, status
44

55
from src.common.errors import AUTHZ_001, RES_001, raise_api_error
6-
from src.lib.authorization import authorize_relation_access
6+
from src.common.models import PaginatedResponse, PaginationParams
7+
from src.lib.authorization import authorize_host_access, authorize_relation_access
78
from src.lib.dependencies import CurrentUser, DBSession
89
from src.relations import service
910
from src.relations.schemas import (
@@ -42,44 +43,56 @@ async def create_care_relation(
4243
return CareRelationResponse.model_validate(relation)
4344

4445

45-
@router.get("", response_model=list[CareRelationResponse])
46+
@router.get("", response_model=PaginatedResponse[CareRelationResponse])
4647
async def list_care_relations(
4748
db: DBSession,
4849
user: CurrentUser,
4950
host_id: uuid.UUID | None = Query(default=None),
5051
caregiver_id: uuid.UUID | None = Query(default=None),
5152
active_only: bool = Query(default=True),
52-
) -> list[CareRelationResponse]:
53+
page: int = Query(default=1, ge=1),
54+
limit: int = Query(default=20, ge=1, le=100),
55+
) -> PaginatedResponse[CareRelationResponse]:
5356
"""List care relations filtered by host or caregiver.
5457
5558
Users can only list relations they participate in.
5659
"""
5760
user_uuid = uuid.UUID(user.id)
61+
params = PaginationParams(page=page, limit=limit)
5862

5963
if host_id:
6064
if user_uuid != host_id:
61-
# Verify caller is a caregiver for this host
62-
relations = await service.list_relations_for_host(
63-
db, host_id, active_only=active_only
64-
)
65-
if not any(r.caregiver_id == user_uuid for r in relations):
66-
raise_api_error(AUTHZ_001, status.HTTP_403_FORBIDDEN)
67-
return [CareRelationResponse.model_validate(r) for r in relations]
68-
relations = await service.list_relations_for_host(
69-
db, host_id, active_only=active_only
65+
await authorize_host_access(db, user=user, host_id=host_id)
66+
relations, total = await service.list_relations_for_host(
67+
db,
68+
host_id,
69+
active_only=active_only,
70+
limit=params.limit,
71+
offset=params.offset,
7072
)
7173
elif caregiver_id:
7274
if user_uuid != caregiver_id:
7375
raise_api_error(AUTHZ_001, status.HTTP_403_FORBIDDEN)
74-
relations = await service.list_relations_for_caregiver(
75-
db, caregiver_id, active_only=active_only
76+
relations, total = await service.list_relations_for_caregiver(
77+
db,
78+
caregiver_id,
79+
active_only=active_only,
80+
limit=params.limit,
81+
offset=params.offset,
7682
)
7783
else:
7884
raise HTTPException(
7985
status_code=status.HTTP_400_BAD_REQUEST,
8086
detail="Either host_id or caregiver_id is required",
8187
)
82-
return [CareRelationResponse.model_validate(r) for r in relations]
88+
89+
data = [CareRelationResponse.model_validate(r) for r in relations]
90+
return PaginatedResponse[CareRelationResponse].create(
91+
data=data,
92+
total=total,
93+
page=params.page,
94+
limit=params.limit,
95+
)
8396

8497

8598
@router.get("/{relation_id}", response_model=CareRelationResponse)

apps/api/src/relations/service.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,27 @@ async def list_relations_for_host(
4040
host_id: uuid.UUID,
4141
*,
4242
active_only: bool = True,
43-
) -> list[CareRelation]:
44-
"""List all care relations where user is host."""
45-
return await repository.find_by_host(db, host_id, active_only=active_only)
43+
limit: int = 20,
44+
offset: int = 0,
45+
) -> tuple[list[CareRelation], int]:
46+
"""List care relations where user is host with pagination."""
47+
return await repository.find_by_host(
48+
db, host_id, active_only=active_only, limit=limit, offset=offset
49+
)
4650

4751

4852
async def list_relations_for_caregiver(
4953
db: AsyncSession,
5054
caregiver_id: uuid.UUID,
5155
*,
5256
active_only: bool = True,
53-
) -> list[CareRelation]:
54-
"""List all care relations where user is caregiver."""
55-
return await repository.find_by_caregiver(db, caregiver_id, active_only=active_only)
57+
limit: int = 20,
58+
offset: int = 0,
59+
) -> tuple[list[CareRelation], int]:
60+
"""List care relations where user is caregiver with pagination."""
61+
return await repository.find_by_caregiver(
62+
db, caregiver_id, active_only=active_only, limit=limit, offset=offset
63+
)
5664

5765

5866
async def get_relation(

apps/api/tests/e2e/test_relation_flow.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ async def test_list_relations_by_host(
5454
params={"host_id": str(HOST_USER_ID)},
5555
)
5656
assert resp.status_code == 200
57-
data = resp.json()
58-
assert len(data) >= 1
59-
assert data[0]["host_id"] == str(HOST_USER_ID)
57+
body = resp.json()
58+
assert "data" in body
59+
assert "meta" in body
60+
assert len(body["data"]) >= 1
61+
assert body["data"][0]["host_id"] == str(HOST_USER_ID)
62+
assert body["meta"]["total"] >= 1
6063

6164
async def test_list_relations_by_caregiver(
6265
self,
@@ -82,9 +85,12 @@ async def test_list_relations_by_caregiver(
8285
params={"caregiver_id": str(CAREGIVER_USER_ID)},
8386
)
8487
assert resp.status_code == 200
85-
data = resp.json()
86-
assert len(data) >= 1
87-
assert data[0]["caregiver_id"] == str(CAREGIVER_USER_ID)
88+
body = resp.json()
89+
assert "data" in body
90+
assert "meta" in body
91+
assert len(body["data"]) >= 1
92+
assert body["data"][0]["caregiver_id"] == str(CAREGIVER_USER_ID)
93+
assert body["meta"]["total"] >= 1
8894

8995
async def test_update_relation_deactivate(
9096
self,

apps/api/tests/test_ai_tools.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ class TestLogWellnessTool:
143143
@pytest.mark.asyncio
144144
@patch(_DISPATCH, new_callable=AsyncMock)
145145
@patch(_GET_TOKENS, new_callable=AsyncMock, return_value=[])
146-
@patch(_FIND_BY_HOST, new_callable=AsyncMock, return_value=[])
146+
@patch(_FIND_BY_HOST, new_callable=AsyncMock, return_value=([], 0))
147147
@patch(_CREATE_WELLNESS, new_callable=AsyncMock)
148148
async def test_log_wellness_success(
149149
self,
@@ -206,9 +206,10 @@ async def test_log_wellness_triggers_alert_on_emergency(
206206
) -> None:
207207
caregiver_id = uuid.UUID("00000000-0000-4000-8000-000000000010")
208208
mock_create.return_value = _mock_wellness_log()
209-
mock_find.return_value = [
210-
MagicMock(caregiver_id=caregiver_id),
211-
]
209+
mock_find.return_value = (
210+
[MagicMock(caregiver_id=caregiver_id)],
211+
1,
212+
)
212213
mock_get_tokens.return_value = ["fcm-tok-cg1"]
213214

214215
tool = LogWellnessTool()
@@ -244,10 +245,10 @@ async def test_log_wellness_triggers_alert_on_warning(
244245
cg1 = uuid.UUID("00000000-0000-4000-8000-000000000011")
245246
cg2 = uuid.UUID("00000000-0000-4000-8000-000000000012")
246247
mock_create.return_value = _mock_wellness_log()
247-
mock_find.return_value = [
248-
MagicMock(caregiver_id=cg1),
249-
MagicMock(caregiver_id=cg2),
250-
]
248+
mock_find.return_value = (
249+
[MagicMock(caregiver_id=cg1), MagicMock(caregiver_id=cg2)],
250+
2,
251+
)
251252
mock_get_tokens.side_effect = [["tok-cg1"], ["tok-cg2"]]
252253

253254
tool = LogWellnessTool()

0 commit comments

Comments
 (0)