Skip to content

Commit 9716300

Browse files
committed
feat: integrate nudging
1 parent 048c149 commit 9716300

File tree

11 files changed

+223
-136
lines changed

11 files changed

+223
-136
lines changed

alembic/versions/001_initial_schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def upgrade() -> None:
4848
sa.Column(
4949
"email_notifications", sa.Boolean(), nullable=False, server_default="false"
5050
),
51+
sa.Column(
52+
"webpush_enabled", sa.Boolean(), nullable=False, server_default="false"
53+
),
5154
sa.Column(
5255
"created_at",
5356
sa.DateTime(timezone=True),

src/celine/webapp/api/deps.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from celine.sdk.auth import JwtUser
1111
from celine.sdk.auth.static import StaticTokenProvider
1212
from celine.sdk.dt import DTClient
13+
from celine.sdk.nudging.client import NudgingClient
1314

1415

1516
def get_user_from_request(request: Request) -> JwtUser:
@@ -60,6 +61,18 @@ def get_dt_client(request: Request) -> DTClient:
6061
)
6162

6263

64+
def get_nudging_client(request: Request) -> NudgingClient:
65+
if not settings.nudging_api_url:
66+
raise HTTPException(
67+
status_code=503,
68+
detail="Nudging API not configured",
69+
)
70+
71+
raw_token = get_raw_token(request)
72+
73+
return NudgingClient(base_url=settings.nudging_api_url, default_token=raw_token)
74+
75+
6376
def get_client_ip(request: Request) -> str:
6477
"""Extract client IP from request headers."""
6578
forwarded = request.headers.get("X-Forwarded-For")
@@ -75,3 +88,4 @@ def get_client_ip(request: Request) -> str:
7588
UserDep = Annotated[JwtUser, Depends(get_user_from_request)]
7689
DbDep = Annotated[AsyncSession, Depends(get_db)]
7790
DTDep = Annotated[DTClient, Depends(get_dt_client)]
91+
NudgingDep = Annotated[NudgingClient, Depends(get_nudging_client)]
Lines changed: 97 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,36 @@
11
"""Notification-related API routes."""
22

3-
from dataclasses import dataclass
4-
from datetime import datetime
5-
from typing import Any
6-
from fastapi import APIRouter, Request, HTTPException
7-
from sqlalchemy import select, desc, delete
3+
import asyncio
4+
from fastapi import APIRouter, HTTPException
5+
from sqlalchemy import select
86

9-
from celine.webapp.api.deps import UserDep, DbDep
7+
from celine.webapp.api.deps import NudgingDep, UserDep, DbDep
108
from celine.webapp.api.schemas import (
119
NotificationItem,
10+
PushSubscriptionPayload,
11+
PushSubscriptionUnsubscribePayload,
1212
VapidKeyResponse,
13-
WebPushUnsubscribeRequest,
1413
SuccessResponse,
1514
)
16-
from celine.webapp.db import WebPushSubscription
17-
from celine.webapp.settings import settings as app_settings
15+
from celine.webapp.db.user_settings import update_user_settings
1816

17+
from celine.sdk.openapi.nudging.models import (
18+
SubscribeRequest,
19+
WebPushSubscriptionIn,
20+
WebPushKeysIn,
21+
UnsubscribeRequest,
22+
)
1923

2024
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
2125

2226

2327
@router.get("", response_model=list[NotificationItem])
2428
async def list_notifications(
25-
user: UserDep,
26-
db: DbDep,
29+
user: UserDep, db: DbDep, nudging_client: NudgingDep
2730
) -> list[NotificationItem]:
2831
"""List user notifications."""
2932

30-
# TODO
31-
@dataclass(frozen=True)
32-
class TestNotification:
33-
id: str
34-
title: str
35-
body: str
36-
severity: str
37-
created_at: datetime
38-
read_at: datetime | None
39-
40-
notifications: list[TestNotification] = [
41-
TestNotification(
42-
id="0000001",
43-
created_at=datetime.now(),
44-
title="Welcome to the REC Webapp (example)",
45-
body="Learn more about the app from your energy community manager",
46-
severity="info",
47-
read_at=None,
48-
)
49-
]
33+
res = await nudging_client.list_notifications(token=user.token)
5034

5135
return [
5236
NotificationItem(
@@ -60,8 +44,9 @@ class TestNotification:
6044
else "warning" if n.severity == "warning" else "info"
6145
),
6246
read_at=n.read_at.isoformat() if n.read_at else None,
47+
deleted_at=n.deleted_at.isoformat() if n.deleted_at else None,
6348
)
64-
for n in notifications
49+
for n in res
6550
]
6651

