add telegram auth and reminders foundation

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:36:30 +09:00
parent a6cdc98f7b
commit f7a3b8be54
13 changed files with 522 additions and 52 deletions

View File

@@ -1,29 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import get_session
from app.models.car import Car
from app.models.expense import ServiceEntry
from app.models.push import PushSubscription
from app.models.user import User
from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert
from app.schemas.user import (
AuthConfig,
PushSubscriptionCreate,
ReminderRead,
TelegramLoginRequest,
UserPreferencesUpdate,
UserRead,
UserUpsert,
WebAppAuthRequest,
)
from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead)
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id))
def username_from_telegram(telegram_id: int, username: str | None = None) -> str:
return str(telegram_id) if not username else str(telegram_id)
async def upsert_telegram_user(
session: AsyncSession,
*,
telegram_id: int,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
locale: str | None = None,
currency: str | None = None,
) -> User:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
payload = {
"telegram_id": telegram_id,
"username": username_from_telegram(telegram_id, username),
"first_name": first_name,
"last_name": last_name,
"locale": locale,
"currency": currency,
}
if user is None:
user = User(**payload.model_dump(exclude_none=True))
user = User(**{key: value for key, value in payload.items() if value is not None})
session.add(user)
else:
for field, value in payload.model_dump(exclude_none=True).items():
setattr(user, field, value)
for field, value in payload.items():
if value is not None:
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user
@router.post("", response_model=UserRead)
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
return await upsert_telegram_user(session, **payload.model_dump())
@router.get("/auth/config", response_model=AuthConfig)
async def auth_config() -> AuthConfig:
return AuthConfig(
bot_username=settings.bot_username or "seoulmate_officialbot",
vapid_public_key=settings.vapid_public_key or None,
)
@router.post("/webapp-auth", response_model=UserRead)
async def webapp_auth(
payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session)
) -> User:
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
telegram_id = int(user_data["id"])
return await upsert_telegram_user(
session,
telegram_id=telegram_id,
username=user_data.get("username"),
first_name=user_data.get("first_name"),
last_name=user_data.get("last_name"),
locale=user_data.get("language_code"),
)
@router.post("/telegram-login", response_model=UserRead)
async def telegram_login(
payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session)
) -> User:
values = verify_login_widget(payload.model_dump(), settings.bot_token)
telegram_id = int(values["id"])
return await upsert_telegram_user(
session,
telegram_id=telegram_id,
username=values.get("username"),
first_name=values.get("first_name"),
last_name=values.get("last_name"),
)
@router.get("/telegram/{telegram_id}", response_model=UserRead)
async def get_user_by_telegram_id(
telegram_id: int, session: AsyncSession = Depends(get_session)
@@ -44,3 +125,85 @@ async def update_preferences(
await session.commit()
await session.refresh(user)
return user
@router.post("/{user_id}/push-subscriptions", status_code=status.HTTP_204_NO_CONTENT)
async def save_push_subscription(
user_id: int,
payload: PushSubscriptionCreate,
request: Request,
session: AsyncSession = Depends(get_session),
) -> None:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == payload.endpoint,
)
)
subscription = result.scalar_one_or_none()
keys = payload.keys or None
user_agent = payload.user_agent or request.headers.get("user-agent")
if subscription is None:
subscription = PushSubscription(
user_id=user_id,
endpoint=payload.endpoint,
p256dh=keys.p256dh if keys else None,
auth=keys.auth if keys else None,
user_agent=user_agent[:256] if user_agent else None,
)
session.add(subscription)
else:
subscription.p256dh = keys.p256dh if keys else subscription.p256dh
subscription.auth = keys.auth if keys else subscription.auth
subscription.user_agent = user_agent[:256] if user_agent else subscription.user_agent
await session.commit()
@router.get("/{user_id}/reminders", response_model=list[ReminderRead])
async def due_reminders(
user_id: int,
days: int = 30,
session: AsyncSession = Depends(get_session),
) -> list[ReminderRead]:
today = date.today()
horizon = today + timedelta(days=max(1, min(days, 180)))
stmt = (
select(ServiceEntry, Car)
.join(Car, ServiceEntry.car_id == Car.id)
.where(Car.owner_id == user_id)
.where(
(ServiceEntry.next_due_date.is_not(None) & (ServiceEntry.next_due_date <= horizon))
| (
ServiceEntry.next_due_odometer.is_not(None)
& Car.current_odometer.is_not(None)
& (ServiceEntry.next_due_odometer <= Car.current_odometer + 1000)
)
)
.order_by(ServiceEntry.next_due_date.asc().nulls_last())
)
rows = (await session.execute(stmt)).all()
reminders: list[ReminderRead] = []
for service, car in rows:
overdue_date = service.next_due_date is not None and service.next_due_date <= today
overdue_odo = (
service.next_due_odometer is not None
and car.current_odometer is not None
and service.next_due_odometer <= car.current_odometer
)
reminders.append(
ReminderRead(
id=service.id,
car_id=car.id,
car_name=car.name,
title=service.title,
service_type=service.service_type.value,
due_date=service.next_due_date.isoformat() if service.next_due_date else None,
due_odometer=service.next_due_odometer,
current_odometer=car.current_odometer,
priority="overdue" if overdue_date or overdue_odo else "soon",
)
)
return reminders