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:
2026-07-16 18:06:17 +09:00
co-authored by Claude Sonnet 5
commit cee589bb3e
80 changed files with 4695 additions and 0 deletions
+69
View File
@@ -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