6752

@@ -70,76 +55,113 @@ async def enable_notifications(
7055
user: UserDep,
7156
db: DbDep,
7257
) -> SuccessResponse:
73-
"""Enable notifications for user."""
58+
# TODO
59+
await update_user_settings(user_id=user.sub, db=db, email_notifications=True)
60+
return SuccessResponse()
61+
62+
63+
@router.post("/disable", response_model=SuccessResponse)
64+
async def disable_notifications(
65+
user: UserDep,
66+
db: DbDep,
67+
) -> SuccessResponse:
68+
# TODO
69+
await update_user_settings(user_id=user.sub, db=db, email_notifications=False)
70+
return SuccessResponse()
71+
72+
73+
# NOTE: /read-all must be registered before /{id}/read so FastAPI does not
74+
# capture the literal string "read-all" as a notification id.
75+
@router.post("/read-all", response_model=SuccessResponse)
76+
async def mark_all_notifications_read(
77+
user: UserDep,
78+
nudging_client: NudgingDep,
79+
) -> SuccessResponse:
80+
"""Mark every unread notification as read for the current user.
81+
82+
The nudging service has no bulk-mark-read endpoint, so we fetch the
83+
unread list and fan out individual mark_read calls concurrently.
84+
"""
85+
unread = await nudging_client.list_notifications(unread_only=True, token=user.token)
86+
87+
if unread:
88+
await asyncio.gather(
89+
*[nudging_client.mark_read(n.id, token=user.token) for n in unread]
90+
)
91+
92+
return SuccessResponse()
93+
7494

75-
# Idempotent placeholder: in a real implementation this might
76-
# register the user in the nudging tool
95+
@router.post("/{notification_id}/read", response_model=SuccessResponse)
96+
async def mark_notification_read(
97+
notification_id: str,
98+
user: UserDep,
99+
nudging_client: NudgingDep,
100+
) -> SuccessResponse:
101+
"""Mark a single notification as read. Idempotent."""
102+
result = await nudging_client.mark_read(notification_id, token=user.token)
103+
if result is None:
104+
raise HTTPException(status_code=404, detail="Notification not found")
77105
return SuccessResponse()
78106

79107

80108
@router.get("/webpush/vapid-public-key", response_model=VapidKeyResponse)
81109
async def vapid_public_key(
82-
user: UserDep,
83-
db: DbDep,
110+
nudging_client: NudgingDep,
84111
) -> VapidKeyResponse:
85112
"""Get VAPID public key for web push."""
86-
return VapidKeyResponse(public_key=app_settings.vapid_public_key)
113+
res = await nudging_client.get_vapid_public_key()
114+
if res is None:
115+
raise HTTPException(500, "Failed to fetch VAPID public key")
116+
return VapidKeyResponse(public_key=res.public_key)
87117

88118

89119
@router.post("/webpush/subscribe", response_model=SuccessResponse)
90120
async def webpush_subscribe(
91-
request: Request,
92121
user: UserDep,
93122
db: DbDep,
123+
nudging_client: NudgingDep,
124+
payload: PushSubscriptionPayload,
94125
) -> SuccessResponse:
95-
"""Subscribe to web push notifications."""
96-
97-
data = await request.json()
98-
endpoint = data.get("endpoint")
99-
100-
if not endpoint:
101-
raise HTTPException(status_code=400, detail="subscription endpoint missing")
126+
"""Enable notifications for user."""
127+
await nudging_client.subscribe(
128+
body=SubscribeRequest(
129+
subscription=WebPushSubscriptionIn(
130+
endpoint=payload.endpoint,
131+
keys=WebPushKeysIn(
132+
p256dh=payload.p256dh,
133+
auth=payload.auth,
134+
),
135+
)
136+
),
137+
token=user.token,
138+
)
102139

