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

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings from app.core.config import settings
from app.db.base import Base 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 = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

View 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")

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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import get_session 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.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 = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserRead) def username_from_telegram(telegram_id: int, username: str | None = None) -> str:
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User: return str(telegram_id) if not username else str(telegram_id)
result = await session.execute(select(User).where(User.telegram_id == payload.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() 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: 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) session.add(user)
else: else:
for field, value in payload.model_dump(exclude_none=True).items(): for field, value in payload.items():
setattr(user, field, value) if value is not None:
setattr(user, field, value)
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
return 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) @router.get("/telegram/{telegram_id}", response_model=UserRead)
async def get_user_by_telegram_id( async def get_user_by_telegram_id(
telegram_id: int, session: AsyncSession = Depends(get_session) telegram_id: int, session: AsyncSession = Depends(get_session)
@@ -44,3 +125,85 @@ async def update_preferences(
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
return 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

View File

@@ -6,10 +6,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers" database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers"
bot_token: str = "" bot_token: str = ""
bot_username: str = ""
api_base_url: str = "http://localhost:8000" api_base_url: str = "http://localhost:8000"
webapp_url: str = "http://localhost:8000" webapp_url: str = "http://localhost:8000"
app_host: str = "0.0.0.0" app_host: str = "0.0.0.0"
app_port: int = 8000 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") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

View File

@@ -1 +1,3 @@
from app.models.push import PushSubscription
__all__ = ["PushSubscription"]

24
app/models/push.py Normal file
View 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")

View File

@@ -22,3 +22,6 @@ class User(Base):
) )
cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan") cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan")
push_subscriptions = relationship(
"PushSubscription", back_populates="user", cascade="all, delete-orphan"
)

View File

@@ -12,6 +12,48 @@ class UserUpsert(BaseModel):
currency: str | None = None 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): class UserPreferencesUpdate(BaseModel):
locale: str | None = None locale: str | None = None
currency: str | None = None currency: str | None = None

View 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

View File

@@ -12,6 +12,14 @@
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
</head> </head>
<body> <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"> <main class="shell">
<header class="topbar"> <header class="topbar">
<div> <div>
@@ -187,6 +195,14 @@
Исполнитель Исполнитель
<input name="vendor" placeholder="СТО / магазин" /> <input name="vendor" placeholder="СТО / магазин" />
</label> </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> <button type="submit">Сохранить запись</button>
</form> </form>
</section> </section>
@@ -235,24 +251,6 @@
<button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button> <button type="button" class="wide-btn" id="enableNotificationsBtn">Включить уведомления</button>
</section> </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"> <section class="drawer-section" id="carFormSection">
<h2>Новое авто</h2> <h2>Новое авто</h2>
<form id="carForm" class="grid-form drawer-form"> <form id="carForm" class="grid-form drawer-form">
@@ -278,6 +276,26 @@
</div> </div>
</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="report-sheet hidden" id="reportSheet">
<div class="sheet-panel"> <div class="sheet-panel">
<div class="section-head"> <div class="section-head">

View File

