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,65 @@
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.security import SESSION_MAX_AGE_SECONDS, create_session_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="google",
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_id=settings.google_client_id,
|
||||
client_secret=settings.google_client_secret,
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/google/login")
|
||||
async def google_login(request: Request):
|
||||
return await oauth.google.authorize_redirect(request, settings.google_redirect_uri)
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_callback(request: Request, db: Session = Depends(get_db)):
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
userinfo = token["userinfo"]
|
||||
google_sub = userinfo["sub"]
|
||||
email = userinfo["email"]
|
||||
name = userinfo.get("name")
|
||||
picture_url = userinfo.get("picture")
|
||||
|
||||
user = db.scalar(select(User).where(User.google_sub == google_sub))
|
||||
if user is None:
|
||||
user = User(google_sub=google_sub, email=email, name=name, picture_url=picture_url)
|
||||
db.add(user)
|
||||
else:
|
||||
user.email = email
|
||||
user.name = name
|
||||
user.picture_url = picture_url
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
session_token = create_session_token(user.id)
|
||||
response = RedirectResponse(url="/today", status_code=303)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
session_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=SESSION_MAX_AGE_SECONDS,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
response = RedirectResponse(url="/login", status_code=303)
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return response
|
||||
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.habit import HabitStatus, HabitType
|
||||
from app.models.user import User
|
||||
from app.schemas.habit import HabitCreate, HabitOut, HabitReorderRequest, HabitUpdate
|
||||
from app.schemas.habit_log import HabitStats
|
||||
from app.security import require_login
|
||||
from app.services import habit_service, log_service
|
||||
|
||||
router = APIRouter(prefix="/api/habits", tags=["habits"], dependencies=[Depends(require_login)])
|
||||
|
||||
|
||||
def _get_habit_or_404(db: Session, habit_id: int, user_id: int):
|
||||
habit = habit_service.get_habit(db, habit_id, user_id)
|
||||
if habit is None:
|
||||
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
|
||||
return habit
|
||||
|
||||
|
||||
@router.get("", response_model=list[HabitOut])
|
||||
def list_habits(
|
||||
type: HabitType | None = None,
|
||||
status: HabitStatus | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
return habit_service.list_habits(db, current_user.id, habit_type=type, status=status)
|
||||
|
||||
|
||||
@router.post("", response_model=HabitOut, status_code=201)
|
||||
def create_habit(
|
||||
data: HabitCreate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
return habit_service.create_habit(db, current_user.id, data)
|
||||
|
||||
|
||||
@router.post("/reorder")
|
||||
def reorder_habits(
|
||||
data: HabitReorderRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
habit_service.reorder_habits(db, current_user.id, data.habit_ids)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{habit_id}", response_model=HabitOut)
|
||||
def get_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
return _get_habit_or_404(db, habit_id, current_user.id)
|
||||
|
||||
|
||||
@router.put("/{habit_id}", response_model=HabitOut)
|
||||
def update_habit(
|
||||
habit_id: int, data: HabitUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
return habit_service.update_habit(db, habit, data)
|
||||
|
||||
|
||||
@router.delete("/{habit_id}", status_code=204)
|
||||
def delete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
habit_service.delete_habit(db, habit)
|
||||
|
||||
|
||||
@router.post("/{habit_id}/complete", response_model=HabitOut)
|
||||
def complete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
return habit_service.complete_habit(db, habit)
|
||||
|
||||
|
||||
@router.post("/{habit_id}/reactivate", response_model=HabitOut)
|
||||
def reactivate_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
return habit_service.reactivate_habit(db, habit)
|
||||
|
||||
|
||||
@router.get("/{habit_id}/stats", response_model=HabitStats)
|
||||
def get_habit_stats(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = _get_habit_or_404(db, habit_id, current_user.id)
|
||||
return log_service.get_habit_stats(db, habit)
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.habit_log import HabitLogOut, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
|
||||
from app.security import require_login
|
||||
from app.services import habit_service, log_service
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["logs"], dependencies=[Depends(require_login)])
|
||||
|
||||
|
||||
@router.get("/today", response_model=list[TodayItem])
|
||||
def get_today(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
build_items, quit_items = log_service.get_today_items(db, current_user.id, date.today())
|
||||
return build_items + quit_items
|
||||
|
||||
|
||||
@router.post("/logs/{habit_id}/toggle")
|
||||
def toggle_log(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
habit = habit_service.get_habit(db, habit_id, current_user.id)
|
||||
if habit is None:
|
||||
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
|
||||
checked, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today())
|
||||
return {"checked": checked, "milestone_streak": milestone_streak}
|
||||
|
||||
|
||||
@router.get("/logs", response_model=list[HabitLogOut])
|
||||
def list_logs(
|
||||
habit_id: int | None = None,
|
||||
start: date | None = None,
|
||||
end: date | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
return log_service.list_logs(db, current_user.id, habit_id=habit_id, start=start, end=end)
|
||||
|
||||
|
||||
@router.get("/logs/summary/monthly", response_model=list[MonthlySummaryDay])
|
||||
def monthly_summary(
|
||||
year: int, month: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
return log_service.get_monthly_summary(db, current_user.id, year, month)
|
||||
|
||||
|
||||
@router.get("/logs/summary/weekly", response_model=list[WeeklyMatrixRow])
|
||||
def weekly_summary(
|
||||
start_date: date, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
return log_service.get_weekly_matrix(db, current_user.id, start_date)
|
||||
@@ -0,0 +1,304 @@
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
from datetime import time as time_type
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.habit import HabitStatus, HabitType
|
||||
from app.models.user import User
|
||||
from app.schemas.habit import HabitCreate, HabitUpdate
|
||||
from app.security import get_current_user_optional
|
||||
from app.services import habit_service, log_service
|
||||
from app.template_utils import heatmap_opacity, is_milestone_streak, weekday_label
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.globals["weekday_label"] = weekday_label
|
||||
templates.env.globals["heatmap_opacity"] = heatmap_opacity
|
||||
templates.env.globals["is_milestone_streak"] = is_milestone_streak
|
||||
|
||||
|
||||
def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
|
||||
user = get_current_user_optional(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def index(request: Request, db: Session = Depends(get_db)):
|
||||
if get_current_user_optional(request, db) is not None:
|
||||
return RedirectResponse(url="/today", status_code=303)
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||
if get_current_user_optional(request, db) is not None:
|
||||
return RedirectResponse(url="/today", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None})
|
||||
|
||||
|
||||
def _today_context(db: Session, user_id: int, **extra) -> dict:
|
||||
build_items, quit_items = log_service.get_today_items(db, user_id, date.today())
|
||||
all_items = build_items + quit_items
|
||||
return {
|
||||
"build_items": build_items,
|
||||
"quit_items": quit_items,
|
||||
"total_count": len(all_items),
|
||||
"checked_count": sum(1 for item in all_items if item.checked),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/today")
|
||||
def today_page(request: Request, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"today.html",
|
||||
{"logged_in": True, "current_user": current, **_today_context(db, current.id)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/today/{habit_id}/toggle")
|
||||
def toggle_today_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
_, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today())
|
||||
extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/today_content.html",
|
||||
{"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/habits")
|
||||
def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
if tab == "completed":
|
||||
habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED)
|
||||
else:
|
||||
if tab not in ("build", "quit"):
|
||||
tab = "build"
|
||||
habits = habit_service.list_habits(db, current.id, habit_type=HabitType(tab), status=HabitStatus.ACTIVE)
|
||||
|
||||
stats_map = {h.id: log_service.get_habit_stats(db, h) for h in habits}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"habits.html",
|
||||
{
|
||||
"logged_in": True,
|
||||
"current_user": current,
|
||||
"tab": tab,
|
||||
"habits": habits,
|
||||
"habit_type": tab,
|
||||
"stats_map": stats_map,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/habits/new")
|
||||
def create_habit_page(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
habit_type: str = Form(...),
|
||||
weekdays_mask: int = Form(...),
|
||||
condition_text: str | None = Form(None),
|
||||
reminder_time: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
try:
|
||||
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
|
||||
data = HabitCreate(
|
||||
name=name,
|
||||
habit_type=HabitType(habit_type),
|
||||
weekdays_mask=weekdays_mask,
|
||||
condition_text=condition_text,
|
||||
reminder_time=parsed_time,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
|
||||
return HTMLResponse(message)
|
||||
except ValueError:
|
||||
return HTMLResponse("입력값을 확인해주세요")
|
||||
|
||||
habit_service.create_habit(db, current.id, data)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={habit_type}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/edit")
|
||||
def edit_habit_page(
|
||||
request: Request,
|
||||
habit_id: int,
|
||||
name: str = Form(""),
|
||||
weekdays_mask: int = Form(...),
|
||||
condition_text: str | None = Form(None),
|
||||
reminder_time: str | None = Form(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
|
||||
try:
|
||||
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
|
||||
data = HabitUpdate(
|
||||
name=name,
|
||||
habit_type=habit.habit_type,
|
||||
weekdays_mask=weekdays_mask,
|
||||
condition_text=condition_text,
|
||||
reminder_time=parsed_time,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
|
||||
return HTMLResponse(message)
|
||||
except ValueError:
|
||||
return HTMLResponse("입력값을 확인해주세요")
|
||||
|
||||
habit_service.update_habit(db, habit, data)
|
||||
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/complete")
|
||||
def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
tab = habit.habit_type.value
|
||||
habit_service.complete_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/habits/{habit_id}/reactivate")
|
||||
def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
habit_service.reactivate_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = "/habits?tab=completed"
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/habits/{habit_id}/delete")
|
||||
def delete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
habit = habit_service.get_habit(db, habit_id, current.id)
|
||||
if habit is None:
|
||||
return Response(status_code=404)
|
||||
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
||||
habit_service.delete_habit(db, habit)
|
||||
response = Response(status_code=200)
|
||||
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
|
||||
return response
|
||||
|
||||
|
||||
def _month_context(db: Session, user_id: int, year: int, month: int) -> dict:
|
||||
summaries = log_service.get_monthly_summary(db, user_id, year, month)
|
||||
summary_map = {s.log_date: s for s in summaries}
|
||||
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month) # 6=일요일(calendar 모듈 기준)부터 시작
|
||||
completion_rate = log_service.summarize_completion_rate(summaries, date.today())
|
||||
|
||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
return {
|
||||
"view": "month",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": weeks,
|
||||
"summary_map": summary_map,
|
||||
"completion_rate": completion_rate,
|
||||
"prev_year": prev_year,
|
||||
"prev_month": prev_month,
|
||||
"next_year": next_year,
|
||||
"next_month": next_month,
|
||||
}
|
||||
|
||||
|
||||
def _week_context(db: Session, user_id: int, week_start: date) -> dict:
|
||||
rows = log_service.get_weekly_matrix(db, user_id, week_start)
|
||||
return {
|
||||
"view": "week",
|
||||
"week_start": week_start,
|
||||
"week_end": week_start + timedelta(days=6),
|
||||
"rows": rows,
|
||||
"prev_week": (week_start - timedelta(days=7)).isoformat(),
|
||||
"next_week": (week_start + timedelta(days=7)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def history_page(
|
||||
request: Request,
|
||||
view: str = "month",
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
start: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
current = _current_user_or_redirect(request, db)
|
||||
if isinstance(current, RedirectResponse):
|
||||
return current
|
||||
|
||||
today = date.today()
|
||||
if view == "week":
|
||||
week_start = date.fromisoformat(start) if start else today
|
||||
# date.weekday()는 월=0..일=6이라, 일요일까지 거슬러 올라가려면 +1 해서 나머지를 구해야 한다
|
||||
# (일요일 자신은 0일 전, 월요일은 1일 전, ... 토요일은 6일 전).
|
||||
week_start = week_start - timedelta(days=(week_start.weekday() + 1) % 7)
|
||||
context = _week_context(db, current.id, week_start)
|
||||
else:
|
||||
view = "month"
|
||||
context = _month_context(db, current.id, year or today.year, month or today.month)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "history.html", {"logged_in": True, "current_user": current, **context}
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.push import PushSubscribeRequest
|
||||
from app.security import require_login
|
||||
from app.services import push_service
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"], dependencies=[Depends(require_login)])
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
@router.get("/vapid-public-key")
|
||||
def get_vapid_public_key():
|
||||
return {"publicKey": settings.vapid_public_key}
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
def subscribe(
|
||||
data: PushSubscribeRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
push_service.save_subscription(db, current_user.id, data, user_agent=request.headers.get("user-agent"))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/unsubscribe")
|
||||
def unsubscribe(
|
||||
data: UnsubscribeRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
|
||||
):
|
||||
push_service.delete_subscription(db, data.endpoint)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||
sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.")
|
||||
return {"sent": sent}
|
||||
Reference in New Issue
Block a user