harden telegram webapp production readiness

This commit is contained in:
VPN SaaS Dev
2026-05-12 19:14:21 +09:00
parent e75697f83e
commit 2ba2e88432
27 changed files with 931 additions and 155 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
POSTGRES_DB=drivers
POSTGRES_USER=drivers
POSTGRES_PASSWORD=drivers
POSTGRES_PORT=5433
DATABASE_URL=postgresql+asyncpg://drivers:drivers@db:5432/drivers
BOT_TOKEN=change-me
BOT_USERNAME=your_bot_username
API_BASE_URL=http://api:8000
WEBAPP_URL=https://drivers.smartsoltech.kr
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
CORS_ORIGINS=https://drivers.smartsoltech.kr,https://t.me
INTERNAL_API_TOKEN=change-this-long-random-token
APP_ENV=production
ALLOW_DEV_AUTH=false
APP_HOST=0.0.0.0
APP_PORT=8000
VAPID_PUBLIC_KEY=

144
README.md
View File

@@ -1,68 +1,138 @@
# Drivers Bot # Drivers Bot
Telegram mini app для учета расходов автовладельца: заправки, ремонты, обслуживание, жидкости, статистика стоимости владения и расхода топлива. Telegram bot + Telegram Mini App для учета автомобилей, заправок, сервиса, жидкостей, напоминаний и стоимости владения.
## Состав ## Состав
- `app/` - FastAPI сервис. Через него работают и бот, и HTML5 mini app. - `app/` - FastAPI API, статика Mini App, бизнес-логика и Alembic.
- `bot/` - aiogram 3 бот, который регистрирует пользователя, открывает mini app и показывает быстрые команды. - `bot/` - aiogram 3 бот, который открывает Mini App и работает с API через внутренний токен.
- `web/` - HTML5 Telegram WebApp фронт. - `web/` - статический frontend Telegram WebApp.
- `alembic/` - миграции PostgreSQL. - `alembic/` - миграции PostgreSQL.
- `tests/` - базовые security/API тесты.
## Основные таблицы ## Production Mini App
- `users` - пользователь Telegram. Для production Mini App должен открываться только по публичному HTTPS-домену. Для текущего проекта:
- `cars` - автомобили пользователя.
- `fuel_entries` - заправки: дата, одометр, литры, цена, стоимость, АЗС.
- `service_entries` - обслуживание, ремонты, жидкости, шины, страховка, налоги и прочие расходы.
Связи: `users 1:N cars`, `cars 1:N fuel_entries`, `cars 1:N service_entries`. ```text
https://drivers.smartsoltech.kr
```
В BotFather нужно выполнить:
```text
/setdomain
@seoulmate_officialbot
drivers.smartsoltech.kr
```
Важно:
- в BotFather указывается домен без `https://`;
- `WEBAPP_URL` или `PUBLIC_WEBAPP_URL` в `.env` должны быть `https://drivers.smartsoltech.kr`;
- нельзя использовать `localhost`, `127.0.0.1`, внутренний IP или `http://` для Telegram Mini App в production;
- если появляется `Bot domain invalid`, сначала проверь `/setdomain` и значение `WEBAPP_URL` в контейнере бота.
## Production .env
```dotenv
POSTGRES_DB=drivers
POSTGRES_USER=drivers
POSTGRES_PASSWORD=change-this-db-password
POSTGRES_PORT=5433
DATABASE_URL=postgresql+asyncpg://drivers:change-this-db-password@db:5432/drivers
BOT_TOKEN=123456:telegram-token
BOT_USERNAME=seoulmate_officialbot
WEBAPP_URL=https://drivers.smartsoltech.kr
PUBLIC_WEBAPP_URL=https://drivers.smartsoltech.kr
API_BASE_URL=http://api:8000
CORS_ORIGINS=https://drivers.smartsoltech.kr,https://t.me
INTERNAL_API_TOKEN=change-this-long-random-token
APP_ENV=production
ALLOW_DEV_AUTH=false
VAPID_PUBLIC_KEY=
```
`BOT_TOKEN`, `DATABASE_URL`, `WEBAPP_URL`, `API_BASE_URL`, `CORS_ORIGINS`, `INTERNAL_API_TOKEN` читаются только из env. Секреты не хранятся в коде.
## Nginx
Пример reverse proxy:
```nginx
server {
listen 80;
server_name drivers.smartsoltech.kr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name drivers.smartsoltech.kr;
ssl_certificate /etc/letsencrypt/live/drivers.smartsoltech.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/drivers.smartsoltech.kr/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
## Запуск ## Запуск
1. Создай `.env`:
```bash ```bash
cp .env.example .env cp .env.example .env
docker compose up -d --build
``` ```
2. Заполни `BOT_TOKEN` и `WEBAPP_URL`. Для Telegram mini app `WEBAPP_URL` должен быть HTTPS URL, доступный Telegram. API локально: `http://localhost:8000`.
3. Подними сервисы: Локальные проверки:
```bash ```bash
docker compose up --build python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -q
.venv/bin/ruff check app bot tests
docker compose build
docker compose up -d db api
curl http://127.0.0.1:8000/health
docker compose down
``` ```
API будет доступен на `http://localhost:8000`, документация - `http://localhost:8000/docs`. ## Авторизация API
## Локальный запуск без Docker Пользовательские endpoint-ы требуют Telegram WebApp `initData` в заголовке:
```bash ```http
python -m venv .venv X-Telegram-Init-Data: query_id=...&user=...&auth_date=...&hash=...
source .venv/bin/activate
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload
``` ```
В отдельном терминале: Backend проверяет подпись Telegram, создает/обновляет пользователя и разрешает операции только с объектами владельца. Бот использует `INTERNAL_API_TOKEN` и `X-Telegram-User-Id`.
```bash Публичное `/api/users` закрыто внутренним токеном. Для Mini App создание пользователя выполняется через `/api/users/webapp-auth`.
python -m bot.main
```
## API ## Основные endpoint-ы
Ключевые endpoint-ы: - `GET /api/users/me`
- `POST /api/cars`, `GET /api/cars`, `GET/PATCH/DELETE /api/cars/{id}`
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel?limit=50&offset=0`
- `PATCH /api/fuel/{id}`, `DELETE /api/fuel/{id}`
- `POST /api/service`, `GET /api/cars/{car_id}/service?limit=50&offset=0`
- `PATCH /api/service/{id}`, `DELETE /api/service/{id}`
- `GET /api/cars/{car_id}/stats`
- `GET /api/users/{user_id}/reminders?limit=50&offset=0`
- `POST /api/ocr/parse-text-receipt`
- `POST /api/users` - создать или обновить пользователя Telegram. Расход топлива считается по интервалам между полными баками (`is_full_tank=true`). Если данных мало, API возвращает `null`, а не выдуманную цифру.
- `POST /api/cars`, `GET /api/cars?owner_id=...` - автомобили.
- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel` - заправки.
- `POST /api/service`, `GET /api/cars/{car_id}/service` - сервисные записи.
- `GET /api/cars/{car_id}/stats` - стоимость владения, топливо, пробег, расход л/100 км, цена 1 км.
- `GET /api/cars/{car_id}/charts/expenses.png` - график расходов через pandas/matplotlib.
## Что дальше ## OCR
Практичные следующие шаги: авторизация WebApp через проверку `initData`, CRUD редактирование записей, напоминания по `next_due_date` и `next_due_odometer`, экспорт в CSV/XLSX, валюта и единицы измерения на уровне пользователя. Настоящий OCR по фото/PDF пока не подключен. Endpoint `POST /api/ocr/parse-text-receipt` честно разбирает только текстовый чек. Старый `/api/ocr/fuel-receipt` оставлен как deprecated-совместимость.

