diff --git a/README.md b/README.md index f476c00..8d5a078 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ Caddy автоматически получает TLS-сертификат. WebA - `GET /api/healthz` — проверка состояния; - `GET /api/leaderboard?limit=100` — общий рейтинг; - `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей; +- `GET /api/leaderboard?category=Продукты` — рейтинг по категории; +- `GET /api/categories` — категории, встречающиеся у участников; - `GET /api/users/{id}/summary` — публичная сводка пользователя; +- `GET /api/users/{id}/categories` — сохранённые категории пользователя; +- `GET /api/users/{id}/transactions` — активные записи пользователя; - `GET /api/users/{id}/friends` — Telegram ID друзей; - `GET /api/avatars/{id}` — сохранённый аватар; - `GET /api/docs` — OpenAPI UI. diff --git a/app/bot.py b/app/bot.py index debc983..58e2eb7 100644 --- a/app/bot.py +++ b/app/bot.py @@ -23,11 +23,25 @@ def webapp_url_for_user(settings: Settings, user_id: int) -> str: return f"{settings.webapp_url}?user_id={user_id}&mode=keyboard" -def parse_transaction(raw: str) -> TransactionInput: +def decode_payload(raw: str) -> dict[str, Any]: try: - payload: dict[str, Any] = json.loads(raw) + payload = json.loads(raw) except (json.JSONDecodeError, TypeError) as exc: raise ValueError("Не удалось прочитать данные") from exc + if not isinstance(payload, dict): + raise ValueError("Неизвестный формат данных") + return payload + + +def parse_category_name(value: Any) -> str: + category = str(value or "").strip() + if not CATEGORY_PATTERN.fullmatch(category): + raise ValueError("Категория должна содержать от 1 до 40 символов") + return category + + +def parse_transaction(raw: str) -> TransactionInput: + payload = decode_payload(raw) if payload.get("type") != "transaction" or payload.get("v") != 1: raise ValueError("Неизвестный формат данных") @@ -36,9 +50,7 @@ def parse_transaction(raw: str) -> TransactionInput: if kind not in {"income", "expense"}: raise ValueError("Некорректный тип операции") - category = str(payload.get("category", "")).strip() - if not CATEGORY_PATTERN.fullmatch(category): - raise ValueError("Категория должна содержать от 1 до 40 символов") + category = parse_category_name(payload.get("category")) note = str(payload.get("note", "")).strip() or None if note and len(note) > 160: @@ -61,6 +73,26 @@ def parse_transaction(raw: str) -> TransactionInput: ) +def parse_category(raw: str) -> tuple[str, str]: + payload = decode_payload(raw) + if payload.get("type") != "category_create" or payload.get("v") != 1: + raise ValueError("Неизвестный формат данных") + kind = payload.get("kind") + if kind not in {"income", "expense"}: + raise ValueError("Некорректный тип категории") + return kind, parse_category_name(payload.get("category")) + + +def parse_cancellation(raw: str) -> int: + payload = decode_payload(raw) + if payload.get("type") != "transaction_cancel" or payload.get("v") != 1: + raise ValueError("Неизвестный формат данных") + transaction_id = payload.get("transaction_id") + if not isinstance(transaction_id, int) or transaction_id <= 0: + raise ValueError("Некорректный ID записи") + return transaction_id + + class TelegramBotService: def __init__(self, settings: Settings, database: Database): self.settings = settings @@ -115,8 +147,31 @@ class TelegramBotService: return self._save_user(user) try: - item = parse_transaction(message.web_app_data.data) - self.database.add_transaction(user.id, item) + raw = message.web_app_data.data + action = decode_payload(raw).get("type") + if action == "transaction": + item = parse_transaction(raw) + self.database.add_transaction(user.id, item) + operation = "Доход" if item.kind == "income" else "Расход" + response = ( + f"{operation} сохранён в категории «{item.category}». " + "Откройте приложение снова, чтобы увидеть обновлённую статистику." + ) + elif action == "category_create": + kind, category = parse_category(raw) + created = self.database.add_category(user.id, kind, category) + response = ( + f"Категория «{category}» добавлена." + if created + else f"Категория «{category}» уже существует." + ) + elif action == "transaction_cancel": + transaction_id = parse_cancellation(raw) + if not self.database.cancel_transaction(user.id, transaction_id): + raise ValueError("Запись не найдена или уже отменена") + response = "Запись отменена и больше не участвует в расчётах." + else: + raise ValueError("Неизвестный формат данных") except ValueError as exc: self.bot.send_message( message.chat.id, @@ -125,10 +180,9 @@ class TelegramBotService: ) return - operation = "Доход" if item.kind == "income" else "Расход" self.bot.send_message( message.chat.id, - f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.", + response, reply_markup=self._keyboard(user.id), ) diff --git a/app/db.py b/app/db.py index 3309fe2..feadea1 100644 --- a/app/db.py +++ b/app/db.py @@ -33,7 +33,8 @@ CREATE TABLE IF NOT EXISTS transactions ( category TEXT NOT NULL, note TEXT, occurred_at TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + cancelled_at TEXT ); CREATE INDEX IF NOT EXISTS idx_transactions_user_id @@ -47,6 +48,14 @@ CREATE TABLE IF NOT EXISTS friendships ( CHECK (user_id_low < user_id_high) ); +CREATE TABLE IF NOT EXISTS user_categories ( + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('income', 'expense')), + name TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, name) +); + CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -95,6 +104,22 @@ class Database: self.path.parent.mkdir(parents=True, exist_ok=True) with closing(self.connect()) as connection: connection.executescript(SCHEMA) + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(transactions)") + } + if "cancelled_at" not in columns: + connection.execute( + "ALTER TABLE transactions ADD COLUMN cancelled_at TEXT" + ) + connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + SELECT user_id, kind, category, MIN(created_at) + FROM transactions + GROUP BY user_id, kind, category + """ + ) connection.commit() def upsert_user( @@ -149,6 +174,13 @@ class Database: def add_transaction(self, user_id: int, item: TransactionInput) -> int: with closing(self.connect()) as connection: + connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + VALUES (?, ?, ?, ?) + """, + (user_id, item.kind, item.category, utc_now()), + ) cursor = connection.execute( """ INSERT INTO transactions ( @@ -169,6 +201,70 @@ class Database: connection.commit() return int(cursor.lastrowid) + def add_category(self, user_id: int, kind: str, name: str) -> bool: + with closing(self.connect()) as connection: + cursor = connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + VALUES (?, ?, ?, ?) + """, + (user_id, kind, name, utc_now()), + ) + connection.commit() + return cursor.rowcount > 0 + + def cancel_transaction(self, user_id: int, transaction_id: int) -> bool: + with closing(self.connect()) as connection: + cursor = connection.execute( + """ + UPDATE transactions + SET cancelled_at = ? + WHERE id = ? AND user_id = ? AND cancelled_at IS NULL + """, + (utc_now(), transaction_id, user_id), + ) + connection.commit() + return cursor.rowcount > 0 + + def transactions(self, user_id: int, limit: int = 50) -> list[dict[str, Any]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT id, kind, amount_cents, category, note, occurred_at + FROM transactions + WHERE user_id = ? AND cancelled_at IS NULL + ORDER BY occurred_at DESC, id DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + return [dict(row) for row in rows] + + def user_categories(self, user_id: int) -> list[dict[str, str]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT kind, name + FROM user_categories + WHERE user_id = ? + ORDER BY kind, name COLLATE NOCASE + """, + (user_id,), + ).fetchall() + return [dict(row) for row in rows] + + def global_categories(self) -> list[dict[str, str]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT kind, name + FROM user_categories + GROUP BY kind, name + ORDER BY name COLLATE NOCASE, kind + """ + ).fetchall() + return [dict(row) for row in rows] + def add_friendship(self, first_user_id: int, second_user_id: int) -> bool: if first_user_id == second_user_id: return False @@ -232,7 +328,8 @@ class Database: COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents, COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents FROM users u - LEFT JOIN transactions t ON t.user_id = u.user_id + LEFT JOIN transactions t + ON t.user_id = u.user_id AND t.cancelled_at IS NULL WHERE u.user_id = ? GROUP BY u.user_id """, @@ -244,7 +341,7 @@ class Database: """ SELECT kind, category, SUM(amount_cents) AS amount_cents FROM transactions - WHERE user_id = ? + WHERE user_id = ? AND cancelled_at IS NULL GROUP BY kind, category ORDER BY amount_cents DESC """, @@ -253,16 +350,27 @@ class Database: return self._serialize_totals(dict(row), categories) def leaderboard( - self, *, user_ids: Iterable[int] | None = None, limit: int = 100 + self, + *, + user_ids: Iterable[int] | None = None, + categories: Iterable[str] | None = None, + limit: int = 100, ) -> list[dict[str, Any]]: ids = list(dict.fromkeys(user_ids or [])) + category_names = list(dict.fromkeys(categories or [])) where = "" - params: list[Any] = [] + where_params: list[Any] = [] if ids: placeholders = ",".join("?" for _ in ids) where = f"WHERE u.user_id IN ({placeholders})" - params.extend(ids) - params.append(limit) + where_params.extend(ids) + join_conditions = ["t.user_id = u.user_id", "t.cancelled_at IS NULL"] + join_params: list[Any] = [] + if category_names: + placeholders = ",".join("?" for _ in category_names) + join_conditions.append(f"t.category IN ({placeholders})") + join_params.extend(category_names) + params = [*join_params, *where_params, limit] with closing(self.connect()) as connection: rows = connection.execute( f""" @@ -274,7 +382,7 @@ class Database: COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents, COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents FROM users u - LEFT JOIN transactions t ON t.user_id = u.user_id + LEFT JOIN transactions t ON {' AND '.join(join_conditions)} {where} GROUP BY u.user_id ORDER BY diff --git a/app/main.py b/app/main.py index 2272b17..48bebe0 100644 --- a/app/main.py +++ b/app/main.py @@ -75,6 +75,23 @@ def user_friends(user_id: int) -> dict[str, list[int]]: return {"user_ids": database.friend_ids(user_id)} +@app.get("/api/users/{user_id}/categories") +def user_categories(user_id: int) -> dict[str, list[dict[str, str]]]: + return {"items": database.user_categories(user_id)} + + +@app.get("/api/users/{user_id}/transactions") +def user_transactions( + user_id: int, limit: int = Query(default=50, ge=1, le=100) +) -> dict[str, list[dict]]: + return {"items": database.transactions(user_id, limit)} + + +@app.get("/api/categories") +def global_categories() -> dict[str, list[dict[str, str]]]: + return {"items": database.global_categories()} + + def parse_user_ids(raw: str | None) -> list[int] | None: if not raw: return None @@ -100,9 +117,23 @@ def leaderboard( user_ids: str | None = Query( default=None, description="Telegram user ID через запятую" ), + category: list[str] | None = Query( + default=None, description="Один или несколько фильтров по категории" + ), limit: int = Query(default=100, ge=1, le=100), ) -> dict[str, list[dict]]: - return {"items": database.leaderboard(user_ids=parse_user_ids(user_ids), limit=limit)} + categories = None + if category: + categories = list(dict.fromkeys(item.strip() for item in category if item.strip())) + if len(categories) > 20 or any(len(item) > 40 for item in categories): + raise HTTPException(status_code=422, detail="Некорректный фильтр категорий") + return { + "items": database.leaderboard( + user_ids=parse_user_ids(user_ids), + categories=categories, + limit=limit, + ) + } @app.get("/api/avatars/{user_id}", response_class=FileResponse) diff --git a/static/app.js b/static/app.js index 4d6e021..6d481b2 100644 --- a/static/app.js +++ b/static/app.js @@ -9,7 +9,14 @@ const user = tgUser || (previewUserId ? { id: previewUserId } : null); const userId = Number(user?.id) || 0; - const state = { kind: "income", board: "all", summary: null, config: null }; + const state = { + kind: "income", + board: "all", + categoryFilter: "", + summary: null, + config: null, + customCategories: { income: [], expense: [] }, + }; const categories = { income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"], expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"], @@ -57,7 +64,9 @@ function fillCategories() { const select = $("#category"); - select.innerHTML = categories[state.kind].map((category) => ``).join(""); + const items = [...new Set([...categories[state.kind], ...state.customCategories[state.kind]])]; + select.innerHTML = items.map((category) => ``).join("") + + ''; $("#customCategoryWrap").classList.add("hidden"); $("#customCategory").required = false; } @@ -72,11 +81,12 @@ }); }); $("#category").addEventListener("change", (event) => { - const custom = event.target.value === "Другое"; + const custom = event.target.value === "__new__"; $("#customCategoryWrap").classList.toggle("hidden", !custom); $("#customCategory").required = custom; if (custom) $("#customCategory").focus(); }); + $("#saveCategoryButton").addEventListener("click", saveCategory); $("#transactionForm").addEventListener("submit", submitTransaction); } @@ -84,7 +94,7 @@ event.preventDefault(); const amount = Number($("#amount").value); const selected = $("#category").value; - const category = selected === "Другое" ? $("#customCategory").value.trim() : selected; + const category = selected === "__new__" ? $("#customCategory").value.trim() : selected; if (!Number.isFinite(amount) || amount <= 0 || !category) { showToast("Проверьте сумму и категорию"); return; @@ -98,22 +108,47 @@ note: $("#note").value.trim().slice(0, 160), occurred_at: new Date().toISOString(), }; - const encoded = JSON.stringify(payload); - if (encoded.length > 4096) { - showToast("Запись получилась слишком длинной"); + sendTelegramPayload(payload); + } + + function saveCategory() { + const category = $("#customCategory").value.trim(); + if (!category) { + showToast("Введите название категории"); return; } + sendTelegramPayload({ + v: 1, + type: "category_create", + kind: state.kind, + category: category.slice(0, 40), + }); + } + + function canSendViaTelegram() { const isTelegramClient = Boolean(tg?.platform && tg.platform !== "unknown"); if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) { showToast("Отправьте /start боту и откройте приложение кнопкой под сообщением"); - return; + return false; } + return true; + } + + function sendTelegramPayload(payload) { + const encoded = JSON.stringify(payload); + if (encoded.length > 4096) { + showToast("Запись получилась слишком длинной"); + return false; + } + if (!canSendViaTelegram()) return false; try { tg.HapticFeedback?.notificationOccurred("success"); tg.sendData(encoded); + return true; } catch (error) { console.error("Telegram sendData failed", error); showToast("Telegram не принял запись. Обновите приложение и попробуйте снова"); + return false; } } @@ -129,11 +164,27 @@ state.summary = await fetchJson(`../api/users/${userId}/summary`); setIdentity(state.summary); renderSummary(state.summary); + await loadTransactions(); } catch (error) { console.warn("Summary is not available", error); } } + async function loadUserCategories() { + if (!userId) return; + try { + const data = await fetchJson(`../api/users/${userId}/categories`); + for (const item of data.items || []) { + if (state.customCategories[item.kind] && !state.customCategories[item.kind].includes(item.name)) { + state.customCategories[item.kind].push(item.name); + } + } + fillCategories(); + } catch (error) { + console.warn("User categories are not available", error); + } + } + function renderSummary(summary) { $("#balanceValue").textContent = money.format(summary.balance_cents / 100); $("#incomeValue").textContent = money.format(summary.income_cents / 100); @@ -164,10 +215,82 @@ return element.innerHTML; } + async function loadTransactions() { + if (!userId) return; + try { + const data = await fetchJson(`../api/users/${userId}/transactions?limit=30`); + renderTransactions(data.items || []); + } catch (error) { + console.warn("Transactions are not available", error); + } + } + + function renderTransactions(items) { + const list = $("#transactionList"); + if (!items.length) { + list.className = "transaction-list empty-state"; + list.textContent = "Записей пока нет."; + return; + } + list.className = "transaction-list"; + list.innerHTML = items.map((item) => { + const income = item.kind === "income"; + const date = new Date(item.occurred_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); + const detail = [date, item.note].filter(Boolean).map(escapeHtml).join(" · "); + return `