103-
# Check if subscription already exists
104-
result = await db.execute(
105-
select(WebPushSubscription).filter(
106-
WebPushSubscription.user_id == user.sub,
107-
WebPushSubscription.endpoint == endpoint,
108-
)
140+
await update_user_settings(
141+
user_id=user.sub,
142+
db=db,
143+
webpush_enabled=True,
109144
)
110-
existing = result.scalar_one_or_none()
111-
112-
if existing:
113-
# Update existing subscription
114-
existing.subscription_json = data
115-
await db.commit()
116-
else:
117-
# Create new subscription
118-
subscription = WebPushSubscription(
119-
user_id=user.sub,
120-
endpoint=endpoint,
121-
subscription_json=data,
122-
)
123-
db.add(subscription)
124-
await db.commit()
125145

126146
return SuccessResponse()
127147

128148

129149
@router.post("/webpush/unsubscribe", response_model=SuccessResponse)
130150
async def webpush_unsubscribe(
131-
body: WebPushUnsubscribeRequest,
132151
user: UserDep,
133152
db: DbDep,
153+
nudging_client: NudgingDep,
154+
payload: PushSubscriptionUnsubscribePayload,
134155
) -> SuccessResponse:
135-
"""Unsubscribe from web push notifications."""
156+
await nudging_client.unsubscribe(
157+
body=UnsubscribeRequest(endpoint=payload.endpoint),
158+
token=user.token,
159+
)
136160

137-
await db.execute(
138-
delete(WebPushSubscription).filter(
139-
WebPushSubscription.user_id == user.sub,
140-
WebPushSubscription.endpoint == body.endpoint,
141-
)
161+
await update_user_settings(
162+
user_id=user.sub,
163+
db=db,
164+
webpush_enabled=False,
142165
)
143-
await db.commit()
144166

145167
return SuccessResponse()

src/celine/webapp/api/schemas.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class NotificationItem(BaseModel):
6464
body: str
6565
severity: Literal["info", "warning", "critical"]
6666
read_at: Optional[str] = None
67+
deleted_at: Optional[str] = None
6768

6869

6970
# Settings
@@ -72,7 +73,12 @@ class SettingsModel(BaseModel):
7273

7374
simple_mode: bool = False
7475
font_scale: float = Field(default=1.0, ge=0.9, le=1.3)
75-
notifications: dict = Field(default_factory=lambda: {"email_enabled": False})
76+
notifications: dict = Field(
77+
default_factory=lambda: {
78+
"email_enabled": False,
79+
"webpush_enabled": False,
80+
}
81+
)
7682

7783

7884
# WebPush
@@ -93,3 +99,16 @@ class SuccessResponse(BaseModel):
9399
"""Generic success response."""
94100

95101
ok: bool = True
102+
103+
104+
class PushSubscriptionPayload(BaseModel):
105+
"""What the browser sends after navigator.serviceWorker.pushManager.subscribe()"""
106+
107+
endpoint: str
108+
p256dh: str
109+
auth: str
110+
111+
112+
class PushSubscriptionUnsubscribePayload(BaseModel):
113+
114+
endpoint: str

src/celine/webapp/api/settings_routes.py

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from celine.webapp.api.deps import UserDep, DbDep
77
from celine.webapp.api.schemas import SettingsModel
88
from celine.webapp.db import Settings
9-
9+
from celine.webapp.db.user_settings import load_user_settings, update_user_settings
1010

1111
router = APIRouter(prefix="/api", tags=["settings"])
1212

@@ -17,21 +17,7 @@ async def get_settings(
1717
db: DbDep,
1818
) -> SettingsModel:
1919
"""Get user settings."""
20-
21-
result = await db.execute(select(Settings).filter(Settings.user_id == user.sub))
22-
settings = result.scalar_one_or_none()
23-
24-
if not settings:
25-
settings = Settings(
26-
user_id=user.sub,
27-
simple_mode=False,
28-
font_scale=1.0,
29-
email_notifications=False,
30-
)
31-
db.add(settings)
32-
await db.commit()
33-
await db.refresh(settings)
34-
20+
settings = await load_user_settings(user.sub, db)
3521
return SettingsModel(
3622
simple_mode=settings.simple_mode,
3723
font_scale=settings.font_scale,
@@ -50,18 +36,13 @@ async def update_settings(
5036
data = await request.json()
5137
model = SettingsModel.model_validate(data)
5238

53-
result = await db.execute(select(Settings).filter(Settings.user_id == user.sub))
54-
settings = result.scalar_one_or_none()
55-
56-
if not settings:
57-
settings = Settings(user_id=user.sub)
58-
db.add(settings)
59-
60-
settings.simple_mode = model.simple_mode
61-
settings.font_scale = model.font_scale
62-
settings.email_notifications = bool(model.notifications.get("email_enabled"))
63-
64-
await db.commit()
65-
await db.refresh(settings)
39+
await update_user_settings(
40+
user_id=user.sub,
41+
db=db,
42+
simple_mode=model.simple_mode,
43+
font_scale=model.font_scale,
44+
email_notifications=bool(model.notifications.get("email_enabled")),
45+
webpush_enabled=bool(model.notifications.get("webpush_enabled")),
46+
)
6647

6748
return model

0 commit comments

Comments
 (0)