add telegram auth and reminders foundation
This commit is contained in:
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import car, expense, user # noqa: F401
|
||||
from app.models import car, expense, push, user # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
39
alembic/versions/202605120001_push_subscriptions.py
Normal file
39
alembic/versions/202605120001_push_subscriptions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""push subscriptions
|
||||
|
||||
Revision ID: 202605120001
|
||||
Revises: 202605110003
|
||||
Create Date: 2026-05-12
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605120001"
|
||||
down_revision: str | None = "202605110003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"push_subscriptions",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("endpoint", sa.Text(), nullable=False),
|
||||
sa.Column("p256dh", sa.String(length=256), nullable=True),
|
||||
sa.Column("auth", sa.String(length=256), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"),
|
||||
)
|
||||
op.create_index(op.f("ix_push_subscriptions_user_id"), "push_subscriptions", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_push_subscriptions_user_id"), table_name="push_subscriptions")
|
||||
op.drop_table("push_subscriptions")
|
||||
177
app/api/users.py
177
app/api/users.py
@@ -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():
|
||||
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
|
||||
|
||||
@@ -6,10 +6,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers"
|
||||
bot_token: str = ""
|
||||
bot_username: str = ""
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
webapp_url: str = "http://localhost:8000"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_env: str = "production"
|
||||
vapid_public_key: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from app.models.push import PushSubscription
|
||||
|
||||
__all__ = ["PushSubscription"]
|
||||
|
||||
24
app/models/push.py
Normal file
24
app/models/push.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscriptions"
|
||||
__table_args__ = (UniqueConstraint("user_id", "endpoint", name="uq_push_user_endpoint"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
endpoint: Mapped[str] = mapped_column(Text)
|
||||
p256dh: Mapped[str | None] = mapped_column(String(256))
|
||||
auth: Mapped[str | None] = mapped_column(String(256))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="push_subscriptions")
|
||||
@@ -22,3 +22,6 @@ class User(Base):
|
||||
)
|
||||
|
||||
cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan")
|
||||
push_subscriptions = relationship(
|
||||
"PushSubscription", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@@ -12,6 +12,48 @@ class UserUpsert(BaseModel):
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class WebAppAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
|
||||
|
||||
class TelegramLoginRequest(BaseModel):
|
||||
id: int
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int
|
||||
hash: str
|
||||
|
||||
|
||||
class AuthConfig(BaseModel):
|
||||
bot_username: str
|
||||
vapid_public_key: str | None = None
|
||||
|
||||
|
||||
class PushSubscriptionKeys(BaseModel):
|
||||
p256dh: str | None = None
|
||||
auth: str | None = None
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str
|
||||
keys: PushSubscriptionKeys | None = None
|
||||
user_agent: str | None = None
|
||||
|
||||
|
||||
class ReminderRead(BaseModel):
|
||||
id: int
|
||||
car_id: int
|
||||
car_name: str
|
||||
title: str
|
||||
service_type: str
|
||||
due_date: str | None = None
|
||||
due_odometer: int | None = None
|
||||
current_odometer: int | None = None
|
||||
priority: str
|
||||
|
||||
|
||||
class UserPreferencesUpdate(BaseModel):
|
||||
locale: str | None = None
|
||||
currency: str | None = None
|
||||
|
||||
49
app/services/telegram_auth.py
Normal file
49
app/services/telegram_auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
def _secret_key(bot_token: str, *, webapp: bool) -> bytes:
|
||||
if webapp:
|
||||
return hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
|
||||
return hashlib.sha256(bot_token.encode()).digest()
|
||||
|
||||
|
||||
def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict:
|
||||
values = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||
received_hash = values.pop("hash", "")
|
||||
if not received_hash:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram hash missing")
|
||||
auth_date = int(values.get("auth_date") or 0)
|
||||
if max_age_seconds and auth_date and time.time() - auth_date > max_age_seconds:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth expired")
|
||||
|
||||
data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values))
|
||||
expected = hmac.new(_secret_key(bot_token, webapp=True), data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, received_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth invalid")
|
||||
|
||||
user_raw = values.get("user")
|
||||
if not user_raw:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram user missing")
|
||||
return json.loads(user_raw)
|
||||
|
||||
|
||||
def verify_login_widget(payload: dict, bot_token: str, max_age_seconds: int = 86400) -> dict:
|
||||
values = {key: value for key, value in payload.items() if value is not None}
|
||||
received_hash = str(values.pop("hash", ""))
|
||||
if not received_hash:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram hash missing")
|
||||
auth_date = int(values.get("auth_date") or 0)
|
||||
if max_age_seconds and auth_date and time.time() - auth_date > max_age_seconds:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth expired")
|
||||
|
||||
data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values))
|
||||
expected = hmac.new(_secret_key(bot_token, webapp=False), data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, received_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram auth invalid")
|
||||
return values
|
||||
@@ -12,6 +12,14 @@
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">Drivers</p>
|
||||
<h1>Гараж</h1>
|
||||
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
|
||||
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
@@ -187,6 +195,14 @@
|
||||
Исполнитель
|
||||
<input name="vendor" placeholder="СТО / магазин" />
|
||||
</label>
|
||||
<label>
|
||||
Следующая дата
|
||||
<input name="next_due_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Следующий пробег
|
||||
<input name="next_due_odometer" type="number" min="0" />
|
||||
</label>
|
||||
<button type="submit">Сохранить запись</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -235,24 +251,6 @@
|
||||
<button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="scanSection">
|
||||
<h2>Скан чека</h2>
|
||||
<form id="ocrForm" class="grid-form drawer-form">
|
||||
<label>
|
||||
Фото или файл чека
|
||||
<span class="scan-actions">
|
||||
<button type="button" class="ghost-btn" id="scanCameraBtn">Сфотографировать</button>
|
||||
<button type="button" class="ghost-btn" id="scanFileBtn">Выбрать файл</button>
|
||||
</span>
|
||||
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
|
||||
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
|
||||
</label>
|
||||
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
||||
<button type="submit">Распознать</button>
|
||||
</form>
|
||||
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section" id="carFormSection">
|
||||
<h2>Новое авто</h2>
|
||||
<form id="carForm" class="grid-form drawer-form">
|
||||
@@ -278,6 +276,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-modal hidden" id="scanModal">
|
||||
<div class="scan-panel">
|
||||
<div class="section-head">
|
||||
<h2>Скан чека</h2>
|
||||
<button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<form id="ocrForm" class="scan-form">
|
||||
<div class="scan-actions">
|
||||
<button type="button" class="ghost-btn" id="scanCameraBtn">Сфотографировать</button>
|
||||
<button type="button" class="ghost-btn" id="scanFileBtn">Выбрать файл</button>
|
||||
</div>
|
||||
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
|
||||
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
|
||||
<div id="receiptFileName" class="file-hint">Файл не выбран</div>
|
||||
<button type="submit">Распознать</button>
|
||||
</form>
|
||||
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-sheet hidden" id="reportSheet">
|
||||
<div class="sheet-panel">
|
||||
<div class="section-head">
|
||||
|
||||
@@ -126,6 +126,7 @@ const i18n = {
|
||||
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
|
||||
"Напоминания готовы": "Reminders are ready",
|
||||
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
|
||||
"Напоминаний на ближайшее время нет": "No reminders due soon",
|
||||
"Готов к работе": "Ready",
|
||||
"Обновляю данные...": "Refreshing data...",
|
||||
"Сохраняю...": "Saving...",
|
||||
@@ -254,6 +255,7 @@ const i18n = {
|
||||
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
|
||||
"Напоминания готовы": "알림 준비 완료",
|
||||
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
|
||||
"Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
|
||||
"Готов к работе": "준비 완료",
|
||||
"Обновляю данные...": "데이터 새로고침 중...",
|
||||
"Сохраняю...": "저장 중...",
|
||||
@@ -305,6 +307,7 @@ function applyTranslations(root = document.body) {
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
authConfig: null,
|
||||
cars: [],
|
||||
catalog: [],
|
||||
selectedCarId: null,
|
||||
@@ -353,7 +356,9 @@ async function enableNotifications() {
|
||||
applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY),
|
||||
});
|
||||
localStorage.setItem("driversPushSubscription", JSON.stringify(subscription));
|
||||
await savePushSubscription(subscription);
|
||||
}
|
||||
await showDueReminders(registration);
|
||||
if (registration?.showNotification) {
|
||||
await registration.showNotification(t("Напоминания готовы"), {
|
||||
body: t("Мы напомним о ТО, страховке и обновлении пробега."),
|
||||
@@ -365,6 +370,28 @@ async function enableNotifications() {
|
||||
updateNotificationStatus("Уведомления включены");
|
||||
}
|
||||
|
||||
async function showDueReminders(registration) {
|
||||
if (!state.user) return;
|
||||
const reminders = await api(`/users/${state.user.id}/reminders`);
|
||||
if (!reminders.length) {
|
||||
updateNotificationStatus("Напоминаний на ближайшее время нет");
|
||||
return;
|
||||
}
|
||||
updateNotificationStatus(`Есть напоминаний: ${reminders.length}`);
|
||||
if (Notification.permission !== "granted" || !registration?.showNotification) return;
|
||||
const item = reminders[0];
|
||||
const body = item.due_odometer
|
||||
? `${item.car_name}: ${item.title}, срок ${item.due_odometer} км`
|
||||
: `${item.car_name}: ${item.title}, срок ${item.due_date}`;
|
||||
await registration.showNotification(t("Напоминания готовы"), {
|
||||
body,
|
||||
icon: "/static/icon.svg",
|
||||
badge: "/static/icon.svg",
|
||||
tag: `drivers-reminder-${item.id}`,
|
||||
data: "/",
|
||||
});
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
@@ -372,15 +399,6 @@ function urlBase64ToUint8Array(base64String) {
|
||||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
const fallbackUser = {
|
||||
id: 1,
|
||||
username: "demo",
|
||||
first_name: "Demo",
|
||||
last_name: null,
|
||||
locale: "ru",
|
||||
currency: "RUB",
|
||||
};
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -402,6 +420,11 @@ async function api(path, options = {}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadAuthConfig() {
|
||||
state.authConfig = await api("/users/auth/config");
|
||||
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
|
||||
}
|
||||
|
||||
function setStatus(message = "Готов к работе") {
|
||||
const node = document.querySelector("#statusBar");
|
||||
if (node) node.textContent = t(message);
|
||||
@@ -466,14 +489,66 @@ async function runAction(button, statusMessage, callback) {
|
||||
}
|
||||
|
||||
async function ensureUser() {
|
||||
const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
|
||||
state.user = await api("/users", {
|
||||
if (tg?.initData) {
|
||||
state.user = await api("/users/webapp-auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
hideAuthOverlay();
|
||||
return;
|
||||
}
|
||||
const stored = localStorage.getItem("driversUser");
|
||||
if (stored) {
|
||||
state.user = JSON.parse(stored);
|
||||
hideAuthOverlay();
|
||||
return;
|
||||
}
|
||||
await showTelegramLogin();
|
||||
throw new Error("Требуется вход через Telegram");
|
||||
}
|
||||
|
||||
function hideAuthOverlay() {
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
async function showTelegramLogin() {
|
||||
const overlay = document.querySelector("#authOverlay");
|
||||
const slot = document.querySelector("#telegramLoginSlot");
|
||||
overlay?.classList.remove("hidden");
|
||||
if (!slot || slot.dataset.ready) return;
|
||||
const botUsername = state.authConfig?.bot_username;
|
||||
if (!botUsername) {
|
||||
slot.textContent = "Telegram Login временно недоступен";
|
||||
return;
|
||||
}
|
||||
window.onTelegramAuth = async (user) => {
|
||||
state.user = await api("/users/telegram-login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
localStorage.setItem("driversUser", JSON.stringify(state.user));
|
||||
hideAuthOverlay();
|
||||
await loadCars();
|
||||
};
|
||||
const script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.setAttribute("data-telegram-login", botUsername);
|
||||
script.setAttribute("data-size", "large");
|
||||
script.setAttribute("data-radius", "8");
|
||||
script.setAttribute("data-request-access", "write");
|
||||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||||
slot.dataset.ready = "true";
|
||||
slot.appendChild(script);
|
||||
}
|
||||
|
||||
async function savePushSubscription(subscription) {
|
||||
if (!state.user || !subscription) return;
|
||||
await api(`/users/${state.user.id}/push-subscriptions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
telegram_id: tgUser.id,
|
||||
username: tgUser.username || null,
|
||||
first_name: tgUser.first_name || null,
|
||||
last_name: tgUser.last_name || null,
|
||||
...subscription.toJSON(),
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -969,7 +1044,7 @@ async function loadSelectedCar() {
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[type="date"]').forEach((input) => {
|
||||
input.value = today();
|
||||
if (input.name !== "next_due_date") input.value = today();
|
||||
});
|
||||
|
||||
applyPeriodPreset("month");
|
||||
@@ -1081,6 +1156,8 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
|
||||
title: data.title,
|
||||
total_cost: Number(data.total_cost),
|
||||
vendor: data.vendor || null,
|
||||
next_due_date: data.next_due_date || null,
|
||||
next_due_odometer: data.next_due_odometer ? Number(data.next_due_odometer) : null,
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
@@ -1099,13 +1176,17 @@ function setAction(action) {
|
||||
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
|
||||
}
|
||||
|
||||
function openScanModal() {
|
||||
haptic();
|
||||
document.querySelector("#userDrawer").classList.add("hidden");
|
||||
document.querySelector("#scanModal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
haptic();
|
||||
if (button.dataset.action === "scan") {
|
||||
document.querySelector("#userDrawer").classList.remove("hidden");
|
||||
document.querySelector("#scanSection").classList.remove("hidden");
|
||||
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
openScanModal();
|
||||
return;
|
||||
}
|
||||
setAction(button.dataset.action);
|
||||
@@ -1162,8 +1243,11 @@ document.querySelector("#openNotificationsBtn").addEventListener("click", () =>
|
||||
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
|
||||
|
||||
document.querySelector("#openScanBtn").addEventListener("click", () => {
|
||||
document.querySelector("#scanSection").classList.remove("hidden");
|
||||
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
openScanModal();
|
||||
});
|
||||
|
||||
document.querySelector("#closeScanBtn").addEventListener("click", () => {
|
||||
document.querySelector("#scanModal").classList.add("hidden");
|
||||
});
|
||||
|
||||
function setReceiptFile(file) {
|
||||
@@ -1208,7 +1292,7 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
||||
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
|
||||
if (result.station) fuelForm.station.value = result.station;
|
||||
setAction("fuel");
|
||||
document.querySelector("#userDrawer").classList.add("hidden");
|
||||
document.querySelector("#scanModal").classList.add("hidden");
|
||||
toast("Проверь распознанные значения");
|
||||
haptic("success");
|
||||
});
|
||||
@@ -1228,7 +1312,8 @@ window.addEventListener("resize", () => {
|
||||
|
||||
initPwa();
|
||||
|
||||
Promise.all([ensureUser(), loadCatalog()])
|
||||
Promise.all([loadAuthConfig()])
|
||||
.then(() => Promise.all([ensureUser(), loadCatalog()]))
|
||||
.then(() => {
|
||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||
@@ -1237,5 +1322,6 @@ Promise.all([ensureUser(), loadCatalog()])
|
||||
return loadCars();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
|
||||
});
|
||||
|
||||
@@ -1158,6 +1158,47 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-overlay,
|
||||
.scan-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(18, 24, 21, 0.46);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.auth-overlay.hidden,
|
||||
.scan-modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.scan-panel {
|
||||
width: min(420px, 100%);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 22px 60px rgba(18, 24, 21, 0.24);
|
||||
}
|
||||
|
||||
.auth-panel p:not(.eyebrow) {
|
||||
margin: 12px 0 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.telegram-login-slot {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.scan-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
Reference in New Issue
Block a user