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:
2026-03-25 21:26:51 -06:00
parent 5c30651754
commit 7d3a393758
9 changed files with 774 additions and 199 deletions

View File

@@ -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",
]

View 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),
}

View 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