summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/apns.py73
-rw-r--r--app/config.py23
-rw-r--r--app/crud.py55
-rw-r--r--app/db.py16
-rw-r--r--app/jobs.py16
-rw-r--r--app/main.py30
-rw-r--r--app/models.py72
-rw-r--r--app/poller.py20
-rw-r--r--app/routers/devices.py20
-rw-r--r--app/routers/health.py8
-rw-r--r--app/routers/subscriptions.py14
-rw-r--r--app/routers/test.py33
-rw-r--r--app/schemas.py31
-rw-r--r--app/security.py7
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"])
+
+
+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)])
+
+
+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")