feat(08-03): push notification backend — DB model, migration, API router, VAPID setup
- Add PushSubscription ORM model with unique(user_id, endpoint) constraint - Add Alembic migration 012 for push_subscriptions table - Add push router (subscribe, unsubscribe, send) in shared/api/push.py - Mount push router in gateway/main.py - Add pywebpush to gateway dependencies for server-side VAPID delivery - Wire push trigger into WebSocket handler (fires when client disconnects mid-stream) - Add VAPID keys to .env / .env.example - Add push/install i18n keys in en/es/pt message files
This commit is contained in:
@@ -10,6 +10,7 @@ from shared.api.chat import chat_router
|
||||
from shared.api.invitations import invitations_router
|
||||
from shared.api.llm_keys import llm_keys_router
|
||||
from shared.api.portal import portal_router
|
||||
from shared.api.push import push_router
|
||||
from shared.api.templates import templates_router
|
||||
from shared.api.usage import usage_router
|
||||
|
||||
@@ -23,4 +24,5 @@ __all__ = [
|
||||
"invitations_router",
|
||||
"templates_router",
|
||||
"chat_router",
|
||||
"push_router",
|
||||
]
|
||||
|
||||
232
packages/shared/shared/api/push.py
Normal file
232
packages/shared/shared/api/push.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
FastAPI push notification API — subscription management and send endpoint.
|
||||
|
||||
Provides Web Push subscription storage so the gateway can deliver
|
||||
push notifications when an AI employee responds and the user's
|
||||
WebSocket is not connected.
|
||||
|
||||
Endpoints:
|
||||
POST /api/portal/push/subscribe — store browser push subscription
|
||||
DELETE /api/portal/push/unsubscribe — remove subscription by endpoint
|
||||
POST /api/portal/push/send — internal: send push to user (called by WS handler)
|
||||
|
||||
Authentication:
|
||||
subscribe / unsubscribe: require portal user headers (X-Portal-User-Id)
|
||||
send: internal endpoint — requires same portal headers but is called by
|
||||
the gateway WebSocket handler when user is offline
|
||||
|
||||
Push delivery:
|
||||
Uses pywebpush for VAPID-signed Web Push delivery.
|
||||
Handles 410 Gone responses by deleting stale subscriptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.api.rbac import PortalCaller, get_portal_caller
|
||||
from shared.db import get_session
|
||||
from shared.models.push import PushSubscription, PushSubscriptionCreate, PushSubscriptionOut, PushSendRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
push_router = APIRouter(prefix="/api/portal/push", tags=["push"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VAPID config (read from environment at import time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
|
||||
VAPID_PUBLIC_KEY: str = os.environ.get("NEXT_PUBLIC_VAPID_PUBLIC_KEY", "")
|
||||
VAPID_CLAIMS_EMAIL: str = os.environ.get("VAPID_CLAIMS_EMAIL", "admin@konstruct.dev")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper — send a single push notification via pywebpush
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _send_push(subscription: PushSubscription, payload: dict[str, object]) -> bool:
|
||||
"""
|
||||
Send a Web Push notification to a single subscription.
|
||||
|
||||
Returns True on success, False if the subscription is stale (410 Gone).
|
||||
Raises on other errors so the caller can decide how to handle them.
|
||||
"""
|
||||
if not VAPID_PRIVATE_KEY:
|
||||
logger.warning("VAPID_PRIVATE_KEY not set — skipping push notification")
|
||||
return True
|
||||
|
||||
try:
|
||||
from pywebpush import WebPusher, webpush, WebPushException # type: ignore[import]
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh,
|
||||
"auth": subscription.auth,
|
||||
},
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
vapid_claims={
|
||||
"sub": f"mailto:{VAPID_CLAIMS_EMAIL}",
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
# Check for 410 Gone — subscription is no longer valid
|
||||
exc_str = str(exc)
|
||||
if "410" in exc_str or "Gone" in exc_str or "expired" in exc_str.lower():
|
||||
logger.info("Push subscription stale (410 Gone): %s", subscription.endpoint[:40])
|
||||
return False
|
||||
logger.error("Push delivery failed: %s", exc_str)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@push_router.post("/subscribe", status_code=status.HTTP_201_CREATED, response_model=PushSubscriptionOut)
|
||||
async def subscribe(
|
||||
body: PushSubscriptionCreate,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PushSubscriptionOut:
|
||||
"""
|
||||
Store a browser push subscription for the authenticated user.
|
||||
|
||||
Uses INSERT ... ON CONFLICT (user_id, endpoint) DO UPDATE so
|
||||
re-subscribing the same browser updates the keys without creating
|
||||
a duplicate row.
|
||||
"""
|
||||
stmt = (
|
||||
pg_insert(PushSubscription)
|
||||
.values(
|
||||
user_id=caller.user_id,
|
||||
tenant_id=uuid.UUID(body.tenant_id) if body.tenant_id else None,
|
||||
endpoint=body.endpoint,
|
||||
p256dh=body.p256dh,
|
||||
auth=body.auth,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
constraint="uq_push_user_endpoint",
|
||||
set_={
|
||||
"p256dh": body.p256dh,
|
||||
"auth": body.auth,
|
||||
"tenant_id": uuid.UUID(body.tenant_id) if body.tenant_id else None,
|
||||
},
|
||||
)
|
||||
.returning(PushSubscription)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one()
|
||||
await session.commit()
|
||||
|
||||
return PushSubscriptionOut(
|
||||
id=str(row.id),
|
||||
endpoint=row.endpoint,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
@push_router.delete("/unsubscribe", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def unsubscribe(
|
||||
body: UnsubscribeRequest,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Remove a push subscription for the authenticated user."""
|
||||
await session.execute(
|
||||
delete(PushSubscription).where(
|
||||
PushSubscription.user_id == caller.user_id,
|
||||
PushSubscription.endpoint == body.endpoint,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@push_router.post("/send", status_code=status.HTTP_200_OK)
|
||||
async def send_push(
|
||||
body: PushSendRequest,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Internal endpoint — send a push notification to all subscriptions for a user.
|
||||
|
||||
Called by the gateway WebSocket handler when the agent responds but
|
||||
the user's WebSocket is no longer connected.
|
||||
|
||||
Handles 410 Gone by deleting stale subscriptions.
|
||||
Returns counts of delivered and stale subscriptions.
|
||||
"""
|
||||
try:
|
||||
target_user_id = uuid.UUID(body.user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid user_id") from exc
|
||||
|
||||
# Fetch all subscriptions for this user
|
||||
result = await session.execute(
|
||||
select(PushSubscription).where(PushSubscription.user_id == target_user_id)
|
||||
)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
if not subscriptions:
|
||||
return {"delivered": 0, "stale": 0, "total": 0}
|
||||
|
||||
payload = {
|
||||
"title": body.title,
|
||||
"body": body.body,
|
||||
"data": {
|
||||
"conversationId": body.conversation_id,
|
||||
},
|
||||
}
|
||||
|
||||
delivered = 0
|
||||
stale_endpoints: list[str] = []
|
||||
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
ok = await _send_push(sub, payload)
|
||||
if ok:
|
||||
delivered += 1
|
||||
else:
|
||||
stale_endpoints.append(sub.endpoint)
|
||||
except Exception as exc:
|
||||
logger.error("Push send error for user %s: %s", body.user_id, exc)
|
||||
|
||||
# Delete stale subscriptions
|
||||
if stale_endpoints:
|
||||
await session.execute(
|
||||
delete(PushSubscription).where(
|
||||
PushSubscription.user_id == target_user_id,
|
||||
PushSubscription.endpoint.in_(stale_endpoints),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"delivered": delivered,
|
||||
"stale": len(stale_endpoints),
|
||||
"total": len(subscriptions),
|
||||
}
|
||||
122
packages/shared/shared/models/push.py
Normal file
122
packages/shared/shared/models/push.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Push subscription model for Web Push notifications.
|
||||
|
||||
Stores browser push subscriptions for portal users so the gateway can
|
||||
send push notifications when an AI employee responds and the user's
|
||||
WebSocket is not connected.
|
||||
|
||||
Push subscriptions are per-user, per-browser-endpoint. No RLS is applied
|
||||
to this table — the API filters by user_id in the query (push subscriptions
|
||||
are portal-user-scoped, not tenant-scoped).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from shared.models.tenant import Base
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""
|
||||
Browser push subscription for a portal user.
|
||||
|
||||
endpoint: The push service URL provided by the browser.
|
||||
p256dh: ECDH public key for message encryption.
|
||||
auth: Auth secret for message encryption.
|
||||
|
||||
Unique constraint on (user_id, endpoint) — one subscription per
|
||||
browser per user. Upsert on conflict avoids duplicates on re-subscribe.
|
||||
"""
|
||||
|
||||
__tablename__ = "push_subscriptions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("portal_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Optional tenant scope for notification routing",
|
||||
)
|
||||
endpoint: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Push service URL (browser-provided)",
|
||||
)
|
||||
p256dh: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="ECDH public key for payload encryption",
|
||||
)
|
||||
auth: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Auth secret for payload encryption",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PushSubscription user={self.user_id} endpoint={self.endpoint[:40]!r}>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
"""Payload for POST /portal/push/subscribe."""
|
||||
|
||||
endpoint: str
|
||||
p256dh: str
|
||||
auth: str
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
class PushSubscriptionOut(BaseModel):
|
||||
"""Response body for subscription operations."""
|
||||
|
||||
id: str
|
||||
endpoint: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PushSendRequest(BaseModel):
|
||||
"""Internal payload for POST /portal/push/send."""
|
||||
|
||||
user_id: str
|
||||
title: str
|
||||
body: str
|
||||
conversation_id: str | None = None
|
||||
Reference in New Issue
Block a user