@@ -126,6 +126,7 @@ const i18n = {
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.", "PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
"Напоминания готовы": "Reminders are ready", "Напоминания готовы": "Reminders are ready",
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.", "Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
"Напоминаний на ближайшее время нет": "No reminders due soon",
"Готов к работе": "Ready", "Готов к работе": "Ready",
"Обновляю данные...": "Refreshing data...", "Обновляю данные...": "Refreshing data...",
"Сохраняю...": "Saving...", "Сохраняю...": "Saving...",
@@ -254,6 +255,7 @@ const i18n = {
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.", "PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
"Напоминания готовы": "알림 준비 완료", "Напоминания готовы": "알림 준비 완료",
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.", "Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
"Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
"Готов к работе": "준비 완료", "Готов к работе": "준비 완료",
"Обновляю данные...": "데이터 새로고침 중...", "Обновляю данные...": "데이터 새로고침 중...",
"Сохраняю...": "저장 중...", "Сохраняю...": "저장 중...",
@@ -305,6 +307,7 @@ function applyTranslations(root = document.body) {
const state = { const state = {
user: null, user: null,
authConfig: null,
cars: [], cars: [],
catalog: [], catalog: [],
selectedCarId: null, selectedCarId: null,
@@ -353,7 +356,9 @@ async function enableNotifications() {
applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY), applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY),
}); });
localStorage.setItem("driversPushSubscription", JSON.stringify(subscription)); localStorage.setItem("driversPushSubscription", JSON.stringify(subscription));
await savePushSubscription(subscription);
} }
await showDueReminders(registration);
if (registration?.showNotification) { if (registration?.showNotification) {
await registration.showNotification(t("Напоминания готовы"), { await registration.showNotification(t("Напоминания готовы"), {
body: t("Мы напомним о ТО, страховке и обновлении пробега."), body: t("Мы напомним о ТО, страховке и обновлении пробега."),
@@ -365,6 +370,28 @@ async function enableNotifications() {
updateNotificationStatus("Уведомления включены"); 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) { function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
@@ -372,15 +399,6 @@ function urlBase64ToUint8Array(base64String) {
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 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() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
@@ -402,6 +420,11 @@ async function api(path, options = {}) {
return response.json(); 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 = "Готов к работе") { function setStatus(message = "Готов к работе") {
const node = document.querySelector("#statusBar"); const node = document.querySelector("#statusBar");
if (node) node.textContent = t(message); if (node) node.textContent = t(message);
@@ -466,14 +489,66 @@ async function runAction(button, statusMessage, callback) {
} }
async function ensureUser() { async function ensureUser() {
const tgUser = tg?.initDataUnsafe?.user || fallbackUser; if (tg?.initData) {
state.user = await api("/users", { 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", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
telegram_id: tgUser.id, ...subscription.toJSON(),
username: tgUser.username || null, user_agent: navigator.userAgent,
first_name: tgUser.first_name || null,
last_name: tgUser.last_name || null,
}), }),
}); });
} }
@@ -969,7 +1044,7 @@ async function loadSelectedCar() {
} }
document.querySelectorAll('input[type="date"]').forEach((input) => { document.querySelectorAll('input[type="date"]').forEach((input) => {
input.value = today(); if (input.name !== "next_due_date") input.value = today();
}); });
applyPeriodPreset("month"); applyPeriodPreset("month");
@@ -1081,6 +1156,8 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
title: data.title, title: data.title,
total_cost: Number(data.total_cost), total_cost: Number(data.total_cost),
vendor: data.vendor || null, 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(); form.reset();
@@ -1099,13 +1176,17 @@ function setAction(action) {
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service"); 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) => { document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
haptic(); haptic();
if (button.dataset.action === "scan") { if (button.dataset.action === "scan") {
document.querySelector("#userDrawer").classList.remove("hidden"); openScanModal();
document.querySelector("#scanSection").classList.remove("hidden");
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
return; return;
} }
setAction(button.dataset.action); setAction(button.dataset.action);
@@ -1162,8 +1243,11 @@ document.querySelector("#openNotificationsBtn").addEventListener("click", () =>
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications); document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#openScanBtn").addEventListener("click", () => { document.querySelector("#openScanBtn").addEventListener("click", () => {
document.querySelector("#scanSection").classList.remove("hidden"); openScanModal();
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" }); });
document.querySelector("#closeScanBtn").addEventListener("click", () => {
document.querySelector("#scanModal").classList.add("hidden");
}); });
function setReceiptFile(file) { 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.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
if (result.station) fuelForm.station.value = result.station; if (result.station) fuelForm.station.value = result.station;
setAction("fuel"); setAction("fuel");
document.querySelector("#userDrawer").classList.add("hidden"); document.querySelector("#scanModal").classList.add("hidden");
toast("Проверь распознанные значения"); toast("Проверь распознанные значения");
haptic("success"); haptic("success");
}); });
@@ -1228,7 +1312,8 @@ window.addEventListener("resize", () => {
initPwa(); initPwa();
Promise.all([ensureUser(), loadCatalog()]) Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => { .then(() => {
document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
@@ -1237,5 +1322,6 @@ Promise.all([ensureUser(), loadCatalog()])
return loadCars(); return loadCars();
}) })
.catch((error) => { .catch((error) => {
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`); if (error.message === "Требуется вход через Telegram") return;
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
}); });

View File

@@ -1158,6 +1158,47 @@ select {
display: none; 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 { @keyframes toastIn {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "drivers-garage-v1"; const CACHE_NAME = "drivers-garage-v2";
const APP_SHELL = [ const APP_SHELL = [
"/", "/",
"/static/app.js", "/static/app.js",