diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/apns.py | 73 | ||||
| -rw-r--r-- | app/config.py | 23 | ||||
| -rw-r--r-- | app/crud.py | 55 | ||||
| -rw-r--r-- | app/db.py | 16 | ||||
| -rw-r--r-- | app/jobs.py | 16 | ||||
| -rw-r--r-- | app/main.py | 30 | ||||
| -rw-r--r-- | app/models.py | 72 | ||||
| -rw-r--r-- | app/poller.py | 20 | ||||
| -rw-r--r-- | app/routers/devices.py | 20 | ||||
| -rw-r--r-- | app/routers/health.py | 8 | ||||
| -rw-r--r-- | app/routers/subscriptions.py | 14 | ||||
| -rw-r--r-- | app/routers/test.py | 33 | ||||
| -rw-r--r-- | app/schemas.py | 31 | ||||
| -rw-r--r-- | app/security.py | 7 |
14 files changed, 418 insertions, 0 deletions
diff --git a/app/apns.py b/app/apns.py new file mode 100644 index 0000000..ae70874 --- /dev/null +++ b/app/apns.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path + +import httpx +import jwt + +from app.config import settings + + +class APNSClient: + def __init__(self) -> None: + self._key_text = Path(settings.apns_private_key_path).read_text() + self._token_cache: tuple[str, int] | None = None + self._client = httpx.AsyncClient(http2=True, timeout=10.0) + + async def aclose(self) -> None: + await self._client.aclose() + + def _bearer_token(self) -> str: + now = int(time.time()) + if self._token_cache and now - self._token_cache[1] < 3000: + return self._token_cache[0] + + token = jwt.encode( + {"iss": settings.apns_team_id, "iat": now}, + self._key_text, + algorithm="ES256", + headers={"kid": settings.apns_key_id}, + ) + self._token_cache = (token, now) + return token + + async def send_alert( + self, + *, + device_token: str, + apns_env: str, + topic: str, + title: str, + body: str, + payload: dict, + ) -> tuple[bool, str | None, str | None]: + host = "https://api.push.apple.com" if apns_env == "production" else "https://api.sandbox.push.apple.com" + url = f"{host}/3/device/{device_token}" + merged_payload = { + "aps": { + "alert": {"title": title, "body": body}, + "sound": "default", + }, + **payload, + } + response = await self._client.post( + url, + headers={ + "authorization": f"bearer {self._bearer_token()}", + "apns-topic": topic, + "apns-push-type": "alert", + "apns-priority": "10", + }, + content=json.dumps(merged_payload).encode("utf-8"), + ) + apns_id = response.headers.get("apns-id") + if 200 <= response.status_code < 300: + return True, apns_id, None + error = None + try: + error = response.json().get("reason") + except Exception: + error = response.text + return False, apns_id, error diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9784755 --- /dev/null +++ b/app/config.py @@ -0,0 +1,23 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_env: str = "development" + app_host: str = "0.0.0.0" + app_port: int = 8000 + database_url: str = "sqlite+aiosqlite:///./hutch_notify.db" + api_key: str = "change-me" + + apns_key_id: str + apns_team_id: str + apns_bundle_id: str + apns_private_key_path: str + + srht_token: str + poll_interval_seconds: int = 120 + log_level: str = "INFO" + + +settings = Settings() diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..424cbfe --- /dev/null +++ b/app/crud.py @@ -0,0 +1,55 @@ +from datetime import datetime +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import Device, Subscription, Event, Delivery +from app.schemas import DeviceRegisterIn, SubscriptionUpsertIn + + +async def upsert_device(db: AsyncSession, payload: DeviceRegisterIn) -> Device: + stmt = select(Device).where( + Device.apns_token == payload.apns_token, + Device.bundle_id == payload.bundle_id, + ) + result = await db.execute(stmt) + device = result.scalar_one_or_none() + + if device is None: + device = Device( + user_id=payload.user_id, + apns_token=payload.apns_token, + apns_env=payload.apns_env, + bundle_id=payload.bundle_id, + platform=payload.platform, + app_version=payload.app_version, + device_name=payload.device_name, + is_enabled=True, + last_seen_at=datetime.utcnow(), + ) + db.add(device) + else: + device.user_id = payload.user_id + device.apns_env = payload.apns_env + device.platform = payload.platform + device.app_version = payload.app_version + device.device_name = payload.device_name + device.is_enabled = True + device.last_seen_at = datetime.utcnow() + + await db.commit() + await db.refresh(device) + return device + + +async def replace_subscriptions(db: AsyncSession, payload: SubscriptionUpsertIn) -> None: + await db.execute(delete(Subscription).where(Subscription.device_id == payload.device_id)) + for item in payload.subscriptions: + db.add( + Subscription( + device_id=payload.device_id, + source_type=item.source_type, + source_id=item.source_id, + event_type=item.event_type, + is_enabled=item.is_enabled, + ) + ) + await db.commit() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..5846834 --- /dev/null +++ b/app/db.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_async_engine(settings.database_url, future=True) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def get_db() -> AsyncSession: + async with SessionLocal() as session: + yield session diff --git a/app/jobs.py b/app/jobs.py new file mode 100644 index 0000000..4ad4d40 --- /dev/null +++ b/app/jobs.py @@ -0,0 +1,16 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.config import settings +from app.db import SessionLocal +from app.poller import poll_builds_once + +scheduler = AsyncIOScheduler() + + +def start_scheduler() -> None: + scheduler.add_job(run_build_poll, "interval", seconds=settings.poll_interval_seconds, id="build-poll", replace_existing=True) + scheduler.start() + + +async def run_build_poll() -> None: + async with SessionLocal() as db: + await poll_builds_once(db) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..495e41f --- /dev/null +++ b/app/main.py @@ -0,0 +1,30 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlalchemy import text + +from app.db import engine, Base +from app.jobs import start_scheduler, scheduler +from app.routers.health import router as health_router +from app.routers.devices import router as devices_router +from app.routers.subscriptions import router as subscriptions_router +from app.routers.test import router as test_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute(text("select 1")) + start_scheduler() + yield + if scheduler.running: + scheduler.shutdown(wait=False) + await engine.dispose() + + +app = FastAPI(title="hutch-notify", lifespan=lifespan) +app.include_router(health_router) +app.include_router(devices_router) +app.include_router(subscriptions_router) +app.include_router(test_router) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..34bc160 --- /dev/null +++ b/app/models.py @@ -0,0 +1,72 @@ +from datetime import datetime +from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.db import Base + + +class Device(Base): + __tablename__ = "devices" + __table_args__ = (UniqueConstraint("apns_token", "bundle_id", name="uq_device_token_bundle"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[str] = mapped_column(String(255), index=True) + apns_token: Mapped[str] = mapped_column(Text) + apns_env: Mapped[str] = mapped_column(String(32)) + bundle_id: Mapped[str] = mapped_column(String(255)) + platform: Mapped[str] = mapped_column(String(32), default="ios") + app_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + subscriptions = relationship("Subscription", back_populates="device", cascade="all, delete-orphan") + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + device_id: Mapped[int] = mapped_column(ForeignKey("devices.id", ondelete="CASCADE"), index=True) + source_type: Mapped[str] = mapped_column(String(64), index=True) + source_id: Mapped[str] = mapped_column(String(255), index=True) + event_type: Mapped[str] = mapped_column(String(64), index=True) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + device = relationship("Device", back_populates="subscriptions") + + +class Event(Base): + __tablename__ = "events" + __table_args__ = (UniqueConstraint("dedupe_key", name="uq_event_dedupe_key"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source_type: Mapped[str] = mapped_column(String(64), index=True) + source_id: Mapped[str] = mapped_column(String(255), index=True) + event_type: Mapped[str] = mapped_column(String(64), index=True) + external_id: Mapped[str] = mapped_column(String(255), index=True) + dedupe_key: Mapped[str] = mapped_column(String(255)) + title: Mapped[str] = mapped_column(String(255)) + body: Mapped[str] = mapped_column(Text) + deep_link: Mapped[str | None] = mapped_column(String(512), nullable=True) + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Delivery(Base): + __tablename__ = "deliveries" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + device_id: Mapped[int] = mapped_column(ForeignKey("devices.id", ondelete="CASCADE"), index=True) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + attempt_count: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + apns_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + scheduled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/poller.py b/app/poller.py new file mode 100644 index 0000000..d08011f --- /dev/null +++ b/app/poller.py @@ -0,0 +1,20 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import Subscription, Event, Delivery, Device + + +async def poll_builds_once(db: AsyncSession) -> None: + # Placeholder. + # Replace with sr.ht fetch logic. + # Strategy: + # 1. query distinct build subscriptions + # 2. fetch latest builds per source_id + # 3. create Event rows for unseen terminal states + # 4. create Delivery rows for enabled devices subscribed to matching event_type + subscribed = await db.execute( + select(Subscription).where(Subscription.source_type == "build", Subscription.is_enabled.is_(True)) + ) + subscriptions = subscribed.scalars().all() + + # no-op until sr.ht integration is wired + _ = subscriptions diff --git a/app/routers/devices.py b/app/routers/devices.py new file mode 100644 index 0000000..882c474 --- /dev/null +++ b/app/routers/devices.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.db import get_db +from app.schemas import DeviceRegisterIn +from app.crud import upsert_device +from app.security import require_api_key + +router = APIRouter(prefix="/v1/devices", tags=["devices"], dependencies=[Depends(require_api_key)]) + + [email protected]("/register") +async def register_device(payload: DeviceRegisterIn, db: AsyncSession = Depends(get_db)) -> dict: + device = await upsert_device(db, payload) + return { + "id": device.id, + "user_id": device.user_id, + "bundle_id": device.bundle_id, + "apns_env": device.apns_env, + "is_enabled": device.is_enabled, + } diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..794a09f --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + [email protected]("/health") +async def health() -> dict: + return {"status": "ok"} diff --git a/app/routers/subscriptions.py b/app/routers/subscriptions.py new file mode 100644 index 0000000..81585a1 --- /dev/null +++ b/app/routers/subscriptions.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.db import get_db +from app.schemas import SubscriptionUpsertIn +from app.crud import replace_subscriptions +from app.security import require_api_key + +router = APIRouter(prefix="/v1/subscriptions", tags=["subscriptions"], dependencies=[Depends(require_api_key)]) + + [email protected]("/upsert") +async def upsert_subscriptions(payload: SubscriptionUpsertIn, db: AsyncSession = Depends(get_db)) -> dict: + await replace_subscriptions(db, payload) + return {"status": "ok"} diff --git a/app/routers/test.py b/app/routers/test.py new file mode 100644 index 0000000..70a5d4e --- /dev/null +++ b/app/routers/test.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.apns import APNSClient +from app.db import get_db +from app.models import Device +from app.security import require_api_key + +router = APIRouter(prefix="/v1/test", tags=["test"], dependencies=[Depends(require_api_key)]) + + [email protected]("/push/{device_id}") +async def test_push(device_id: int, db: AsyncSession = Depends(get_db)) -> dict: + result = await db.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if device is None: + raise HTTPException(status_code=404, detail="device not found") + + client = APNSClient() + try: + ok, apns_id, error = await client.send_alert( + device_token=device.apns_token, + apns_env=device.apns_env, + topic=device.bundle_id, + title="Hutch test", + body="Test push from hutch-notify", + payload={"event_type": "test", "deep_link": "hutch://home"}, + ) + finally: + await client.aclose() + + return {"ok": ok, "apns_id": apns_id, "error": error} diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..e40b0d3 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field + + +class DeviceRegisterIn(BaseModel): + user_id: str + apns_token: str = Field(min_length=16) + apns_env: str + bundle_id: str + platform: str = "ios" + app_version: str | None = None + device_name: str | None = None + + +class DeviceOut(BaseModel): + id: int + user_id: str + bundle_id: str + apns_env: str + is_enabled: bool + + +class SubscriptionIn(BaseModel): + source_type: str + source_id: str + event_type: str + is_enabled: bool = True + + +class SubscriptionUpsertIn(BaseModel): + device_id: int + subscriptions: list[SubscriptionIn] diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..b1ed939 --- /dev/null +++ b/app/security.py @@ -0,0 +1,7 @@ +from fastapi import Header, HTTPException, status +from app.config import settings + + +async def require_api_key(x_api_key: str = Header(default="")) -> None: + if x_api_key != settings.api_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid api key") |
