Initial commit: habit tracker PWA with Google OAuth, push notifications
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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
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
|
||||
Reference in New Issue
Block a user