View File

@@ -0,0 +1,49 @@
"""security indexes
Revision ID: 202605120004
Revises: 202605120003
Create Date: 2026-05-12
"""
from collections.abc import Sequence
from alembic import op
revision: str = "202605120004"
down_revision: str | None = "202605120003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_index(
"ix_fuel_entries_car_id_entry_date",
"fuel_entries",
["car_id", "entry_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_entry_date",
"service_entries",
["car_id", "entry_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_next_due_date",
"service_entries",
["car_id", "next_due_date"],
unique=False,
)
op.create_index(
"ix_service_entries_car_id_next_due_odometer",
"service_entries",
["car_id", "next_due_odometer"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_service_entries_car_id_next_due_odometer", table_name="service_entries")
op.drop_index("ix_service_entries_car_id_next_due_date", table_name="service_entries")
op.drop_index("ix_service_entries_car_id_entry_date", table_name="service_entries")
op.drop_index("ix_fuel_entries_car_id_entry_date", table_name="fuel_entries")

View File

@@ -2,16 +2,23 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car from app.models.car import Car
from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate from app.schemas.car import CarCreate, CarRead, CarUpdate
router = APIRouter(prefix="/cars", tags=["cars"]) router = APIRouter(prefix="/cars", tags=["cars"])
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car: async def create_car(
car = Car(**payload.model_dump()) payload: CarCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
data = payload.model_dump(exclude={"owner_id"})
car = Car(**data, owner_id=current_user.id)
session.add(car) session.add(car)
await session.commit() await session.commit()
await session.refresh(car) await session.refresh(car)
@@ -19,28 +26,45 @@ async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_ses
@router.get("", response_model=list[CarRead]) @router.get("", response_model=list[CarRead])
async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]: async def list_cars(
owner_id: int | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[Car]:
if owner_id is not None and owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
result = await session.execute( result = await session.execute(
select(Car).where(Car.owner_id == owner_id).order_by(Car.created_at.desc()) select(Car).where(Car.owner_id == current_user.id).order_by(Car.created_at.desc())
) )
return list(result.scalars()) return list(result.scalars())
@router.get("/{car_id}", response_model=CarRead) @router.get("/{car_id}", response_model=CarRead)
async def get_car(car_id: int, session: AsyncSession = Depends(get_session)) -> Car: async def get_car(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car:
car = await session.get(Car, car_id) car = await session.get(Car, car_id)
if car is None: if car is None:
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car return car
@router.patch("/{car_id}", response_model=CarRead) @router.patch("/{car_id}", response_model=CarRead)
async def update_car( async def update_car(
car_id: int, payload: CarUpdate, session: AsyncSession = Depends(get_session) car_id: int,
payload: CarUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Car: ) -> Car:
car = await session.get(Car, car_id) car = await session.get(Car, car_id)
if car is None: if car is None:
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
for field, value in payload.model_dump(exclude_unset=True).items(): for field, value in payload.model_dump(exclude_unset=True).items():
setattr(car, field, value) setattr(car, field, value)
await session.commit() await session.commit()
@@ -49,9 +73,15 @@ async def update_car(
@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_car(car_id: int, session: AsyncSession = Depends(get_session)) -> None: async def delete_car(
car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
car = await session.get(Car, car_id) car = await session.get(Car, car_id)
if car is None: if car is None:
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await session.delete(car) await session.delete(car)
await session.commit() await session.commit()

94
app/api/deps.py Normal file
View File

@@ -0,0 +1,94 @@
from typing import Annotated
from fastapi import Depends, Header, HTTPException, 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.user import User
from app.services.telegram_auth import verify_webapp_init_data
async def get_or_create_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": str(telegram_id),
"first_name": first_name,
"last_name": last_name,
"locale": locale,
"currency": currency,
}
if user is None:
user = User(**{key: value for key, value in payload.items() if value is not None})
session.add(user)
else:
for field, value in payload.items():
if value is not None:
setattr(user, field, value)
await session.commit()
await session.refresh(user)
return user
def require_internal_api_token(token: str | None) -> None:
if not settings.internal_api_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Internal API token is not configured",
)
if not token or token != settings.internal_api_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
async def get_current_telegram_user(
session: Annotated[AsyncSession, Depends(get_session)],
x_telegram_init_data: Annotated[str | None, Header(alias="X-Telegram-Init-Data")] = None,
x_internal_api_token: Annotated[str | None, Header(alias="X-Internal-API-Token")] = None,
x_telegram_user_id: Annotated[int | None, Header(alias="X-Telegram-User-Id")] = None,
x_dev_telegram_id: Annotated[int | None, Header(alias="X-Dev-Telegram-Id")] = None,
) -> User:
if x_telegram_init_data:
user_data = verify_webapp_init_data(x_telegram_init_data, settings.bot_token)
return await get_or_create_telegram_user(
session,
telegram_id=int(user_data["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"),
)
if x_internal_api_token and x_telegram_user_id:
require_internal_api_token(x_internal_api_token)
return await get_or_create_telegram_user(session, telegram_id=x_telegram_user_id)
if settings.allow_dev_auth and not settings.is_production and x_dev_telegram_id:
return await get_or_create_telegram_user(session, telegram_id=x_dev_telegram_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Telegram initData required")
async def get_owned_car(
car_id: int,
current_user: Annotated[User, Depends(get_current_telegram_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> Car:
car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car

View File

@@ -1,41 +1,83 @@
from io import BytesIO
from datetime import date from datetime import date
from io import BytesIO
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car from app.models.car import Car
from app.models.expense import FuelEntry, ServiceEntry from app.models.expense import FuelEntry, ServiceEntry
from app.models.user import User
from app.schemas.expense import ( from app.schemas.expense import (
FuelEntryCreate, FuelEntryCreate,
FuelEntryRead, FuelEntryRead,
FuelEntryUpdate,
OdometerPrediction, OdometerPrediction,
OwnershipStats, OwnershipStats,
ServiceEntryCreate, ServiceEntryCreate,
ServiceEntryRead, ServiceEntryRead,
ServiceEntryUpdate,
) )
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
router = APIRouter(tags=["entries"]) router = APIRouter(tags=["entries"])
async def ensure_car(session: AsyncSession, car_id: int) -> None: async def ensure_owned_car(session: AsyncSession, car_id: int, user: User) -> Car:
if await session.get(Car, car_id) is None: car = await session.get(Car, car_id)
if car is None:
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if car.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return car
async def ensure_entry_owner(
session: AsyncSession, entry: FuelEntry | ServiceEntry | None, user: User
) -> FuelEntry | ServiceEntry:
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
await ensure_owned_car(session, entry.car_id, user)
return entry
async def refresh_current_odometer(session: AsyncSession, car_id: int) -> None:
car = await session.get(Car, car_id)
if car is None:
return
fuel_result = await session.execute(
select(FuelEntry.odometer)
.where(FuelEntry.car_id == car_id)
.order_by(FuelEntry.odometer.desc())
.limit(1)
)
service_result = await session.execute(
select(ServiceEntry.odometer)
.where(ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None))
.order_by(ServiceEntry.odometer.desc())
.limit(1)
)
values = [
value
for value in (fuel_result.scalar_one_or_none(), service_result.scalar_one_or_none())
if value is not None
]
car.current_odometer = max(values) if values else None
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED) @router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry( async def create_fuel_entry(
payload: FuelEntryCreate, session: AsyncSession = Depends(get_session) payload: FuelEntryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry: ) -> FuelEntry:
await ensure_car(session, payload.car_id) car = await ensure_owned_car(session, payload.car_id, current_user)
entry = FuelEntry(**payload.model_dump()) entry = FuelEntry(**payload.model_dump())
session.add(entry) session.add(entry)
car = await session.get(Car, payload.car_id) if car.current_odometer is None or payload.odometer > car.current_odometer:
if car and (car.current_odometer is None or payload.odometer > car.current_odometer):
car.current_odometer = payload.odometer car.current_odometer = payload.odometer
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
@@ -47,30 +89,69 @@ async def list_fuel_entries(
car_id: int, car_id: int,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[FuelEntry]: ) -> list[FuelEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(FuelEntry).where(FuelEntry.car_id == car_id) stmt = select(FuelEntry).where(FuelEntry.car_id == car_id)
if date_from: if date_from:
stmt = stmt.where(FuelEntry.entry_date >= date_from) stmt = stmt.where(FuelEntry.entry_date >= date_from)
if date_to: if date_to:
stmt = stmt.where(FuelEntry.entry_date <= date_to) stmt = stmt.where(FuelEntry.entry_date <= date_to)
result = await session.execute( result = await session.execute(
stmt.order_by(FuelEntry.entry_date.desc()) stmt.order_by(FuelEntry.entry_date.desc()).limit(limit).offset(offset)
) )
return list(result.scalars()) return list(result.scalars())
@router.patch("/fuel/{entry_id}", response_model=FuelEntryRead)
async def update_fuel_entry(
entry_id: int,
payload: FuelEntryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> FuelEntry:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
if payload.total_cost is None and (
payload.liters is not None or payload.price_per_liter is not None
):
entry.total_cost = entry.liters * entry.price_per_liter
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/fuel/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fuel_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(FuelEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED) @router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
async def create_service_entry( async def create_service_entry(
payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session) payload: ServiceEntryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry: ) -> ServiceEntry:
await ensure_car(session, payload.car_id) car = await ensure_owned_car(session, payload.car_id, current_user)
entry = ServiceEntry(**payload.model_dump()) entry = ServiceEntry(**payload.model_dump())
session.add(entry) session.add(entry)
car = await session.get(Car, payload.car_id) if payload.odometer and (car.current_odometer is None or payload.odometer > car.current_odometer):
if car and payload.odometer and (
car.current_odometer is None or payload.odometer > car.current_odometer
):
car.current_odometer = payload.odometer car.current_odometer = payload.odometer
await session.commit() await session.commit()
await session.refresh(entry) await session.refresh(entry)
@@ -82,27 +163,64 @@ async def list_service_entries(
car_id: int, car_id: int,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceEntry]: ) -> list[ServiceEntry]:
await ensure_owned_car(session, car_id, current_user)
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id) stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id)
if date_from: if date_from:
stmt = stmt.where(ServiceEntry.entry_date >= date_from) stmt = stmt.where(ServiceEntry.entry_date >= date_from)
if date_to: if date_to:
stmt = stmt.where(ServiceEntry.entry_date <= date_to) stmt = stmt.where(ServiceEntry.entry_date <= date_to)
result = await session.execute( result = await session.execute(
stmt.order_by(ServiceEntry.entry_date.desc()) stmt.order_by(ServiceEntry.entry_date.desc()).limit(limit).offset(offset)
) )
return list(result.scalars()) return list(result.scalars())
@router.patch("/service/{entry_id}", response_model=ServiceEntryRead)
async def update_service_entry(
entry_id: int,
payload: ServiceEntryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEntry:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
await refresh_current_odometer(session, entry.car_id)
await session.commit()
await session.refresh(entry)
return entry
@router.delete("/service/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_service_entry(
entry_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
entry = await ensure_entry_owner(session, await session.get(ServiceEntry, entry_id), current_user)
car_id = entry.car_id
await session.delete(entry)
await session.flush()
await refresh_current_odometer(session, car_id)
await session.commit()
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats) @router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
async def car_stats( async def car_stats(
car_id: int, car_id: int,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> OwnershipStats: ) -> OwnershipStats:
await ensure_car(session, car_id) await ensure_owned_car(session, car_id, current_user)
today = date.today() today = date.today()
period_from = date_from or today.replace(day=1) period_from = date_from or today.replace(day=1)
period_to = date_to or today period_to = date_to or today
@@ -110,14 +228,22 @@ async def car_stats(
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction) @router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction: async def car_analytics(
await ensure_car(session, car_id) car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> OdometerPrediction:
await ensure_owned_car(session, car_id, current_user)
return await predict_odometer(session, car_id) return await predict_odometer(session, car_id)
@router.get("/cars/{car_id}/charts/expenses.png") @router.get("/cars/{car_id}/charts/expenses.png")
async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response: async def expenses_chart(
await ensure_car(session, car_id) car_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> Response:
await ensure_owned_car(session, car_id, current_user)
fuel_df = await dataframe_from_query( fuel_df = await dataframe_from_query(
session, session,
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where( select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(

View File

@@ -1,9 +1,12 @@
import re import re
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, Depends, File, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from app.api.deps import get_current_telegram_user
from app.models.user import User
router = APIRouter(prefix="/ocr", tags=["ocr"]) router = APIRouter(prefix="/ocr", tags=["ocr"])
@@ -16,9 +19,18 @@ class ReceiptSuggestion(BaseModel):
message: str message: str
@router.post("/fuel-receipt", response_model=ReceiptSuggestion) @router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion: async def parse_text_receipt(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> ReceiptSuggestion:
content = await file.read() content = await file.read()
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
return ReceiptSuggestion(
confidence=0,
message="OCR по фото/PDF пока не подключен. Загрузите текстовый чек или заполните поля вручную.",
)
text = " ".join( text = " ".join(
[ [
file.filename or "", file.filename or "",
@@ -54,13 +66,21 @@ async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
station=station, station=station,
confidence=round(confidence, 2) if numbers else 0, confidence=round(confidence, 2) if numbers else 0,
message=( message=(
"Распознал данные чека и заполнил форму. Проверь значения перед сохранением." "Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
if numbers if numbers
else "Не удалось прочитать данные чека. Попробуй фото крупнее или заполни поля вручную." else "Не удалось разобрать текст чека. Загрузите текстовый чек или заполните поля вручную."
), ),
) )
@router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True)
async def scan_fuel_receipt(
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
) -> ReceiptSuggestion:
return await parse_text_receipt(file, current_user)
def detect_station(text: str) -> str | None: def detect_station(text: str) -> str | None:
stations = { stations = {
"shell": "Shell", "shell": "Shell",

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_internal_api_token
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
from app.schemas.service_center import ( from app.schemas.service_center import (
@@ -18,8 +19,11 @@ router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center( async def create_service_center(
payload: ServiceCenterCreate, session: AsyncSession = Depends(get_session) payload: ServiceCenterCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> ServiceCenter: ) -> ServiceCenter:
require_internal_api_token(x_internal_api_token)
center = ServiceCenter(**payload.model_dump()) center = ServiceCenter(**payload.model_dump())
session.add(center) session.add(center)
await session.commit() await session.commit()
@@ -28,15 +32,22 @@ async def create_service_center(
@router.get("", response_model=list[ServiceCenterRead]) @router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(session: AsyncSession = Depends(get_session)) -> list[ServiceCenter]: async def list_service_centers(
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> list[ServiceCenter]:
require_internal_api_token(x_internal_api_token)
result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name)) result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name))
return list(result.scalars()) return list(result.scalars())
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED) @router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service( async def link_car_to_service(
payload: CarServiceLinkCreate, session: AsyncSession = Depends(get_session) payload: CarServiceLinkCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> CarServiceLink: ) -> CarServiceLink:
require_internal_api_token(x_internal_api_token)
if await session.get(Car, payload.car_id) is None: if await session.get(Car, payload.car_id) is None:
raise HTTPException(status_code=404, detail="Car not found") raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None: if await session.get(ServiceCenter, payload.service_center_id) is None:
@@ -50,8 +61,11 @@ async def link_car_to_service(
@router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED) @router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED)
async def receive_service_message( async def receive_service_message(
payload: ServiceInboxCreate, session: AsyncSession = Depends(get_session) payload: ServiceInboxCreate,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> ServiceInboxMessage: ) -> ServiceInboxMessage:
require_internal_api_token(x_internal_api_token)
service_center_id = payload.service_center_id service_center_id = payload.service_center_id
if not service_center_id and payload.source_chat_id: if not service_center_id and payload.source_chat_id:
result = await session.execute( result = await session.execute(

View File

@@ -1,9 +1,14 @@
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, Header, 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.api.deps import (
get_current_telegram_user,
get_or_create_telegram_user,
require_internal_api_token,
)
from app.core.config import settings 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.car import Car
@@ -29,41 +34,14 @@ def username_from_telegram(telegram_id: int, username: str | None = None) -> str
return str(telegram_id) if not username else str(telegram_id) 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(**{key: value for key, value in payload.items() if value is not None})
session.add(user)
else:
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) @router.post("", response_model=UserRead)
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User: async def upsert_user(
return await upsert_telegram_user(session, **payload.model_dump()) payload: UserUpsert,
session: AsyncSession = Depends(get_session),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> User:
require_internal_api_token(x_internal_api_token)
return await get_or_create_telegram_user(session, **payload.model_dump())
@router.get("/auth/config", response_model=AuthConfig) @router.get("/auth/config", response_model=AuthConfig)
@@ -71,6 +49,8 @@ async def auth_config() -> AuthConfig:
return AuthConfig( return AuthConfig(
bot_username=settings.bot_username or "seoulmate_officialbot", bot_username=settings.bot_username or "seoulmate_officialbot",
vapid_public_key=settings.vapid_public_key or None, vapid_public_key=settings.vapid_public_key or None,
app_env=settings.app_env,
allow_dev_auth=settings.allow_dev_auth and not settings.is_production,
) )
@@ -80,7 +60,7 @@ async def webapp_auth(
) -> User: ) -> User:
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token) user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
telegram_id = int(user_data["id"]) telegram_id = int(user_data["id"])
return await upsert_telegram_user( return await get_or_create_telegram_user(
session, session,
telegram_id=telegram_id, telegram_id=telegram_id,
username=user_data.get("username"), username=user_data.get("username"),
@@ -96,7 +76,7 @@ async def telegram_login(
) -> User: ) -> User:
values = verify_login_widget(payload.model_dump(), settings.bot_token) values = verify_login_widget(payload.model_dump(), settings.bot_token)
telegram_id = int(values["id"]) telegram_id = int(values["id"])
return await upsert_telegram_user( return await get_or_create_telegram_user(
session, session,
telegram_id=telegram_id, telegram_id=telegram_id,
username=values.get("username"), username=values.get("username"),
@@ -105,21 +85,35 @@ async def telegram_login(
) )
@router.get("/me", response_model=UserRead)
async def current_user_profile(current_user: User = Depends(get_current_telegram_user)) -> User:
return current_user
@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),
x_internal_api_token: str | None = Header(default=None, alias="X-Internal-API-Token"),
) -> User: ) -> User:
require_internal_api_token(x_internal_api_token)
result = await session.execute(select(User).where(User.telegram_id == telegram_id)) result = await session.execute(select(User).where(User.telegram_id == telegram_id))
return result.scalar_one() user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}/preferences", response_model=UserRead) @router.patch("/{user_id}/preferences", response_model=UserRead)
async def update_preferences( async def update_preferences(
user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session) user_id: int,
payload: UserPreferencesUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> User: ) -> User:
user = await session.get(User, user_id) if current_user.id != user_id:
if user is None: raise HTTPException(status_code=403, detail="Forbidden")
raise HTTPException(status_code=404, detail="User not found") user = current_user
for field, value in payload.model_dump(exclude_none=True).items(): for field, value in payload.model_dump(exclude_none=True).items():
setattr(user, field, value) setattr(user, field, value)
await session.commit() await session.commit()
@@ -133,10 +127,10 @@ async def save_push_subscription(
payload: PushSubscriptionCreate, payload: PushSubscriptionCreate,
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None: ) -> None:
user = await session.get(User, user_id) if current_user.id != user_id:
if user is None: raise HTTPException(status_code=403, detail="Forbidden")
raise HTTPException(status_code=404, detail="User not found")
result = await session.execute( result = await session.execute(
select(PushSubscription).where( select(PushSubscription).where(
PushSubscription.user_id == user_id, PushSubscription.user_id == user_id,
@@ -166,8 +160,15 @@ async def save_push_subscription(
async def due_reminders( async def due_reminders(
user_id: int, user_id: int,
days: int = 30, days: int = 30,
limit: int = 50,
offset: int = 0,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ReminderRead]: ) -> list[ReminderRead]:
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Forbidden")
limit = min(max(limit, 1), 200)
offset = max(offset, 0)
today = date.today() today = date.today()
horizon = today + timedelta(days=max(1, min(days, 180))) horizon = today + timedelta(days=max(1, min(days, 180)))
stmt = ( stmt = (
@@ -183,6 +184,8 @@ async def due_reminders(
) )
) )
.order_by(ServiceEntry.next_due_date.asc().nulls_last()) .order_by(ServiceEntry.next_due_date.asc().nulls_last())
.limit(limit)
.offset(offset)
) )
rows = (await session.execute(stmt)).all() rows = (await session.execute(stmt)).all()
reminders: list[ReminderRead] = [] reminders: list[ReminderRead] = []

View File

@@ -9,13 +9,37 @@ class Settings(BaseSettings):
bot_username: 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"
public_webapp_url: str | None = None
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" app_env: str = "production"
cors_origins: str = ""
internal_api_token: str = ""
vapid_public_key: str = "" vapid_public_key: str = ""
allow_dev_auth: bool = False
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")
@property
def cors_origin_list(self) -> list[str]:
return [item.strip().rstrip("/") for item in self.cors_origins.split(",") if item.strip()]
@property
def effective_webapp_url(self) -> str:
return (self.public_webapp_url or self.webapp_url).rstrip("/")
@property
def is_production(self) -> bool:
return self.app_env.lower() == "production"
def validate_webapp_url_for_telegram(self) -> None:
url = self.effective_webapp_url
if self.is_production and not url.startswith("https://"):
raise RuntimeError("WEBAPP_URL/PUBLIC_WEBAPP_URL must be public HTTPS in production")
forbidden = ("localhost", "127.0.0.1", "0.0.0.0", "http://")
if self.is_production and any(item in url for item in forbidden):
raise RuntimeError("Telegram Mini App URL must not use localhost, internal IP, or http://")
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -1,5 +1,5 @@
import asyncio
import argparse import argparse
import asyncio
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
@@ -13,7 +13,6 @@ from app.models.expense import FuelEntry, ServiceEntry, ServiceType
from app.models.user import User from app.models.user import User
from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES
MOCK_PLATE_PREFIX = "MOCK" MOCK_PLATE_PREFIX = "MOCK"
MOCK_CARS = [ MOCK_CARS = [

View File

@@ -3,12 +3,16 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api import cars, catalog, entries, ocr, service_centers, users from app.api import cars, catalog, entries, ocr, service_centers, users
from app.core.config import settings
app = FastAPI(title="Drivers Bot API", version="0.1.0") app = FastAPI(title="Drivers Bot API", version="0.1.0")
dev_origins = ["http://localhost:8000", "http://127.0.0.1:8000"] if not settings.is_production else []
cors_origins = settings.cors_origin_list or dev_origins
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -1,7 +1,17 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base

View File

@@ -29,7 +29,7 @@ class CarBase(BaseModel):
class CarCreate(CarBase): class CarCreate(CarBase):
owner_id: int owner_id: int | None = None
class CarUpdate(BaseModel): class CarUpdate(BaseModel):

View File

@@ -28,6 +28,18 @@ class FuelEntryCreate(FuelEntryBase):
car_id: int car_id: int
class FuelEntryUpdate(BaseModel):
entry_date: date | None = None
odometer: int | None = None
liters: Decimal | None = None
price_per_liter: Decimal | None = None
total_cost: Decimal | None = None
station: str | None = None
fuel_brand: str | None = None
is_full_tank: bool | None = None
notes: str | None = None
class FuelEntryRead(FuelEntryBase): class FuelEntryRead(FuelEntryBase):
id: int id: int
car_id: int car_id: int
@@ -54,6 +66,19 @@ class ServiceEntryCreate(ServiceEntryBase):
car_id: int car_id: int
class ServiceEntryUpdate(BaseModel):
entry_date: date | None = None
odometer: int | None = None
service_type: ServiceType | None = None
title: str | None = None
category: str | None = None
vendor: str | None = None
total_cost: Decimal | None = None
next_due_date: date | None = None
next_due_odometer: int | None = None
notes: str | None = None
class ServiceEntryRead(ServiceEntryBase): class ServiceEntryRead(ServiceEntryBase):
id: int id: int
car_id: int car_id: int

View File

@@ -29,6 +29,8 @@ class TelegramLoginRequest(BaseModel):
class AuthConfig(BaseModel): class AuthConfig(BaseModel):
bot_username: str bot_username: str
vapid_public_key: str | None = None vapid_public_key: str | None = None
app_env: str
allow_dev_auth: bool = False
class PushSubscriptionKeys(BaseModel): class PushSubscriptionKeys(BaseModel):

View File

@@ -38,7 +38,7 @@ async def get_ownership_stats(
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0 distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
total_cost = Decimal(fuel_cost) + Decimal(service_cost) total_cost = Decimal(fuel_cost) + Decimal(service_cost)
avg_consumption = float(Decimal(liters) * Decimal(100) / distance_km) if distance_km else None avg_consumption = await full_tank_consumption(session, car_id, date_from, date_to)
cost_per_km = float(total_cost / distance_km) if distance_km else None cost_per_km = float(total_cost / distance_km) if distance_km else None
return OwnershipStats( return OwnershipStats(
@@ -57,6 +57,48 @@ async def get_ownership_stats(
) )
async def full_tank_consumption(
session: AsyncSession, car_id: int, date_from: date, date_to: date
) -> float | None:
result = await session.execute(
select(FuelEntry)
.where(
FuelEntry.car_id == car_id,
FuelEntry.entry_date <= date_to,
)
.order_by(FuelEntry.entry_date.asc(), FuelEntry.odometer.asc(), FuelEntry.id.asc())
)
entries = list(result.scalars())
full_indexes = [index for index, entry in enumerate(entries) if entry.is_full_tank]
if len(full_indexes) < 2:
return None
total_liters = Decimal("0")
total_distance = 0
previous_full_index = full_indexes[0]
for current_full_index in full_indexes[1:]:
previous = entries[previous_full_index]
current = entries[current_full_index]
if current.entry_date < date_from:
previous_full_index = current_full_index
continue
distance = current.odometer - previous.odometer
if distance <= 0:
previous_full_index = current_full_index
continue
interval_liters = sum(
Decimal(entry.liters) for entry in entries[previous_full_index + 1 : current_full_index + 1]
)
if interval_liters > 0:
total_liters += interval_liters
total_distance += distance
previous_full_index = current_full_index
if total_distance <= 0 or total_liters <= 0:
return None
return float(total_liters * Decimal(100) / Decimal(total_distance))
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame: async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
result = await session.execute(stmt) result = await session.execute(stmt)
rows = result.mappings().all() rows = result.mappings().all()

View File

@@ -14,6 +14,8 @@ def _secret_key(bot_token: str, *, webapp: bool) -> bytes:
def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict: def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict:
if not bot_token:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="BOT_TOKEN is not configured")
values = dict(parse_qsl(init_data, keep_blank_values=True)) values = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = values.pop("hash", "") received_hash = values.pop("hash", "")
if not received_hash: if not received_hash:
@@ -34,6 +36,8 @@ def verify_webapp_init_data(init_data: str, bot_token: str, max_age_seconds: int
def verify_login_widget(payload: dict, bot_token: str, max_age_seconds: int = 86400) -> dict: def verify_login_widget(payload: dict, bot_token: str, max_age_seconds: int = 86400) -> dict:
if not bot_token:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="BOT_TOKEN is not configured")
values = {key: value for key, value in payload.items() if value is not None} values = {key: value for key, value in payload.items() if value is not None}
received_hash = str(values.pop("hash", "")) received_hash = str(values.pop("hash", ""))
if not received_hash: if not received_hash:

View File

@@ -9,6 +9,12 @@ class ApiClient:
def __init__(self) -> None: def __init__(self) -> None:
self.base_url = settings.api_base_url.rstrip("/") self.base_url = settings.api_base_url.rstrip("/")
def headers(self, telegram_id: int | None = None) -> dict[str, str]:
headers = {"X-Internal-API-Token": settings.internal_api_token}
if telegram_id is not None:
headers["X-Telegram-User-Id"] = str(telegram_id)
return headers
async def upsert_user(self, telegram_user: Any) -> dict[str, Any]: async def upsert_user(self, telegram_user: Any) -> dict[str, Any]:
payload = { payload = {
"telegram_id": telegram_user.id, "telegram_id": telegram_user.id,
@@ -17,24 +23,30 @@ class ApiClient:
"last_name": telegram_user.last_name, "last_name": telegram_user.last_name,
} }
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/users", json=payload) response = await client.post("/api/users", json=payload, headers=self.headers())
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def list_cars(self, owner_id: int) -> list[dict[str, Any]]: async def list_cars(self, owner_id: int, telegram_id: int) -> list[dict[str, Any]]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get("/api/cars", params={"owner_id": owner_id}) response = await client.get(
"/api/cars", params={"owner_id": owner_id}, headers=self.headers(telegram_id)
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def create_car(self, owner_id: int, name: str) -> dict[str, Any]: async def create_car(self, owner_id: int, name: str, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.post("/api/cars", json={"owner_id": owner_id, "name": name}) response = await client.post(
"/api/cars",
json={"owner_id": owner_id, "name": name},
headers=self.headers(telegram_id),
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def stats(self, car_id: int) -> dict[str, Any]: async def stats(self, car_id: int, telegram_id: int) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client:
response = await client.get(f"/api/cars/{car_id}/stats") response = await client.get(f"/api/cars/{car_id}/stats", headers=self.headers(telegram_id))
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()

View File

@@ -25,7 +25,7 @@ api = ApiClient()
def main_keyboard() -> ReplyKeyboardMarkup: def main_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.webapp_url))], [KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))],
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")], [KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
], ],
resize_keyboard=True, resize_keyboard=True,
@@ -50,7 +50,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
if not name: if not name:
await message.answer("Напиши так: /add_car Toyota Camry") await message.answer("Напиши так: /add_car Toyota Camry")
return return
car = await api.create_car(user["id"], name) car = await api.create_car(user["id"], name, message.from_user.id)
await message.answer(f"Добавил авто: {car['name']}") await message.answer(f"Добавил авто: {car['name']}")
@@ -58,7 +58,7 @@ async def add_car(message: Message, command: CommandObject) -> None:
@dp.message(F.text == "Мои авто") @dp.message(F.text == "Мои авто")
async def cars(message: Message) -> None: async def cars(message: Message) -> None:
user = await api.upsert_user(message.from_user) user = await api.upsert_user(message.from_user)
items = await api.list_cars(user["id"]) items = await api.list_cars(user["id"], message.from_user.id)
if not items: if not items:
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.") await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
return return
@@ -72,7 +72,7 @@ async def cars(message: Message) -> None:
@dp.callback_query(F.data.startswith("stats:")) @dp.callback_query(F.data.startswith("stats:"))
async def show_stats(callback: CallbackQuery) -> None: async def show_stats(callback: CallbackQuery) -> None:
car_id = int(callback.data.split(":", 1)[1]) car_id = int(callback.data.split(":", 1)[1])
stats = await api.stats(car_id) stats = await api.stats(car_id, callback.from_user.id)
consumption = stats["avg_consumption_l_per_100km"] consumption = stats["avg_consumption_l_per_100km"]
cost_per_km = stats["cost_per_km"] cost_per_km = stats["cost_per_km"]
await callback.message.answer( await callback.message.answer(
@@ -106,6 +106,9 @@ async def help_message(message: Message) -> None:
async def main() -> None: async def main() -> None:
if not settings.bot_token: if not settings.bot_token:
raise RuntimeError("BOT_TOKEN is empty") raise RuntimeError("BOT_TOKEN is empty")
if not settings.internal_api_token:
raise RuntimeError("INTERNAL_API_TOKEN is empty")
settings.validate_webapp_url_for_telegram()
bot = Bot(settings.bot_token) bot = Bot(settings.bot_token)
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@@ -24,8 +24,14 @@ services:
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers} DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers}
BOT_TOKEN: ${BOT_TOKEN:-} BOT_TOKEN: ${BOT_TOKEN:-}
BOT_USERNAME: ${BOT_USERNAME:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000} API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000} WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
ALLOW_DEV_AUTH: ${ALLOW_DEV_AUTH:-false}
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:8000:8000"
depends_on: depends_on:
@@ -40,8 +46,12 @@ services:
required: false required: false
environment: environment:
BOT_TOKEN: ${BOT_TOKEN:-} BOT_TOKEN: ${BOT_TOKEN:-}
BOT_USERNAME: ${BOT_USERNAME:-}
API_BASE_URL: ${API_BASE_URL:-http://api:8000} API_BASE_URL: ${API_BASE_URL:-http://api:8000}
WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000} WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000}
PUBLIC_WEBAPP_URL: ${PUBLIC_WEBAPP_URL:-}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
APP_ENV: ${APP_ENV:-development}
depends_on: depends_on:
- api - api

View File

@@ -26,6 +26,9 @@ include = ["app*", "bot*"]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"aiosqlite>=0.20,<1.0",
"pytest>=8.0,<9.0",
"pytest-asyncio>=0.23,<1.0",
"ruff>=0.4,<1.0", "ruff>=0.4,<1.0",
] ]
@@ -35,3 +38,7 @@ target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B"]
ignore = ["B008", "E501", "UP042"]
[tool.pytest.ini_options]
asyncio_mode = "auto"

71
tests/conftest.py Normal file
View File

@@ -0,0 +1,71 @@
import hashlib
import hmac
import json
import time
from collections.abc import AsyncGenerator
from urllib.parse import urlencode
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api.deps import get_current_telegram_user
from app.core.config import settings
from app.db.base import Base
from app.db.session import get_session
from app.main import app
from app.models import car, expense, push, user # noqa: F401
TEST_BOT_TOKEN = "123456:test-token"
TEST_INTERNAL_TOKEN = "internal-test-token"
def make_init_data(telegram_id: int, first_name: str = "Test") -> str:
user_payload = json.dumps(
{"id": telegram_id, "first_name": first_name, "username": str(telegram_id)},
separators=(",", ":"),
)
values = {"auth_date": str(int(time.time())), "user": user_payload}
data_check_string = "\n".join(f"{key}={values[key]}" for key in sorted(values))
secret = hmac.new(b"WebAppData", TEST_BOT_TOKEN.encode(), hashlib.sha256).digest()
values["hash"] = hmac.new(secret, data_check_string.encode(), hashlib.sha256).hexdigest()
return urlencode(values)
@pytest.fixture(autouse=True)
def configure_settings() -> None:
settings.bot_token = TEST_BOT_TOKEN
settings.internal_api_token = TEST_INTERNAL_TOKEN
settings.app_env = "test"
settings.allow_dev_auth = False
yield
@pytest.fixture()
async def client() -> AsyncGenerator[AsyncClient, None]:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def override_session() -> AsyncGenerator:
async with session_factory() as session:
yield session
app.dependency_overrides[get_session] = override_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as test_client:
yield test_client
app.dependency_overrides.pop(get_session, None)
app.dependency_overrides.pop(get_current_telegram_user, None)
await engine.dispose()
@pytest.fixture()
def auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(1001)}
@pytest.fixture()
def other_auth_headers() -> dict[str, str]:
return {"X-Telegram-Init-Data": make_init_data(2002)}

30
tests/test_auth.py Normal file
View File

@@ -0,0 +1,30 @@
import pytest
from conftest import TEST_BOT_TOKEN, make_init_data
from app.core.config import Settings
from app.services.telegram_auth import verify_webapp_init_data
def test_telegram_init_data_auth() -> None:
values = verify_webapp_init_data(make_init_data(42), TEST_BOT_TOKEN)
assert values["id"] == 42
def test_cors_config_reads_csv() -> None:
settings = Settings(
bot_token="token",
cors_origins="https://drivers.smartsoltech.kr,https://t.me",
)
assert settings.cors_origin_list == ["https://drivers.smartsoltech.kr", "https://t.me"]
@pytest.mark.asyncio
async def test_user_cannot_get_foreign_car(client, auth_headers, other_auth_headers) -> None:
created = await client.post("/api/cars", headers=auth_headers, json={"name": "Owner car"})
car_id = created.json()["id"]
response = await client.get(f"/api/cars/{car_id}", headers=other_auth_headers)
assert response.status_code == 403

89
tests/test_entries.py Normal file
View File

@@ -0,0 +1,89 @@
import pytest
@pytest.mark.asyncio
async def test_user_cannot_add_fuel_to_foreign_car(client, auth_headers, other_auth_headers) -> None:
created = await client.post("/api/cars", headers=auth_headers, json={"name": "Owner car"})
car_id = created.json()["id"]
response = await client.post(
"/api/fuel",
headers=other_auth_headers,
json={
"car_id": car_id,
"entry_date": "2026-05-12",
"odometer": 1000,
"liters": 30,
"price_per_liter": 2,
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_fuel_crud(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Fuel car"})).json()
created = await client.post(
"/api/fuel",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-05-12",
"odometer": 1000,
"liters": 30,
"price_per_liter": 2,
},
)
assert created.status_code == 201
entry_id = created.json()["id"]
patched = await client.patch(
f"/api/fuel/{entry_id}",
headers=auth_headers,
json={"liters": 35, "price_per_liter": 3},
)
assert patched.status_code == 200
assert patched.json()["total_cost"] == "105.00"
deleted = await client.delete(f"/api/fuel/{entry_id}", headers=auth_headers)
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_service_crud(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Service car"})).json()
created = await client.post(
"/api/service",
headers=auth_headers,
json={
"car_id": car["id"],
"entry_date": "2026-05-12",
"service_type": "maintenance",
"title": "Oil",
"total_cost": 100,
},
)
assert created.status_code == 201
entry_id = created.json()["id"]
patched = await client.patch(
f"/api/service/{entry_id}",
headers=auth_headers,
json={"title": "Oil and filter", "next_due_odometer": 2000},
)
assert patched.status_code == 200
assert patched.json()["title"] == "Oil and filter"
deleted = await client.delete(f"/api/service/{entry_id}", headers=auth_headers)
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_stats_do_not_fail_with_insufficient_data(client, auth_headers) -> None:
car = (await client.post("/api/cars", headers=auth_headers, json={"name": "Stats car"})).json()
response = await client.get(f"/api/cars/{car['id']}/stats", headers=auth_headers)
assert response.status_code == 200
assert response.json()["avg_consumption_l_per_100km"] is None

View File

@@ -16,9 +16,9 @@
<div class="auth-panel"> <div class="auth-panel">
<p class="eyebrow">Drivers</p> <p class="eyebrow">Drivers</p>
<h1>Гараж</h1> <h1>Гараж</h1>
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p> <p id="authMessage">Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.</p>
<div id="telegramLoginSlot" class="telegram-login-slot"></div> <div id="telegramLoginSlot" class="telegram-login-slot"></div>
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Войти через Telegram</a> <a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Открыть бота</a>
</div> </div>
</div> </div>
<main class="shell"> <main class="shell">
@@ -102,8 +102,8 @@
<strong>ТО / ремонт</strong> <strong>ТО / ремонт</strong>
</button> </button>
<button class="action-card" data-action="scan"> <button class="action-card" data-action="scan">
<span>Скан чека</span> <span>Разбор чека</span>
<strong>OCR</strong> <strong>Текст</strong>
</button> </button>
</section> </section>
@@ -220,7 +220,7 @@
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button> <button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button> <button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
<button class="menu-row" id="openNotificationsBtn">Уведомления</button> <button class="menu-row" id="openNotificationsBtn">Уведомления</button>
<button class="menu-row" id="openScanBtn">Сканировать чек</button> <button class="menu-row" id="openScanBtn">Разобрать чек</button>
<section class="drawer-section hidden" id="settingsSection"> <section class="drawer-section hidden" id="settingsSection">
<h2>Настройки</h2> <h2>Настройки</h2>
@@ -357,7 +357,7 @@
<div class="scan-modal hidden" id="scanModal"> <div class="scan-modal hidden" id="scanModal">
<div class="scan-panel"> <div class="scan-panel">
<div class="section-head"> <div class="section-head">
<h2>Скан чека</h2> <h2>Разбор чека</h2>
<button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button> <button class="icon-btn" id="closeScanBtn" aria-label="Закрыть">×</button>
</div> </div>
<form id="ocrForm" class="scan-form"> <form id="ocrForm" class="scan-form">
@@ -368,9 +368,9 @@
<input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" /> <input id="receiptCameraInput" class="hidden-file" type="file" accept="image/*" capture="environment" />
<input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" /> <input id="receiptFileInput" class="hidden-file" type="file" accept="image/*,.pdf,.txt" />
<div id="receiptFileName" class="file-hint">Файл не выбран</div> <div id="receiptFileName" class="file-hint">Файл не выбран</div>
<button type="submit">Распознать</button> <button type="submit">Разобрать текст</button>
</form> </form>
<div id="ocrResult" class="tip-card">После распознавания поля заправки заполнятся автоматически.</div> <div id="ocrResult" class="tip-card">Сейчас разбираем текстовые чеки. OCR по фото будет подключен отдельно.</div>
</div> </div>
</div> </div>

View File

@@ -408,8 +408,13 @@ function formData(form) {
} }
async function api(path, options = {}) { async function api(path, options = {}) {
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
const response = await fetch(`/api${path}`, { const response = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) }, headers,
...options, ...options,
}); });
if (!response.ok) { if (!response.ok) {
@@ -497,13 +502,14 @@ async function ensureUser() {
hideAuthOverlay(); hideAuthOverlay();
return; return;
} }
const stored = localStorage.getItem("driversUser"); if (state.authConfig?.allow_dev_auth) {
if (stored) { const devId = localStorage.getItem("driversDevTelegramId") || "1";
state.user = JSON.parse(stored); localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
hideAuthOverlay(); hideAuthOverlay();
return; return;
} }
await showTelegramLogin(); showTelegramOpenHint();
throw new Error("Требуется вход через Telegram"); throw new Error("Требуется вход через Telegram");
} }
@@ -512,22 +518,33 @@ function hideAuthOverlay() {
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
} }
async function showTelegramLogin() { function showTelegramOpenHint() {
const overlay = document.querySelector("#authOverlay"); const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot"); const slot = document.querySelector("#telegramLoginSlot");
const link = document.querySelector("#telegramLoginLink"); const link = document.querySelector("#telegramLoginLink");
const message = document.querySelector("#authMessage");
overlay?.classList.remove("hidden"); overlay?.classList.remove("hidden");
document.body.classList.add("auth-required"); document.body.classList.add("auth-required");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username; const botUsername = state.authConfig?.bot_username;
if (message) {
message.textContent = "Это приложение открывается через Telegram-бота. Откройте Mini App из Telegram.";
}
if (slot) slot.textContent = "";
if (!botUsername) { if (!botUsername) {
slot.textContent = "Telegram Login временно недоступен";
return; return;
} }
if (link) { if (link) {
link.href = `https://t.me/${botUsername}?start=web_login`; link.href = `https://t.me/${botUsername}`;
link.classList.remove("hidden"); link.classList.remove("hidden");
} }
}
async function showTelegramLogin() {
showTelegramOpenHint();
const slot = document.querySelector("#telegramLoginSlot");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (!botUsername) return;
window.onTelegramAuth = async (user) => { window.onTelegramAuth = async (user) => {
state.user = await api("/users/telegram-login", { state.user = await api("/users/telegram-login", {
method: "POST", method: "POST",
@@ -1424,7 +1441,11 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
await runAction(formButton, "Распознаю чек...", async () => { await runAction(formButton, "Распознаю чек...", async () => {
const payload = new FormData(); const payload = new FormData();
payload.append("file", file); payload.append("file", file);
const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload }); const response = await fetch("/api/ocr/parse-text-receipt", {
method: "POST",
headers: tg?.initData ? { "X-Telegram-Init-Data": tg.initData } : {},
body: payload,
});
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
const result = await response.json(); const result = await response.json();
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`; document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;