FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine server-rendered frontend. Multi-user via Google OAuth, daily habit tracking, monthly/weekly history views, Web Push reminders via APScheduler, and PWA support (manifest, service worker, offline caching). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
70 lines
2.5 KiB
Python
70 lines
2.5 KiB
Python
import json
|
|
|
|
from pywebpush import WebPushException, webpush
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import settings
|
|
from app.models.push_subscription import PushSubscription
|
|
from app.schemas.push import PushSubscribeRequest
|
|
|
|
|
|
def list_subscriptions(db: Session, user_id: int) -> list[PushSubscription]:
|
|
return list(db.scalars(select(PushSubscription).where(PushSubscription.user_id == user_id)))
|
|
|
|
|
|
def save_subscription(
|
|
db: Session, user_id: int, data: PushSubscribeRequest, user_agent: str | None = None
|
|
) -> PushSubscription:
|
|
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
|
|
if existing:
|
|
existing.user_id = user_id # 같은 기기에서 다른 유저가 재구독하면 소유자를 갱신한다.
|
|
existing.p256dh_key = data.keys.p256dh
|
|
existing.auth_key = data.keys.auth
|
|
db.commit()
|
|
return existing
|
|
|
|
sub = PushSubscription(
|
|
user_id=user_id,
|
|
endpoint=data.endpoint,
|
|
p256dh_key=data.keys.p256dh,
|
|
auth_key=data.keys.auth,
|
|
user_agent=user_agent,
|
|
)
|
|
db.add(sub)
|
|
db.commit()
|
|
db.refresh(sub)
|
|
return sub
|
|
|
|
|
|
def delete_subscription(db: Session, endpoint: str) -> None:
|
|
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
|
|
if existing:
|
|
db.delete(existing)
|
|
db.commit()
|
|
|
|
|
|
def send_to_user(db: Session, user_id: int, title: str, body: str, url: str = "/today") -> int:
|
|
"""해당 유저의 구독자에게만 알림을 보낸다. 만료된(410/404) 구독은 자동으로 삭제한다. 성공 발송 건수를 반환."""
|
|
payload = json.dumps({"title": title, "body": body, "url": url}, ensure_ascii=False)
|
|
sent = 0
|
|
for sub in list_subscriptions(db, user_id):
|
|
try:
|
|
webpush(
|
|
subscription_info={
|
|
"endpoint": sub.endpoint,
|
|
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
|
|
},
|
|
data=payload,
|
|
vapid_private_key=settings.vapid_private_key,
|
|
vapid_claims={"sub": settings.vapid_subject},
|
|
)
|
|
sent += 1
|
|
except WebPushException as exc:
|
|
status_code = exc.response.status_code if exc.response is not None else None
|
|
if status_code in (404, 410):
|
|
db.delete(sub)
|
|
db.commit()
|
|
# 그 외 오류(일시적 네트워크 문제 등)는 건너뛰고 다음 구독자에게 계속 발송한다.
|
|
return sent
|