From f1c78008fb9ec662c0a93ebc6bcc4b5d6f043b74 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 15 Apr 2026 15:42:17 +0300 Subject: [PATCH 1/4] feat(i18n): add de-DE locale and EUR, patch axios to 1.15.0 --- apps/api/package.json | 4 +- .../migration.sql | 18 + apps/api/prisma/schema.prisma | 1 - apps/api/src/modules/summary/controller.ts | 5 +- .../api/src/modules/transaction/controller.ts | 1 - apps/web/package.json | 4 +- .../MonobankAccountSelectPopup.tsx | 1 - .../_components/ChangeTransactionPopup.tsx | 4 +- apps/web/src/hooks/useCurrency.ts | 5 +- apps/web/src/shared/i18n/config.ts | 2 +- apps/web/src/shared/i18n/i18n.ts | 4 +- .../shared/i18n/locales/de/translation.json | 207 +++ .../shared/i18n/locales/ru/translation.json | 206 --- apps/web/src/types/currency.ts | 2 +- package-lock.json | 1415 +++-------------- package.json | 7 +- packages/types/src/transaction.ts | 9 +- 17 files changed, 458 insertions(+), 1437 deletions(-) create mode 100644 apps/api/prisma/migrations/20260415130000_remove_rub_currency_code/migration.sql create mode 100644 apps/web/src/shared/i18n/locales/de/translation.json delete mode 100644 apps/web/src/shared/i18n/locales/ru/translation.json diff --git a/apps/api/package.json b/apps/api/package.json index 50ce177..86e3c0e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -39,7 +39,7 @@ "homepage": "https://github.com/BODMAT/FinTrack#readme", "dependencies": { "@fintrack/types": "*", - "@prisma/client": "^6.14.0", + "@prisma/client": "^6.19.3", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -70,7 +70,7 @@ "jest": "^29.7.0", "nock": "^14.0.11", "nodemon": "^3.1.10", - "prisma": "^6.14.0", + "prisma": "^6.19.3", "rimraf": "^6.0.1", "supertest": "^7.2.2", "ts-jest": "^29.2.5", diff --git a/apps/api/prisma/migrations/20260415130000_remove_rub_currency_code/migration.sql b/apps/api/prisma/migrations/20260415130000_remove_rub_currency_code/migration.sql new file mode 100644 index 0000000..d543812 --- /dev/null +++ b/apps/api/prisma/migrations/20260415130000_remove_rub_currency_code/migration.sql @@ -0,0 +1,18 @@ +-- Map legacy RUB data to EUR before removing RUB from enum +UPDATE "Transaction" +SET "currencyCode" = 'EUR' +WHERE "currencyCode" = 'RUB'; + +-- Recreate enum without RUB +ALTER TYPE "public"."CurrencyCode" RENAME TO "CurrencyCode_old"; +CREATE TYPE "public"."CurrencyCode" AS ENUM ('USD', 'UAH', 'EUR'); + +ALTER TABLE "Transaction" +ALTER COLUMN "currencyCode" DROP DEFAULT, +ALTER COLUMN "currencyCode" TYPE "public"."CurrencyCode" +USING ("currencyCode"::text::"public"."CurrencyCode"); + +ALTER TABLE "Transaction" +ALTER COLUMN "currencyCode" SET DEFAULT 'USD'; + +DROP TYPE "public"."CurrencyCode_old"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5386996..9be0a34 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -26,7 +26,6 @@ enum TransactionSource { enum CurrencyCode { USD UAH - RUB EUR } diff --git a/apps/api/src/modules/summary/controller.ts b/apps/api/src/modules/summary/controller.ts index 33873a2..aaca372 100644 --- a/apps/api/src/modules/summary/controller.ts +++ b/apps/api/src/modules/summary/controller.ts @@ -28,7 +28,10 @@ function convertToIData( return transactionsData.data.map((t) => ({ ...t, amount: Number(t.amount), - currencyCode: t.currencyCode, + currencyCode: + t.currencyCode === "UAH" || t.currencyCode === "EUR" + ? t.currencyCode + : "USD", created_at: new Date(t.created_at), updated_at: new Date(t.updated_at), location: t.location || undefined, diff --git a/apps/api/src/modules/transaction/controller.ts b/apps/api/src/modules/transaction/controller.ts index 4adb1a2..93623ce 100644 --- a/apps/api/src/modules/transaction/controller.ts +++ b/apps/api/src/modules/transaction/controller.ts @@ -36,7 +36,6 @@ function numericCurrencyToCode(code?: number) { if (code === 980) return "UAH"; if (code === 840) return "USD"; if (code === 978) return "EUR"; - if (code === 643) return "RUB"; return "USD"; } diff --git a/apps/web/package.json b/apps/web/package.json index 5ccdcb5..947352f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,13 +16,13 @@ "@fintrack/types": "*", "@tanstack/react-query": "^5.84.2", "@tanstack/react-query-devtools": "^5.84.2", - "axios": "^1.14.0", + "axios": "^1.15.0", "chart.js": "^4.5.0", "framer-motion": "^12.23.12", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.0.1", + "next": "^16.2.3", "next-auth": "^4.24.13", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx index 0bf81ec..7aec0d7 100644 --- a/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx +++ b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx @@ -28,7 +28,6 @@ function numericToAlpha(code?: number) { if (code === 980) return "UAH"; if (code === 840) return "USD"; if (code === 978) return "EUR"; - if (code === 643) return "RUB"; return "USD"; } diff --git a/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx index 3d314e5..5439b6f 100644 --- a/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx +++ b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx @@ -18,9 +18,9 @@ import { useNumericValidator } from "@/hooks/useNumericValidator"; import { useCurrentLocation } from "@/hooks/useCurrentLocation"; import { useCurrency } from "@/hooks/useCurrency"; -function toManualCurrencyCode(currencyCode: string): "USD" | "UAH" | "RUB" { +function toManualCurrencyCode(currencyCode: string): "USD" | "UAH" | "EUR" { if (currencyCode === "UAH") return "UAH"; - if (currencyCode === "RUB") return "RUB"; + if (currencyCode === "EUR") return "EUR"; return "USD"; } diff --git a/apps/web/src/hooks/useCurrency.ts b/apps/web/src/hooks/useCurrency.ts index 5e519bf..83e5342 100644 --- a/apps/web/src/hooks/useCurrency.ts +++ b/apps/web/src/hooks/useCurrency.ts @@ -14,14 +14,12 @@ const ONE_HOUR_MS = 60 * 60 * 1000; const DEFAULT_RATES: SupportedRates = { USD: 1, UAH: 1, - RUB: 1, EUR: 1, }; function normalizeCurrency(value?: string): CurrencyCode { const normalized = value?.trim().toUpperCase(); if (normalized === "UAH") return "UAH"; - if (normalized === "RUB") return "RUB"; if (normalized === "EUR") return "EUR"; return "USD"; } @@ -68,7 +66,6 @@ async function fetchRatesFromApi(): Promise { rates: { USD: Number(rates.USD) || 1, UAH: Number(rates.UAH) || 1, - RUB: Number(rates.RUB) || 1, EUR: Number(rates.EUR) || 1, }, }; @@ -79,7 +76,7 @@ async function fetchRatesFromApi(): Promise { function getLocaleByLanguage(language: string) { if (language === "uk") return "uk-UA"; - if (language === "ru") return "ru-RU"; + if (language === "de") return "de-DE"; return "en-US"; } diff --git a/apps/web/src/shared/i18n/config.ts b/apps/web/src/shared/i18n/config.ts index 4de03f8..2d84b01 100644 --- a/apps/web/src/shared/i18n/config.ts +++ b/apps/web/src/shared/i18n/config.ts @@ -1,4 +1,4 @@ -export const SUPPORTED_LANGUAGES = ["en", "uk", "ru"] as const; +export const SUPPORTED_LANGUAGES = ["en", "uk", "de"] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; export const FALLBACK_LANGUAGE: SupportedLanguage = "en"; diff --git a/apps/web/src/shared/i18n/i18n.ts b/apps/web/src/shared/i18n/i18n.ts index 80055b5..5ce101d 100644 --- a/apps/web/src/shared/i18n/i18n.ts +++ b/apps/web/src/shared/i18n/i18n.ts @@ -4,14 +4,14 @@ import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES } from "./config"; import en from "./locales/en/translation.json"; import uk from "./locales/uk/translation.json"; -import ru from "./locales/ru/translation.json"; +import de from "./locales/de/translation.json"; if (!i18n.isInitialized) { i18n.use(initReactI18next).init({ resources: { en: { translation: en }, uk: { translation: uk }, - ru: { translation: ru }, + de: { translation: de }, }, lng: FALLBACK_LANGUAGE, fallbackLng: FALLBACK_LANGUAGE, diff --git a/apps/web/src/shared/i18n/locales/de/translation.json b/apps/web/src/shared/i18n/locales/de/translation.json new file mode 100644 index 0000000..30fee5c --- /dev/null +++ b/apps/web/src/shared/i18n/locales/de/translation.json @@ -0,0 +1,207 @@ +{ + "nav": { + "dashboard": "Dashboard", + "analytics": "Analytik", + "transactions": "Transaktionen", + "monobank": "Monobank API", + "admin": "Admin-Panel" + }, + "common": { + "loading": "Laedt...", + "retry": "Erneut versuchen", + "noData": "Keine Daten", + "error": "Fehler", + "unexpected": "Etwas ist schiefgelaufen", + "close": "Schliessen" + }, + "auth": { + "loginTitle": "Anmeldung", + "email": "E-Mail", + "name": "Name", + "photoUrlOptional": "Foto-URL (optional)", + "telegramOptional": "Telegram-ID (optional)", + "password": "Passwort", + "loginButton": "In Konto anmelden", + "loginSuccess": "Benutzer erfolgreich angemeldet", + "registerSuccess": "Benutzer erfolgreich erstellt", + "registerNewAccount": "Neues Konto registrieren", + "logout": "Abmelden", + "logoutAllSessions": "Alle Sitzungen abmelden", + "registerNew": "Neu registrieren", + "registerProfileTitle": "Neues Profil registrieren", + "addProfile": "Profil hinzufuegen", + "anonymous": "Anonym", + "userPhotoAlt": "Benutzerfoto" + }, + "dashboard": { + "title": "Dashboard", + "overview": "Finanzuebersicht mit Echtzeitaktivitaet und Standorteinblicken.", + "styleBadge": "Teal-Stil aktiv", + "notLoggedIn": "Sie sind nicht angemeldet. Bitte melden Sie sich an, um Ihr Dashboard zu sehen.", + "noTransactions": "Sie haben keine Transaktionen. Bitte fuegen Sie Transaktionen hinzu, um Ihr Dashboard zu sehen.", + "card": { + "currentBalance": "Aktueller Saldo", + "income": "Einnahmen", + "saving": "Ersparnis", + "outcome": "Ausgaben", + "per": "pro", + "allTime": "gesamt", + "graphWith": "Diagramm mit " + } + }, + "transactions": { + "title": "Transaktionen", + "addNew": "Hinzufuegen", + "addTransactionTitle": "Transaktion hinzufuegen", + "selectedLocation": "Ausgewaehlter Standort", + "noLocation": "Kein Standort", + "notLoggedIn": "Sie sind nicht angemeldet. Bitte melden Sie sich an, um Ihre Transaktionen zu sehen.", + "loadingMore": "Mehr laden...", + "noMore": "Keine weiteren Transaktionen", + "noFound": "Keine Transaktionen gefunden" + }, + "analytics": { + "title": "Analytik", + "notLoggedIn": "Bitte melden Sie sich an, um die Analytik zu nutzen", + "askPlaceholder": "Fragen Sie zu Ihren Finanzen...", + "analyze": "Analysieren", + "apiKeyButton": "API-Schluessel", + "apiKeyTitle": "AI API-Schluessel Einstellungen", + "apiKeyDescription": "Verbinden Sie Ihren eigenen Schluessel fuer Portfolio-Analysen. Unterstuetzt:", + "apiKeySave": "Speichern", + "apiKeyReplace": "Schluessel ersetzen...", + "addApiKey": "API-Schluessel hinzufuegen" + }, + "monobank": { + "title": "Monobank API", + "safeInfo": "Diese Integration ist schreibgeschuetzt und sicher. FinTrack kann Daten nur zur Analyse abrufen. Es kann keine echten Banktransaktionen erstellen oder ausfuehren.", + "rangeLimit": "Auszugsbereich: max. 31 Tage + 1 Stunde (2.682.000 Sekunden).", + "cooldownLimit": "Monobank API Cooldown: eine Anfrage pro 60 Sekunden.", + "getTokenAt": "Persoenlichen Token erhalten unter", + "tokenLabel": "Monobank API Token", + "submitRequest": "Anfrage senden", + "checkingToken": "Token wird geprueft...", + "cooldownBadge": "cooldown", + "nextStep": "Naechster Schritt: Karte und Modus im Popup waehlen", + "storageTitle": "Monobank Speicher", + "storageDesc": "Alle zuvor gespeicherten Monobank-Transaktionen aus Ihrer DB entfernen.", + "deleteMyData": "Meine Monobank-Daten loeschen", + "deleting": "Wird geloescht...", + "transactionsTitle": "Monobank Transaktionen", + "readonlyDesc": "Nur-Lesen Liste. Bearbeiten und Loeschen sind fuer Monobank-Daten deaktiviert.", + "noTransactionsYet": "Noch keine Monobank-Transaktionen zum Anzeigen.", + "analyticsTitle": "Monobank Analytik", + "currentBalance": "Aktueller Saldo", + "selectAccountTitle": "Monobank-Konto waehlen", + "selectAccountDesc": "Waehlen Sie ein Kartenkonto und den Aktionsmodus.", + "actionModeTitle": "Was nach dem Laden der Transaktionen tun?", + "previewOnly": "Nur Vorschau", + "saveToDb": "Direkt in DB speichern", + "continue": "Weiter", + "selected": "Ausgewaehlt", + "accountType": "Typ", + "balance": "Saldo", + "creditLimit": "Kreditlimit", + "accountCurrency": "Kontowaehrung", + "waitBeforeRequest": "Warten Sie {{seconds}}s vor der naechsten Monobank-Anfrage.", + "tokenInvalid": "Bitte geben Sie einen gueltigen Monobank-Token ein.", + "tokenCheckFailed": "Token-Pruefung fehlgeschlagen", + "importCompleted": "Import abgeschlossen", + "previewReady": "Vorschau bereit", + "requestFailed": "Monobank-Anfrage fehlgeschlagen", + "dataRemoved": "Monobank-Daten entfernt", + "deleteFailed": "Loeschen fehlgeschlagen", + "importCompletedMessage": "{{loaded}} Transaktionen geladen. {{imported}} importiert, {{skipped}} Duplikate uebersprungen.", + "previewReadyMessage": "{{loaded}} Transaktion(en) fuer Vorschau geladen.", + "removedMessage": "{{deleted}} Monobank-Transaktion(en) aus Ihrer Datenbank geloescht.", + "selectOneAccount": "Bitte waehlen Sie ein Kartenkonto, um fortzufahren.", + "loading": "Laedt..." + }, + "errors": { + "error": "Fehler", + "failedDashboard": "Dashboard konnte nicht geladen werden", + "failedAnalytics": "Analytik konnte nicht geladen werden", + "failedTransactions": "Transaktionen konnten nicht geladen werden", + "unexpectedDashboard": "Unerwarteter Fehler beim Laden des Dashboards.", + "unexpectedAnalytics": "Unerwarteter Fehler beim Laden der Analytik.", + "unexpectedTransactions": "Unerwarteter Fehler beim Laden der Transaktionen.", + "aiKeyLimit": "Rate-Limit erreicht", + "aiKeyLimitDesc": "Ihr API-Schluessel hat sein Rate-Limit erreicht. Warten Sie oder ersetzen Sie den Schluessel.", + "aiKeyInvalid": "Ungueltiger API-Schluessel", + "aiKeyInvalidDesc": "Ihr API-Schluessel ist ungueltig oder abgelaufen. Pruefen und erneut versuchen.", + "aiDefaultLimit": "Dienst ueberlastet", + "aiDefaultLimitDesc": "Der Standard-AI-Schluessel ist erschoepft. Fuegen Sie Ihren eigenen Schluessel hinzu.", + "aiAllFailed": "Dienst nicht verfuegbar", + "aiAllFailedDesc": "Alle AI-Schluessel sind nicht verfuegbar. Bitte fuegen Sie Ihren eigenen Groq- oder Gemini-Schluessel hinzu.", + "aiUsingDefault": "Standard-AI-Schluessel wird verwendet", + "aiUsingDefaultDesc": "Ihr persoenlicher API-Schluessel ist derzeit nicht aktiv. FinTrack verwendet den Standard-AI-Schluessel fuer diese Anfrage." + }, + "admin": { + "title": "ADMIN-PANEL", + "subtitle": "Rollen, Sitzungen, Benutzermetriken und Client-Fehlerprotokolle verwalten.", + "accessDenied": "Zugriff verweigert. Diese Seite ist nur fuer Administratoren verfuegbar.", + "loadingPanel": "Admin-Panel wird geladen...", + "loadingStats": "Statistiken werden geladen...", + "errorBoundary": { + "title": "Admin-Panel konnte nicht geladen werden", + "unexpected": "Unerwarteter Fehler im Admin-Panel." + }, + "metrics": { + "totalUsers": "Benutzer gesamt", + "admins": "Administratoren", + "verifiedUsers": "Verifizierte Benutzer", + "newUsers7d": "Neue Benutzer (7 Tage)", + "activeSessions": "Aktive Sitzungen", + "openErrors": "Offene Fehler" + }, + "users": { + "title": "Benutzer", + "logoutAllSessions": "Alle Sitzungen abmelden", + "loggingOutAll": "Alle werden abgemeldet...", + "loading": "Benutzer werden geladen...", + "loadError": "Benutzer konnten nicht geladen werden", + "empty": "Keine Benutzer gefunden.", + "yes": "Ja", + "no": "Nein", + "makeAdmin": "Zum Admin machen", + "removeAdmin": "Admin entfernen", + "logoutUser": "Benutzer abmelden", + "columns": { + "name": "Name", + "contact": "Kontakt", + "role": "Rolle", + "verified": "Verifiziert", + "created": "Erstellt", + "actions": "Aktionen" + } + }, + "errors": { + "title": "Fehlerprotokolle", + "open": "Offen", + "resolved": "Geloest", + "all": "Alle", + "loading": "Fehlerprotokolle werden geladen...", + "loadError": "Protokolle konnten nicht geladen werden", + "empty": "Keine Fehlerprotokolle gefunden.", + "source": "Quelle", + "userAgent": "User-Agent", + "resolvedAt": "Geloest", + "markResolved": "Als geloest markieren", + "markOpen": "Als offen markieren" + }, + "charts": { + "title": "Admin-Einblicke", + "subtitle": "Visueller Ueberblick ueber Benutzer- und Fehleraktivitaet.", + "loading": "Diagramme werden geladen...", + "admins": "Administratoren", + "users": "Benutzer", + "verified": "Verifiziert", + "unverified": "Nicht verifiziert", + "newUsers": "Neue Benutzer", + "newErrors": "Neue Fehler", + "rolesBreakdown": "Rollenverteilung", + "verificationBreakdown": "Verifizierungsverteilung", + "activity14d": "Neue Benutzer und Fehler (14 Tage)" + } + } +} diff --git a/apps/web/src/shared/i18n/locales/ru/translation.json b/apps/web/src/shared/i18n/locales/ru/translation.json deleted file mode 100644 index c75a2b6..0000000 --- a/apps/web/src/shared/i18n/locales/ru/translation.json +++ /dev/null @@ -1,206 +0,0 @@ -{ - "nav": { - "dashboard": "Дашборд", - "analytics": "Аналитика", - "transactions": "Транзакции", - "admin": "Панель админа" - }, - "common": { - "loading": "Загрузка...", - "retry": "Попробуйте снова", - "noData": "Нет данных", - "error": "Ошибка", - "unexpected": "Что-то пошло не так...", - "close": "Закрыть" - }, - "auth": { - "loginTitle": "Вход", - "email": "Email", - "name": "Имя", - "photoUrlOptional": "URL фото (необязательно)", - "telegramOptional": "Telegram ID (необязательно)", - "password": "Пароль", - "loginButton": "Войти в аккаунт", - "loginSuccess": "Пользователь успешно вошел", - "registerSuccess": "Пользователь успешно создан", - "registerNewAccount": "Зарегистрировать новый аккаунт", - "logout": "Выйти", - "logoutAllSessions": "Выйти изо всех сессий", - "registerNew": "Зарегистрироваться", - "registerProfileTitle": "Создать новый профиль", - "addProfile": "Добавить профиль", - "anonymous": "Аноним", - "userPhotoAlt": "фото пользователя" - }, - "dashboard": { - "title": "Дашборд", - "overview": "Финансовый обзор с актуальной активностью и геоданными.", - "styleBadge": "Teal-стиль активен", - "notLoggedIn": "Вы не авторизованы. Войдите, чтобы увидеть дашборд.", - "noTransactions": "У вас нет транзакций. Добавьте транзакции, чтобы увидеть дашборд.", - "card": { - "currentBalance": "Текущий баланс", - "income": "Доход", - "saving": "Сбережения", - "outcome": "Расход", - "per": "за", - "allTime": "все время", - "graphWith": "График для " - } - }, - "transactions": { - "title": "Транзакции", - "addNew": "Добавить", - "addTransactionTitle": "Добавить транзакцию", - "selectedLocation": "Выбранная локация", - "noLocation": "Локация не указана", - "notLoggedIn": "Вы не авторизованы. Войдите, чтобы увидеть транзакции.", - "loadingMore": "Загружаем еще...", - "noMore": "Больше транзакций нет", - "noFound": "Транзакции не найдены" - }, - "analytics": { - "title": "Аналитика", - "notLoggedIn": "Пожалуйста, войдите для использования аналитики", - "askPlaceholder": "Спроси про свои финансы...", - "analyze": "Анализировать", - "apiKeyButton": "API ключ", - "apiKeyTitle": "Настройка AI ключа", - "apiKeyDescription": "Подключи собственный ключ для анализа портфеля. Поддерживаются:", - "apiKeySave": "Сохранить", - "apiKeyReplace": "Заменить ключ...", - "addApiKey": "Добавить ключ" - }, - "monobank": { - "title": "Monobank API", - "safeInfo": "Интеграция работает только в режиме чтения. FinTrack загружает данные только для анализа и не может выполнять реальные банковские транзакции.", - "rangeLimit": "Лимит периода выписки: максимум 31 день + 1 час (2 682 000 секунд).", - "cooldownLimit": "Лимит Monobank API: 1 запрос в 60 секунд.", - "getTokenAt": "Получите персональный токен на", - "tokenLabel": "Monobank API токен", - "submitRequest": "Отправить запрос", - "checkingToken": "Проверка токена...", - "cooldownBadge": "кулдаун", - "nextStep": "Далее: выберите карту и режим действия в попапе", - "storageTitle": "Хранилище Monobank", - "storageDesc": "Удалить все ранее сохраненные Monobank-транзакции из БД.", - "deleteMyData": "Удалить мои данные Monobank", - "deleting": "Удаление...", - "transactionsTitle": "Транзакции Monobank", - "readonlyDesc": "Только чтение. Редактирование и удаление Monobank-данных отключено.", - "noTransactionsYet": "Пока нет Monobank-транзакций для отображения.", - "analyticsTitle": "Аналитика Monobank", - "currentBalance": "Текущий баланс", - "selectAccountTitle": "Выбор счета Monobank", - "selectAccountDesc": "Выберите карточный счет и режим действия.", - "actionModeTitle": "Что делать после загрузки транзакций?", - "previewOnly": "Только просмотр", - "saveToDb": "Сразу сохранить в БД", - "continue": "Продолжить", - "selected": "Выбрано", - "accountType": "Тип", - "balance": "Баланс", - "creditLimit": "Кредитный лимит", - "accountCurrency": "Валюта счета", - "waitBeforeRequest": "Подождите {{seconds}}с перед следующим Monobank-запросом.", - "tokenInvalid": "Укажите валидный Monobank токен.", - "tokenCheckFailed": "Ошибка проверки токена", - "importCompleted": "Импорт завершен", - "previewReady": "Просмотр готов", - "requestFailed": "Ошибка Monobank-запроса", - "dataRemoved": "Данные Monobank удалены", - "deleteFailed": "Ошибка удаления", - "importCompletedMessage": "Загружено {{loaded}} транзакций. Импортировано {{imported}}, пропущено дублей {{skipped}}.", - "previewReadyMessage": "Загружено {{loaded}} транзакций для просмотра.", - "removedMessage": "Удалено {{deleted}} Monobank-транзакций из вашей БД.", - "selectOneAccount": "Выберите один карточный счет для продолжения.", - "loading": "Загрузка..." - }, - "errors": { - "error": "Ошибка", - "failedDashboard": "Ошибка загрузки дашборда", - "failedAnalytics": "Ошибка загрузки аналитики", - "failedTransactions": "Ошибка загрузки транзакций", - "unexpectedDashboard": "Неожиданная ошибка при загрузке дашборда.", - "unexpectedAnalytics": "Неожиданная ошибка при загрузке аналитики.", - "unexpectedTransactions": "Неожиданная ошибка при загрузке транзакций.", - "aiKeyLimit": "Лимит исчерпан", - "aiKeyLimitDesc": "Твой API ключ достиг лимита запросов. Подожди или замени ключ.", - "aiKeyInvalid": "Невалидный ключ", - "aiKeyInvalidDesc": "Твой API ключ недействителен или просрочен. Проверь и замени его.", - "aiDefaultLimit": "Сервис перегружен", - "aiDefaultLimitDesc": "Дефолтный AI ключ исчерпан. Добавь собственный ключ, чтобы продолжить.", - "aiAllFailed": "Сервис недоступен", - "aiAllFailedDesc": "Все AI ключи недоступны. Добавь собственный ключ Groq или Gemini.", - "aiUsingDefault": "Используется стандартный AI ключ", - "aiUsingDefaultDesc": "Ваш персональный API ключ сейчас неактивен. Для этого запроса FinTrack использует стандартный AI ключ." - }, - "admin": { - "title": "ПАНЕЛЬ АДМИНА", - "subtitle": "Управляйте ролями, сессиями, метриками пользователей и журналом клиентских ошибок.", - "accessDenied": "Доступ запрещён. Эта страница доступна только администраторам.", - "loadingPanel": "Загрузка админ-панели...", - "loadingStats": "Загрузка статистики...", - "errorBoundary": { - "title": "Не удалось загрузить админ-панель", - "unexpected": "Произошла непредвиденная ошибка админ-панели." - }, - "metrics": { - "totalUsers": "Всего пользователей", - "admins": "Админы", - "verifiedUsers": "Верифицированные пользователи", - "newUsers7d": "Новые пользователи (7 дней)", - "activeSessions": "Активные сессии", - "openErrors": "Открытые ошибки" - }, - "users": { - "title": "Пользователи", - "logoutAllSessions": "Выйти из всех сессий", - "loggingOutAll": "Завершение всех сессий...", - "loading": "Загрузка пользователей...", - "loadError": "Не удалось загрузить пользователей", - "empty": "Пользователи не найдены.", - "yes": "Да", - "no": "Нет", - "makeAdmin": "Сделать админом", - "removeAdmin": "Снять права админа", - "logoutUser": "Выйти пользователя", - "columns": { - "name": "Имя", - "contact": "Контакт", - "role": "Роль", - "verified": "Верифицирован", - "created": "Создан", - "actions": "Действия" - } - }, - "errors": { - "title": "Журнал ошибок", - "open": "Открытые", - "resolved": "Решённые", - "all": "Все", - "loading": "Загрузка журнала ошибок...", - "loadError": "Не удалось загрузить журнал", - "empty": "Записей об ошибках не найдено.", - "source": "Источник", - "userAgent": "User Agent", - "resolvedAt": "Решено", - "markResolved": "Отметить как решённое", - "markOpen": "Отметить как открытое" - }, - "charts": { - "title": "Инсайты админа", - "subtitle": "Визуальный обзор пользователей и активности ошибок.", - "loading": "Загрузка графиков...", - "admins": "Админы", - "users": "Пользователи", - "verified": "Верифицированные", - "unverified": "Неверифицированные", - "newUsers": "Новые пользователи", - "newErrors": "Новые ошибки", - "rolesBreakdown": "Распределение ролей", - "verificationBreakdown": "Распределение верификации", - "activity14d": "Новые пользователи и ошибки (14 дней)" - } - } -} diff --git a/apps/web/src/types/currency.ts b/apps/web/src/types/currency.ts index ee939b2..637a0cc 100644 --- a/apps/web/src/types/currency.ts +++ b/apps/web/src/types/currency.ts @@ -14,5 +14,5 @@ export type CachedRatesPayload = { export const LANGUAGE_TO_CURRENCY = { en: "USD", uk: "UAH", - ru: "RUB", + de: "EUR", } as const; diff --git a/package-lock.json b/package-lock.json index 3de9a94..58000ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "packages/*" ], "dependencies": { - "axios": "^1.11.0", + "axios": "^1.15.0", "zod": "^3.23.8" }, "devDependencies": { @@ -41,7 +41,7 @@ "license": "MIT", "dependencies": { "@fintrack/types": "*", - "@prisma/client": "^6.14.0", + "@prisma/client": "^6.19.3", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -72,7 +72,7 @@ "jest": "^29.7.0", "nock": "^14.0.11", "nodemon": "^3.1.10", - "prisma": "^6.14.0", + "prisma": "^6.19.3", "rimraf": "^6.0.1", "supertest": "^7.2.2", "ts-jest": "^29.2.5", @@ -419,6 +419,62 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "apps/api/node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "apps/api/node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "dev": true, + "license": "Apache-2.0" + }, + "apps/api/node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "apps/api/node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "apps/api/node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, "apps/api/node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -678,6 +734,17 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "apps/api/node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "apps/api/node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -1435,6 +1502,32 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "apps/api/node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "apps/api/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -1650,13 +1743,13 @@ "@fintrack/types": "*", "@tanstack/react-query": "^5.84.2", "@tanstack/react-query-devtools": "^5.84.2", - "axios": "^1.14.0", + "axios": "^1.15.0", "chart.js": "^4.5.0", "framer-motion": "^12.23.12", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.0.1", + "next": "^16.2.3", "next-auth": "^4.24.13", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", @@ -1693,17 +1786,6 @@ "undici-types": "~7.18.0" } }, - "apps/web/node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "apps/web/node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", @@ -1730,15 +1812,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "apps/web/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "apps/web/node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -1817,87 +1890,6 @@ "openapi-types": ">=7" } }, - "node_modules/@auth/core": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", - "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "@panva/hkdf": "^1.1.1", - "@types/cookie": "0.6.0", - "cookie": "0.6.0", - "jose": "^5.1.3", - "oauth4webapi": "^2.10.4", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/core/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@auth/core/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/core/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/@auth/core/node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/@auth/prisma-adapter": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", @@ -5326,70 +5318,6 @@ "node": ">=8" } }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5480,15 +5408,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", - "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", + "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", - "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", + "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", "cpu": [ "arm64" ], @@ -5502,9 +5430,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", - "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", + "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", "cpu": [ "x64" ], @@ -5518,9 +5446,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", - "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", + "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", "cpu": [ "arm64" ], @@ -5534,9 +5462,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", - "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", + "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", "cpu": [ "arm64" ], @@ -5550,9 +5478,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", - "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", + "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", "cpu": [ "x64" ], @@ -5566,9 +5494,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", - "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", + "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", "cpu": [ "x64" ], @@ -5582,9 +5510,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", - "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", + "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", "cpu": [ "arm64" ], @@ -5598,9 +5526,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", - "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", + "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", "cpu": [ "x64" ], @@ -5671,9 +5599,9 @@ } }, "node_modules/@prisma/client": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", - "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -5692,69 +5620,13 @@ } } }, - "node_modules/@prisma/config": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", - "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "c12": "3.1.0", - "deepmerge-ts": "7.1.5", - "effect": "3.18.4", - "empathic": "2.0.0" - } - }, - "node_modules/@prisma/debug": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", - "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", - "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.19.2", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.2", - "@prisma/get-platform": "6.19.2" - } - }, "node_modules/@prisma/engines-version": { "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, - "node_modules/@prisma/fetch-engine": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", - "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.19.2", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.2" - } - }, - "node_modules/@prisma/get-platform": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", - "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.19.2" - } - }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -5786,15 +5658,6 @@ "hasInstallScript": true, "license": "Apache-2.0" }, - "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -5809,7 +5672,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { @@ -6137,288 +6000,34 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@swc/core": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", - "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.11", - "@swc/core-darwin-x64": "1.15.11", - "@swc/core-linux-arm-gnueabihf": "1.15.11", - "@swc/core-linux-arm64-gnu": "1.15.11", - "@swc/core-linux-arm64-musl": "1.15.11", - "@swc/core-linux-x64-gnu": "1.15.11", - "@swc/core-linux-x64-musl": "1.15.11", - "@swc/core-win32-arm64-msvc": "1.15.11", - "@swc/core-win32-ia32-msvc": "1.15.11", - "@swc/core-win32-x64-msvc": "1.15.11" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", - "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", - "cpu": [ - "arm64" - ], + "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", - "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", - "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", - "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", - "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", - "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", - "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", - "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", - "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", - "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/helpers": { - "version": "0.5.20", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz", - "integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/postcss/node_modules/@tailwindcss/oxide": { @@ -7098,14 +6707,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/cookie-parser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", @@ -7667,314 +7268,17 @@ "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.55.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, "node_modules/accepts": { "version": "2.0.0", @@ -8343,14 +7647,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -8637,7 +7941,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -8666,7 +7970,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -8682,7 +7986,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -8865,29 +8169,11 @@ "node": ">= 6" } }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -9034,14 +8320,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -10321,7 +9607,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -10367,7 +9653,7 @@ "version": "6.1.6", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -10392,7 +9678,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/destroy": { @@ -10635,17 +9921,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -10693,7 +9968,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -11358,14 +10633,14 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -11875,7 +11150,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -12936,33 +12211,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", - "picomatch": "^4.0.3", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -12981,163 +12229,11 @@ } } }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", - "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", - "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -13745,24 +12841,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13781,12 +12859,12 @@ } }, "node_modules/next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", - "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", + "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", "license": "MIT", "dependencies": { - "@next/env": "16.2.1", + "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -13800,14 +12878,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.1", - "@next/swc-darwin-x64": "16.2.1", - "@next/swc-linux-arm64-gnu": "16.2.1", - "@next/swc-linux-arm64-musl": "16.2.1", - "@next/swc-linux-x64-gnu": "16.2.1", - "@next/swc-linux-x64-musl": "16.2.1", - "@next/swc-win32-arm64-msvc": "16.2.1", - "@next/swc-win32-x64-msvc": "16.2.1", + "@next/swc-darwin-arm64": "16.2.3", + "@next/swc-darwin-x64": "16.2.3", + "@next/swc-linux-arm64-gnu": "16.2.3", + "@next/swc-linux-arm64-musl": "16.2.3", + "@next/swc-linux-x64-gnu": "16.2.3", + "@next/swc-linux-x64-musl": "16.2.3", + "@next/swc-win32-arm64-msvc": "16.2.3", + "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { @@ -13941,7 +13019,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-gyp-build": { @@ -14074,7 +13152,7 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.0", @@ -14092,7 +13170,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/oauth": { @@ -14101,17 +13179,6 @@ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", "license": "MIT" }, - "node_modules/oauth4webapi": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", - "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -14231,7 +13298,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/oidc-token-hash": { @@ -14558,14 +13625,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -14670,7 +13737,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -14771,32 +13838,6 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, - "node_modules/prisma": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", - "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/config": "6.19.2", - "@prisma/engines": "6.19.2" - }, - "bin": { - "prisma": "build/index.js" - }, - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -14846,10 +13887,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -14872,7 +13916,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -14929,7 +13973,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -16346,7 +15390,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -16883,43 +15927,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index d2b63e2..d87a888 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "check-types": "turbo run check-types" }, "dependencies": { - "axios": "^1.11.0", + "axios": "^1.15.0", "zod": "^3.23.8" }, "devDependencies": { @@ -55,7 +55,10 @@ "overrides": { "zod": "^3.23.8", "@types/express": "4.17.21", - "@types/express-serve-static-core": "4.19.0" + "@types/express-serve-static-core": "4.19.0", + "next": "^16.2.3", + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.57.1" diff --git a/packages/types/src/transaction.ts b/packages/types/src/transaction.ts index cc17375..1342436 100644 --- a/packages/types/src/transaction.ts +++ b/packages/types/src/transaction.ts @@ -7,13 +7,8 @@ export const locationSchema = z.object({ export const transactionSourceSchema = z.enum(["MANUAL", "MONOBANK"]); export const transactionTypeSchema = z.enum(["INCOME", "EXPENSE"]); -export const transactionCurrencyCodeSchema = z.enum([ - "USD", - "UAH", - "RUB", - "EUR", -]); -export const manualCurrencyCodeSchema = z.enum(["USD", "UAH", "RUB"]); +export const transactionCurrencyCodeSchema = z.enum(["USD", "UAH", "EUR"]); +export const manualCurrencyCodeSchema = z.enum(["USD", "UAH", "EUR"]); export const currencyCodeSchema = transactionCurrencyCodeSchema; export const createTransactionSchema = z.object({ From 6fe10b9b18a1fd7276ca4575c77f0d0761edba2d Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 15 Apr 2026 20:32:34 +0300 Subject: [PATCH 2/4] feat(donation): add stripe checkout, webhook processing and donor AI tier --- apps/api/.env.example | 10 + apps/api/package.json | 1 + .../migration.sql | 51 +++ apps/api/prisma/schema.prisma | 73 +++- apps/api/src/app.ts | 4 + apps/api/src/config/env.ts | 34 +- apps/api/src/middleware/csrf.ts | 1 + apps/api/src/middleware/rateLimit.ts | 18 + apps/api/src/modules/ai/controller.ts | 36 +- apps/api/src/modules/ai/route.ts | 3 +- apps/api/src/modules/ai/service.ts | 134 ++++++- apps/api/src/modules/donation/controller.ts | 64 ++++ apps/api/src/modules/donation/route.ts | 31 ++ apps/api/src/modules/donation/service.ts | 338 ++++++++++++++++++ apps/api/src/routes/apiRoutes.ts | 2 + .../test/integration/auth.integration.test.ts | 6 + apps/web/next-env.d.ts | 2 +- apps/web/src/api/ai.ts | 5 + apps/web/src/api/donation.ts | 32 ++ .../analytics/_components/AiLimitPopup.tsx | 41 +++ .../analytics/_components/Analytics.tsx | 57 ++- .../(protected)/donation/DonationClient.tsx | 12 + .../donation/_components/Donation.tsx | 247 +++++++++++++ .../_components/DonationResultPopup.tsx | 30 ++ .../web/src/app/(protected)/donation/page.tsx | 11 + .../app/_components/header/FixedHeader.tsx | 1 + .../src/app/_components/header/MenuLink.tsx | 18 +- apps/web/src/hooks/useAiAccess.ts | 13 + apps/web/src/hooks/useAnalyticsAI.tsx | 5 +- .../shared/i18n/locales/de/translation.json | 37 +- .../shared/i18n/locales/en/translation.json | 37 +- .../shared/i18n/locales/uk/translation.json | 37 +- apps/web/src/styles/style.css | 9 + apps/web/src/types/ai.ts | 12 + apps/web/src/types/donation.ts | 26 ++ package-lock.json | 22 +- 36 files changed, 1429 insertions(+), 31 deletions(-) create mode 100644 apps/api/prisma/migrations/20260415153000_add_donation_and_ai_limits/migration.sql create mode 100644 apps/api/src/modules/donation/controller.ts create mode 100644 apps/api/src/modules/donation/route.ts create mode 100644 apps/api/src/modules/donation/service.ts create mode 100644 apps/web/src/api/donation.ts create mode 100644 apps/web/src/app/(protected)/analytics/_components/AiLimitPopup.tsx create mode 100644 apps/web/src/app/(protected)/donation/DonationClient.tsx create mode 100644 apps/web/src/app/(protected)/donation/_components/Donation.tsx create mode 100644 apps/web/src/app/(protected)/donation/_components/DonationResultPopup.tsx create mode 100644 apps/web/src/app/(protected)/donation/page.tsx create mode 100644 apps/web/src/hooks/useAiAccess.ts create mode 100644 apps/web/src/types/donation.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 9bea665..c99b56b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -19,3 +19,13 @@ GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" # AI apis GROQ_API_KEY_1=your_groq_api_key API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here # 32+ symb + +# Stripe donation +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_DONATION_PRICE_ID=price_xxx # optional if amount/currency is used +STRIPE_DONATION_AMOUNT=300 # in minor units, e.g. 300 = $3.00 +STRIPE_DONATION_CURRENCY=usd +STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?status=success +STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?status=cancel +STRIPE_DONATION_DURATION_DAYS=0 # 0 or empty = permanent donor diff --git a/apps/api/package.json b/apps/api/package.json index 86e3c0e..4fe2849 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -49,6 +49,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "openai": "^5.18.1", + "stripe": "^22.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "zod": "^4.1.3" diff --git a/apps/api/prisma/migrations/20260415153000_add_donation_and_ai_limits/migration.sql b/apps/api/prisma/migrations/20260415153000_add_donation_and_ai_limits/migration.sql new file mode 100644 index 0000000..9e7023b --- /dev/null +++ b/apps/api/prisma/migrations/20260415153000_add_donation_and_ai_limits/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "public"."DonationStatus" AS ENUM ('NONE', 'ACTIVE', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "public"."DonationPaymentStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'CANCELED', 'FAILED'); + +-- AlterTable +ALTER TABLE "public"."User" +ADD COLUMN "aiAnalysisUsed" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "aiAnalysisLimit" INTEGER NOT NULL DEFAULT 10, +ADD COLUMN "donationStatus" "public"."DonationStatus" NOT NULL DEFAULT 'NONE', +ADD COLUMN "donationGrantedAt" TIMESTAMP(3), +ADD COLUMN "donationExpiresAt" TIMESTAMP(3), +ADD COLUMN "stripeCustomerId" VARCHAR(255); + +-- CreateTable +CREATE TABLE "public"."DonationPayment" ( + "id" TEXT NOT NULL, + "amount" INTEGER, + "currency" VARCHAR(16), + "status" "public"."DonationPaymentStatus" NOT NULL DEFAULT 'PENDING', + "stripeCheckoutSessionId" VARCHAR(255) NOT NULL, + "stripePaymentIntentId" VARCHAR(255), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "userId" TEXT NOT NULL, + + CONSTRAINT "DonationPayment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."StripeWebhookEvent" ( + "id" TEXT NOT NULL, + "stripeEventId" VARCHAR(255) NOT NULL, + "eventType" VARCHAR(120) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "public"."User"("stripeCustomerId"); +CREATE UNIQUE INDEX "DonationPayment_stripeCheckoutSessionId_key" ON "public"."DonationPayment"("stripeCheckoutSessionId"); +CREATE UNIQUE INDEX "DonationPayment_stripePaymentIntentId_key" ON "public"."DonationPayment"("stripePaymentIntentId"); +CREATE INDEX "DonationPayment_userId_createdAt_idx" ON "public"."DonationPayment"("userId", "createdAt"); +CREATE INDEX "DonationPayment_status_createdAt_idx" ON "public"."DonationPayment"("status", "createdAt"); +CREATE UNIQUE INDEX "StripeWebhookEvent_stripeEventId_key" ON "public"."StripeWebhookEvent"("stripeEventId"); + +-- AddForeignKey +ALTER TABLE "public"."DonationPayment" ADD CONSTRAINT "DonationPayment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9be0a34..e2bc00c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -40,11 +40,24 @@ enum UserRole { ADMIN } +enum DonationStatus { + NONE + ACTIVE + EXPIRED +} + enum AiProvider { GROQ GEMINI } +enum DonationPaymentStatus { + PENDING + SUCCEEDED + CANCELED + FAILED +} + enum ErrorLogStatus { OPEN RESOLVED @@ -90,21 +103,28 @@ model Message { } model User { - id String @id @default(uuid()) - name String @db.VarChar(200) - photo_url String? - isVerified Boolean @default(false) - role UserRole @default(USER) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(uuid()) + name String @db.VarChar(200) + photo_url String? + isVerified Boolean @default(false) + role UserRole @default(USER) + aiAnalysisUsed Int @default(0) + aiAnalysisLimit Int @default(10) + donationStatus DonationStatus @default(NONE) + donationGrantedAt DateTime? + donationExpiresAt DateTime? + stripeCustomerId String? @unique @db.VarChar(255) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt // relations - authMethods AuthMethod[] - sessions Session[] - transactions Transaction[] - messages Message[] - apiKeys UserApiKey[] - errorLogs ErrorLog[] @relation("ErrorLogReporter") - resolvedLogs ErrorLog[] @relation("ErrorLogResolver") + authMethods AuthMethod[] + sessions Session[] + transactions Transaction[] + messages Message[] + apiKeys UserApiKey[] + donationPayments DonationPayment[] + errorLogs ErrorLog[] @relation("ErrorLogReporter") + resolvedLogs ErrorLog[] @relation("ErrorLogResolver") } model AuthMethod { @@ -179,3 +199,28 @@ model ErrorLog { @@index([status, createdAt]) @@index([resolvedByAdminId]) } + +model DonationPayment { + id String @id @default(uuid()) + amount Int? + currency String? @db.VarChar(16) + status DonationPaymentStatus @default(PENDING) + stripeCheckoutSessionId String @unique @db.VarChar(255) + stripePaymentIntentId String? @unique @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + // relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + @@index([userId, createdAt]) + @@index([status, createdAt]) +} + +model StripeWebhookEvent { + id String @id @default(uuid()) + stripeEventId String @unique @db.VarChar(255) + eventType String @db.VarChar(120) + createdAt DateTime @default(now()) +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6cbc4a7..d5cfe73 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -43,6 +43,10 @@ app.use( }), ); app.use(cookieParser()); +app.use( + "/api/donations/webhook", + express.raw({ type: "application/json", limit: "256kb" }), +); app.use(express.json({ limit: "32kb" })); app.use(express.urlencoded({ extended: true, limit: "32kb" })); app.use("/api", csrfProtection(allowedOrigins)); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 07bff07..b327744 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -21,7 +21,27 @@ const GROQAPITOKENS = Object.keys(process.env) .filter((token): token is string => Boolean(token)); if (GROQAPITOKENS.length === 0) { - console.warn("⚠️ Warning: No Groq API tokens (GROQ_API_KEY_x) found in .env"); + console.warn("Warning: No Groq API tokens (GROQ_API_KEY_x) found in .env"); +} + +if ( + process.env.STRIPE_DONATION_AMOUNT && + Number.isNaN(Number(process.env.STRIPE_DONATION_AMOUNT)) +) { + throw new AppError( + "Invalid STRIPE_DONATION_AMOUNT. It must be a number in minor units.", + 500, + ); +} + +if ( + process.env.STRIPE_DONATION_DURATION_DAYS && + Number.isNaN(Number(process.env.STRIPE_DONATION_DURATION_DAYS)) +) { + throw new AppError( + "Invalid STRIPE_DONATION_DURATION_DAYS. It must be a number.", + 500, + ); } export const ENV = { @@ -35,6 +55,18 @@ export const ENV = { ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET as string, GROQAPITOKENS, API_KEY_ENCRYPTION_SECRET: process.env.API_KEY_ENCRYPTION_SECRET as string, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? "", + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET ?? "", + STRIPE_DONATION_PRICE_ID: process.env.STRIPE_DONATION_PRICE_ID ?? "", + STRIPE_DONATION_SUCCESS_URL: process.env.STRIPE_DONATION_SUCCESS_URL ?? "", + STRIPE_DONATION_CANCEL_URL: process.env.STRIPE_DONATION_CANCEL_URL ?? "", + STRIPE_DONATION_CURRENCY: process.env.STRIPE_DONATION_CURRENCY ?? "usd", + STRIPE_DONATION_AMOUNT: process.env.STRIPE_DONATION_AMOUNT + ? Number(process.env.STRIPE_DONATION_AMOUNT) + : undefined, + STRIPE_DONATION_DURATION_DAYS: process.env.STRIPE_DONATION_DURATION_DAYS + ? Number(process.env.STRIPE_DONATION_DURATION_DAYS) + : undefined, } as const; export type EnvConfig = typeof ENV; diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index ccd49bc..be79094 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -23,6 +23,7 @@ export function csrfProtection(allowedOrigins: string[]) { return (req: Request, _res: Response, next: NextFunction) => { if (ENV.NODE_ENV !== "production") return next(); + if (req.path === "/donations/webhook") return next(); if (SAFE_METHODS.has(req.method)) return next(); const requestOrigin = getRequestOrigin(req); diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index dc7b814..13b4593 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -31,3 +31,21 @@ export const registrationLimiter = rateLimit({ legacyHeaders: false, message: { error: "Too many registration attempts. Try again later." }, }); + +export const donationCheckoutLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { + error: "Too many donation attempts. Please wait and try again.", + }, +}); + +export const donationWebhookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many webhook requests." }, +}); diff --git a/apps/api/src/modules/ai/controller.ts b/apps/api/src/modules/ai/controller.ts index 2ac93e5..e181e30 100644 --- a/apps/api/src/modules/ai/controller.ts +++ b/apps/api/src/modules/ai/controller.ts @@ -1,7 +1,13 @@ import type { Request, Response, NextFunction } from "express"; import { AppError } from "../../middleware/errorHandler.js"; import { prisma } from "../../prisma/client.js"; -import { getAiResponse, AiServiceError } from "./service.js"; +import { + getAiResponse, + AiServiceError, + ensureAiAccessOrThrow, + getAiAccessStatus, + incrementAiAnalysisUsage, +} from "./service.js"; export async function getAIHistory( req: Request, @@ -54,7 +60,9 @@ export async function ai(req: Request, res: Response, next: NextFunction) { if (!prompt || !data) throw new AppError("Invalid input data or prompt", 400); + const access = await ensureAiAccessOrThrow(userId); const response = await getAiResponse(userId, prompt, data, model); + await incrementAiAnalysisUsage(userId, access); return res.json(response); } catch (err) { if (err instanceof AiServiceError) { @@ -64,3 +72,29 @@ export async function ai(req: Request, res: Response, next: NextFunction) { next(err); } } + +export async function getAiAccess( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) + throw new AppError("Unauthorized: User not found in request", 401); + + const access = await getAiAccessStatus(userId); + return res.json({ + role: access.role, + tier: access.tier, + donationStatus: access.donationStatus, + donationExpiresAt: access.donationExpiresAt, + aiAnalysisUsed: access.aiAnalysisUsed, + aiAnalysisLimit: access.aiAnalysisLimit, + remainingAttempts: access.remainingAttempts, + isUnlimited: access.isUnlimited, + }); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/modules/ai/route.ts b/apps/api/src/modules/ai/route.ts index 2b7bf5c..99228a2 100644 --- a/apps/api/src/modules/ai/route.ts +++ b/apps/api/src/modules/ai/route.ts @@ -1,9 +1,10 @@ import express from "express"; import { authenticateToken } from "../auth/controller.js"; import { requireVerifiedUser } from "../../middleware/authz.js"; -import { ai, getAIHistory } from "./controller.js"; +import { ai, getAIHistory, getAiAccess } from "./controller.js"; export const aiRouter = express.Router(); aiRouter.get("/history", authenticateToken, requireVerifiedUser, getAIHistory); +aiRouter.get("/access", authenticateToken, requireVerifiedUser, getAiAccess); aiRouter.post("/", authenticateToken, requireVerifiedUser, ai); diff --git a/apps/api/src/modules/ai/service.ts b/apps/api/src/modules/ai/service.ts index 35f6a2b..a329e32 100644 --- a/apps/api/src/modules/ai/service.ts +++ b/apps/api/src/modules/ai/service.ts @@ -2,10 +2,12 @@ import OpenAI from "openai"; import { ENV } from "../../config/env.js"; import { prisma } from "../../prisma/client.js"; import { decryptApiKey } from "../../utils/crypto.js"; -import type { AiProvider } from "@prisma/client"; +import type { AiProvider, DonationStatus, UserRole } from "@prisma/client"; import type { AiErrorCode } from "@fintrack/types"; +import { AppError } from "../../middleware/errorHandler.js"; const CONTEXT_LIMIT = 20; +const DEFAULT_ANALYSIS_LIMIT = 10; export const systemContent = `You are a personal finance assistant. CRITICAL: Detect the language of the user question below and reply ONLY in that exact language. @@ -15,6 +17,19 @@ Format rules: plain text only, no markdown, no tables, no bold, no emojis, no bu type ServiceAiErrorCode = Exclude; +export type AiAccessTier = "user" | "donor" | "admin"; + +export interface AiAccessStatus { + role: UserRole; + tier: AiAccessTier; + donationStatus: DonationStatus; + donationExpiresAt: Date | null; + aiAnalysisUsed: number; + aiAnalysisLimit: number; + remainingAttempts: number | null; + isUnlimited: boolean; +} + export class AiServiceError extends Error { constructor( public readonly code: ServiceAiErrorCode, @@ -25,6 +40,123 @@ export class AiServiceError extends Error { } } +function isDonationActive(donationExpiresAt: Date | null): boolean { + if (!donationExpiresAt) return true; + return donationExpiresAt.getTime() > Date.now(); +} + +function buildAccessStatus(data: { + role: UserRole; + donationStatus: DonationStatus; + donationExpiresAt: Date | null; + aiAnalysisUsed: number; + aiAnalysisLimit: number; +}): AiAccessStatus { + const normalizedLimit = + data.aiAnalysisLimit > 0 ? data.aiAnalysisLimit : DEFAULT_ANALYSIS_LIMIT; + const donorActive = + data.donationStatus === "ACTIVE" && + isDonationActive(data.donationExpiresAt); + const isUnlimited = data.role === "ADMIN" || donorActive; + + const tier: AiAccessTier = + data.role === "ADMIN" ? "admin" : donorActive ? "donor" : "user"; + const remainingAttempts = isUnlimited + ? null + : Math.max(normalizedLimit - data.aiAnalysisUsed, 0); + + return { + role: data.role, + tier, + donationStatus: donorActive ? "ACTIVE" : data.donationStatus, + donationExpiresAt: data.donationExpiresAt, + aiAnalysisUsed: data.aiAnalysisUsed, + aiAnalysisLimit: normalizedLimit, + remainingAttempts, + isUnlimited, + }; +} + +async function syncExpiredDonationIfNeeded( + userId: string, + accessStatus: AiAccessStatus, +): Promise { + const hasExpiredDonation = + accessStatus.donationStatus === "ACTIVE" && + accessStatus.donationExpiresAt && + accessStatus.donationExpiresAt.getTime() <= Date.now(); + + if (!hasExpiredDonation) return accessStatus; + + await prisma.user.update({ + where: { id: userId }, + data: { + donationStatus: "EXPIRED", + donationExpiresAt: null, + }, + }); + + return { + ...accessStatus, + tier: accessStatus.role === "ADMIN" ? "admin" : "user", + donationStatus: "EXPIRED", + donationExpiresAt: null, + isUnlimited: accessStatus.role === "ADMIN", + remainingAttempts: + accessStatus.role === "ADMIN" + ? null + : Math.max( + accessStatus.aiAnalysisLimit - accessStatus.aiAnalysisUsed, + 0, + ), + }; +} + +export async function getAiAccessStatus( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + role: true, + donationStatus: true, + donationExpiresAt: true, + aiAnalysisUsed: true, + aiAnalysisLimit: true, + }, + }); + + if (!user) throw new AppError("User not found", 404); + + const access = buildAccessStatus(user); + return await syncExpiredDonationIfNeeded(userId, access); +} + +export async function ensureAiAccessOrThrow( + userId: string, +): Promise { + const access = await getAiAccessStatus(userId); + if (!access.isUnlimited && (access.remainingAttempts ?? 0) <= 0) { + throw new AppError( + "AI analysis limit reached. Please make a donation to unlock unlimited access.", + 403, + ); + } + return access; +} + +export async function incrementAiAnalysisUsage( + userId: string, + access: AiAccessStatus, +) { + if (access.isUnlimited) return; + + await prisma.user.update({ + where: { id: userId }, + data: { aiAnalysisUsed: { increment: 1 } }, + }); +} + async function callGroq( apiKey: string, modelToUse: string, diff --git a/apps/api/src/modules/donation/controller.ts b/apps/api/src/modules/donation/controller.ts new file mode 100644 index 0000000..daeda5c --- /dev/null +++ b/apps/api/src/modules/donation/controller.ts @@ -0,0 +1,64 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppError } from "../../middleware/errorHandler.js"; +import { + createDonationCheckoutSession, + getDonationLeaderboard, + processStripeWebhook, +} from "./service.js"; + +export async function createDonationSession( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const idempotencyKeyHeader = req.headers["x-idempotency-key"]; + const idempotencyKey = + typeof idempotencyKeyHeader === "string" + ? idempotencyKeyHeader + : undefined; + + const session = await createDonationCheckoutSession(userId, idempotencyKey); + return res.status(201).json(session); + } catch (err) { + next(err); + } +} + +export async function stripeWebhook( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const signature = req.headers["stripe-signature"]; + if (!signature || typeof signature !== "string") { + throw new AppError("Missing Stripe signature", 400); + } + + if (!Buffer.isBuffer(req.body)) { + throw new AppError("Invalid Stripe webhook payload", 400); + } + + const result = await processStripeWebhook(req.body, signature); + return res.status(200).json(result); + } catch (err) { + next(err); + } +} + +export async function donationLeaderboard( + _req: Request, + res: Response, + next: NextFunction, +) { + try { + const leaderboard = await getDonationLeaderboard(); + return res.json({ items: leaderboard }); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/modules/donation/route.ts b/apps/api/src/modules/donation/route.ts new file mode 100644 index 0000000..627840a --- /dev/null +++ b/apps/api/src/modules/donation/route.ts @@ -0,0 +1,31 @@ +import express from "express"; +import { authenticateToken } from "../auth/controller.js"; +import { requireVerifiedUser } from "../../middleware/authz.js"; +import { + donationCheckoutLimiter, + donationWebhookLimiter, +} from "../../middleware/rateLimit.js"; +import { + createDonationSession, + donationLeaderboard, + stripeWebhook, +} from "./controller.js"; + +export const donationRouter = express.Router(); + +donationRouter.post( + "/checkout-session", + donationCheckoutLimiter, + authenticateToken, + requireVerifiedUser, + createDonationSession, +); + +donationRouter.get( + "/leaderboard", + authenticateToken, + requireVerifiedUser, + donationLeaderboard, +); + +donationRouter.post("/webhook", donationWebhookLimiter, stripeWebhook); diff --git a/apps/api/src/modules/donation/service.ts b/apps/api/src/modules/donation/service.ts new file mode 100644 index 0000000..80a0e17 --- /dev/null +++ b/apps/api/src/modules/donation/service.ts @@ -0,0 +1,338 @@ +import Stripe from "stripe"; +import { Prisma } from "@prisma/client"; +import { ENV } from "../../config/env.js"; +import { prisma } from "../../prisma/client.js"; +import { AppError } from "../../middleware/errorHandler.js"; +import { logSecurityEvent } from "../../utils/authSecurity.js"; + +const DONATION_CURRENCY = (ENV.STRIPE_DONATION_CURRENCY ?? "usd").toLowerCase(); +const DONATION_AMOUNT = ENV.STRIPE_DONATION_AMOUNT; +const DONATION_DURATION_DAYS = ENV.STRIPE_DONATION_DURATION_DAYS; + +type CheckoutSessionLike = { + id: string; + metadata?: Record | null; + payment_status?: string | null; + payment_intent?: string | object | null; + amount_total?: number | null; + currency?: string | null; +}; + +let stripeClient: ReturnType | null = null; + +function initStripeClient() { + return new Stripe(ENV.STRIPE_SECRET_KEY); +} + +type CheckoutSessionCreateParams = Parameters< + ReturnType["checkout"]["sessions"]["create"] +>[0]; + +function getStripeClient() { + if (!ENV.STRIPE_SECRET_KEY) { + throw new AppError("Stripe is not configured", 503); + } + + if (!stripeClient) { + stripeClient = initStripeClient(); + } + + return stripeClient; +} + +function getDonationExpiryDate(): Date | null { + if (!DONATION_DURATION_DAYS || DONATION_DURATION_DAYS <= 0) return null; + + const expires = new Date(); + expires.setDate(expires.getDate() + DONATION_DURATION_DAYS); + return expires; +} + +async function ensureStripeCustomer(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + stripeCustomerId: true, + authMethods: { + where: { type: "EMAIL" }, + select: { email: true }, + take: 1, + }, + }, + }); + + if (!user) throw new AppError("User not found", 404); + if (user.stripeCustomerId) return user.stripeCustomerId; + + const stripe = getStripeClient(); + const email = user.authMethods[0]?.email ?? undefined; + + const customer = await stripe.customers.create({ + name: user.name, + ...(email ? { email } : {}), + metadata: { userId }, + }); + + await prisma.user.update({ + where: { id: userId }, + data: { stripeCustomerId: customer.id }, + }); + + return customer.id; +} + +function buildCheckoutSessionParams( + userId: string, + customerId: string, +): CheckoutSessionCreateParams { + const base = { + mode: "payment" as const, + customer: customerId, + success_url: ENV.STRIPE_DONATION_SUCCESS_URL, + cancel_url: ENV.STRIPE_DONATION_CANCEL_URL, + metadata: { + userId, + type: "donation", + }, + payment_intent_data: { + metadata: { + userId, + type: "donation", + }, + }, + } satisfies Omit; + + if (ENV.STRIPE_DONATION_PRICE_ID) { + return { + ...base, + line_items: [{ price: ENV.STRIPE_DONATION_PRICE_ID, quantity: 1 }], + }; + } + + if (!DONATION_AMOUNT || DONATION_AMOUNT <= 0) { + throw new AppError("Stripe donation amount is not configured", 500); + } + + return { + ...base, + line_items: [ + { + price_data: { + currency: DONATION_CURRENCY, + product_data: { + name: "FinTrack Donation", + description: "Donation with unlimited analytics AI access", + }, + unit_amount: DONATION_AMOUNT, + }, + quantity: 1, + }, + ], + }; +} + +export async function createDonationCheckoutSession( + userId: string, + idempotencyKey?: string, +) { + if (!ENV.STRIPE_DONATION_SUCCESS_URL || !ENV.STRIPE_DONATION_CANCEL_URL) { + throw new AppError("Stripe donation URLs are not configured", 500); + } + + const customerId = await ensureStripeCustomer(userId); + const stripe = getStripeClient(); + + const checkoutSession = await stripe.checkout.sessions.create( + buildCheckoutSessionParams(userId, customerId), + idempotencyKey ? { idempotencyKey } : undefined, + ); + + await prisma.donationPayment.upsert({ + where: { stripeCheckoutSessionId: checkoutSession.id }, + create: { + userId, + stripeCheckoutSessionId: checkoutSession.id, + stripePaymentIntentId: + typeof checkoutSession.payment_intent === "string" + ? checkoutSession.payment_intent + : null, + amount: checkoutSession.amount_total ?? null, + currency: checkoutSession.currency ?? null, + status: "PENDING", + }, + update: { + userId, + stripePaymentIntentId: + typeof checkoutSession.payment_intent === "string" + ? checkoutSession.payment_intent + : null, + amount: checkoutSession.amount_total ?? null, + currency: checkoutSession.currency ?? null, + status: "PENDING", + }, + }); + + return { + checkoutUrl: checkoutSession.url, + checkoutSessionId: checkoutSession.id, + }; +} + +export interface DonationLeaderboardItem { + userId: string; + name: string; + photoUrl: string | null; + totalAmountMinor: number; + currency: string; +} + +export async function getDonationLeaderboard( + limit = 20, +): Promise { + const safeLimit = Math.min(Math.max(limit, 1), 100); + const fallbackCurrency = ( + ENV.STRIPE_DONATION_CURRENCY ?? "usd" + ).toLowerCase(); + + const rows = await prisma.$queryRaw` + SELECT + u.id AS "userId", + u.name AS "name", + u.photo_url AS "photoUrl", + SUM(dp.amount)::int AS "totalAmountMinor", + LOWER(COALESCE(MAX(dp.currency), ${fallbackCurrency})) AS "currency" + FROM "DonationPayment" dp + INNER JOIN "User" u ON u.id = dp."userId" + WHERE dp.status = 'SUCCEEDED' + AND dp.amount IS NOT NULL + AND dp.amount > 0 + GROUP BY u.id, u.name, u.photo_url + ORDER BY SUM(dp.amount) DESC, MAX(dp."completedAt") DESC NULLS LAST + LIMIT ${safeLimit} + `; + + return rows; +} + +async function markDonationSucceeded(session: CheckoutSessionLike) { + const stripeCheckoutSessionId = session.id; + + const payment = await prisma.donationPayment.findUnique({ + where: { stripeCheckoutSessionId }, + select: { userId: true }, + }); + + const userId = session.metadata?.userId ?? payment?.userId; + if (!userId) { + throw new AppError("Unable to resolve user for Stripe session", 400); + } + + const expiresAt = getDonationExpiryDate(); + + await prisma.$transaction(async (tx) => { + await tx.donationPayment.upsert({ + where: { stripeCheckoutSessionId }, + create: { + userId, + stripeCheckoutSessionId, + stripePaymentIntentId: + typeof session.payment_intent === "string" + ? session.payment_intent + : null, + amount: session.amount_total ?? null, + currency: session.currency ?? null, + status: "SUCCEEDED", + completedAt: new Date(), + }, + update: { + stripePaymentIntentId: + typeof session.payment_intent === "string" + ? session.payment_intent + : null, + amount: session.amount_total ?? null, + currency: session.currency ?? null, + status: "SUCCEEDED", + completedAt: new Date(), + }, + }); + + await tx.user.update({ + where: { id: userId }, + data: { + donationStatus: "ACTIVE", + donationGrantedAt: new Date(), + donationExpiresAt: expiresAt, + }, + }); + }); + + logSecurityEvent("stripe.donation.activated", { + userId, + stripeCheckoutSessionId, + stripePaymentIntentId: + typeof session.payment_intent === "string" + ? session.payment_intent + : null, + }); +} + +async function markDonationCanceled(session: CheckoutSessionLike) { + await prisma.donationPayment.updateMany({ + where: { stripeCheckoutSessionId: session.id }, + data: { + status: "CANCELED", + }, + }); +} + +export async function processStripeWebhook(payload: Buffer, signature: string) { + if (!ENV.STRIPE_WEBHOOK_SECRET) { + throw new AppError("Stripe webhook secret is not configured", 500); + } + + const stripe = getStripeClient(); + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + payload, + signature, + ENV.STRIPE_WEBHOOK_SECRET, + ); + } catch { + throw new AppError("Invalid Stripe webhook signature", 400); + } + + try { + await prisma.stripeWebhookEvent.create({ + data: { + stripeEventId: event.id, + eventType: event.type, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { received: true, duplicate: true }; + } + throw error; + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object; + if (session.payment_status === "paid") { + await markDonationSucceeded(session); + } + } + + if (event.type === "checkout.session.expired") { + const session = event.data.object; + await markDonationCanceled(session); + } + + return { received: true, duplicate: false }; +} diff --git a/apps/api/src/routes/apiRoutes.ts b/apps/api/src/routes/apiRoutes.ts index 778d685..8e88164 100644 --- a/apps/api/src/routes/apiRoutes.ts +++ b/apps/api/src/routes/apiRoutes.ts @@ -7,6 +7,7 @@ import { aiRouter } from "../modules/ai/route.js"; import { summaryRouter } from "../modules/summary/route.js"; import { userApiKeyRouter } from "../modules/user-api-key/route.js"; import { adminRouter } from "../modules/admin/route.js"; +import { donationRouter } from "../modules/donation/route.js"; export const apiRouter = express.Router(); @@ -17,6 +18,7 @@ apiRouter.use("/ai", aiRouter); apiRouter.use("/summary", summaryRouter); apiRouter.use("/user-api-keys", userApiKeyRouter); apiRouter.use("/admin", adminRouter); +apiRouter.use("/donations", donationRouter); // apiRouter.all("*", (req: Request, res: Response, next: NextFunction) => { // res.status(404).json({ error: "Endpoint not found" }); diff --git a/apps/api/test/integration/auth.integration.test.ts b/apps/api/test/integration/auth.integration.test.ts index ad63cbb..efcafcd 100644 --- a/apps/api/test/integration/auth.integration.test.ts +++ b/apps/api/test/integration/auth.integration.test.ts @@ -13,6 +13,12 @@ const userStub: UserStub = { photo_url: null, isVerified: true, role: "USER", + aiAnalysisUsed: 0, + aiAnalysisLimit: 10, + donationStatus: "NONE", + donationGrantedAt: null, + donationExpiresAt: null, + stripeCustomerId: null, created_at: new Date(), updated_at: new Date(), authMethods: [ diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/api/ai.ts b/apps/web/src/api/ai.ts index 5307dab..eff3cf5 100644 --- a/apps/web/src/api/ai.ts +++ b/apps/web/src/api/ai.ts @@ -1,5 +1,6 @@ import api from "./api"; import { handleRequest } from "@/utils/api"; +import { AiAccessSchema, type AiAccess } from "@/types/ai"; import { type AIRequest, type AIResponse, @@ -25,3 +26,7 @@ export async function getAIHistory(): Promise { MessageFromDBSchema.array(), ); } + +export async function getAIAccess(): Promise { + return handleRequest(api.get("/ai/access"), AiAccessSchema); +} diff --git a/apps/web/src/api/donation.ts b/apps/web/src/api/donation.ts new file mode 100644 index 0000000..99b7a15 --- /dev/null +++ b/apps/web/src/api/donation.ts @@ -0,0 +1,32 @@ +import api from "./api"; +import { handleRequest } from "@/utils/api"; +import { + DonationCheckoutSessionSchema, + type DonationCheckoutSession, + DonationLeaderboardSchema, + type DonationLeaderboard, +} from "@/types/donation"; + +export async function createDonationCheckoutSession( + idempotencyKey: string, +): Promise { + return handleRequest( + api.post( + "/donations/checkout-session", + {}, + { + headers: { + "x-idempotency-key": idempotencyKey, + }, + }, + ), + DonationCheckoutSessionSchema, + ); +} + +export async function getDonationLeaderboard(): Promise { + return handleRequest( + api.get("/donations/leaderboard"), + DonationLeaderboardSchema, + ); +} diff --git a/apps/web/src/app/(protected)/analytics/_components/AiLimitPopup.tsx b/apps/web/src/app/(protected)/analytics/_components/AiLimitPopup.tsx new file mode 100644 index 0000000..855de37 --- /dev/null +++ b/apps/web/src/app/(protected)/analytics/_components/AiLimitPopup.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { usePopupStore } from "@/store/popup"; + +export function AiLimitPopup() { + const router = useRouter(); + const { close } = usePopupStore(); + + const handleGoDonation = () => { + close(); + router.push("/donation"); + }; + + return ( +
+
+ ! +
+ +
+

+ AI limit reached +

+

+ You used all free analytics attempts. Donate to unlock unlimited AI + analytics access instantly. +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx index 0b4fa32..f77a6bf 100644 --- a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx +++ b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; import { useAuth } from "@/hooks/useAuth"; import { CustomMessage, NoData, Spinner } from "@/shared/ui/Helpers"; import { motion } from "framer-motion"; @@ -10,9 +11,12 @@ import { useTransactionsAll } from "@/hooks/useTransactions"; import { useUserApiKey } from "@/hooks/useUserApiKey"; import { ApiKeyPopup } from "./ApiKeyPopup"; import { AiErrorPopup } from "./AiErrorPopup"; +import { AiLimitPopup } from "./AiLimitPopup"; import type { AIResponseWithDiff, AiErrorCode } from "@/types/ai"; import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; import { usePopupStore } from "@/store/popup"; +import { useAiAccess } from "@/hooks/useAiAccess"; +import type { ApiError } from "@/types/api"; export function Analytics() { const { t } = useSafeTranslation(); @@ -23,6 +27,10 @@ export function Analytics() { const { user, isLoading } = useAuth(); const { hasActiveKey } = useUserApiKey(); const { open } = usePopupStore(); + const { data: access } = useAiAccess(); + const isLimitReached = access + ? !access.isUnlimited && (access.remainingAttempts ?? 0) <= 0 + : false; const { data: transactionData, @@ -79,6 +87,12 @@ export function Analytics() { }); setPrompt(""); } catch (err: unknown) { + const statusCode = (err as ApiError)?.code; + if (statusCode === 403) { + open("AI limit reached", ); + return; + } + const code = (err as { backendCode?: unknown; code?: unknown }) ?.backendCode; const fallbackCode = (err as { code?: unknown })?.code; @@ -96,6 +110,7 @@ export function Analytics() { handleOpenErrorPopup, hasActiveKey, hasShownDefaultKeyNotice, + open, prompt, transactionData, ]); @@ -164,6 +179,46 @@ export function Analytics() { +
+ {access?.isUnlimited ? ( +

+ Unlimited AI analytics access is active. +

+ ) : ( +
+

+ Remaining attempts:{" "} + + {access?.remainingAttempts ?? 0} + {" "} + of {access?.aiAnalysisLimit ?? 10} +

+ + Donation Stripe + +
+ )} +
+ + {isLimitReached && ( +
+
+

+ Free AI limit is exhausted. Donate to unlock unlimited access. +

+ + Open Donation Stripe + +
+
+ )} + {isLoadingAI && (
@@ -240,7 +295,7 @@ export function Analytics() { + + +
+

+ {t("donation.accessStatusTitle")} +

+

+ {t("donation.currentTier")}:{" "} + + {tierLabel} + +

+ + {access?.isUnlimited ? ( +

+ {t("donation.unlimitedActive")} +

+ ) : ( +

+ {t("donation.remainingAttempts", { + remaining: access?.remainingAttempts ?? 0, + limit: access?.aiAnalysisLimit ?? 10, + })} +

+ )} + +
+ {t("donation.faq")} +
+
+
+ +
+

+ {t("donation.leaderboardTitle")} +

+

+ {t("donation.leaderboardSubtitle")} +

+ + {leaderboardItems.length > 0 && !leaderboardQuery.isLoading && ( +
+
1 + ? "marqueeScroll 28s linear infinite" + : "none", + }} + > + {marqueeItems.map((item, index) => ( +
+
+
+ {item.photoUrl ? ( + {item.name} + ) : ( +
+ {item.name.slice(0, 2).toUpperCase()} +
+ )} +
+

+ {item.name} +

+

+ {t("donation.totalDonated")} +

+
+
+ +

+ {formatDonationAmount( + item.totalAmountMinor, + item.currency, + )} +

+
+
+ ))} +
+
+ )} +
+ + ); +} diff --git a/apps/web/src/app/(protected)/donation/_components/DonationResultPopup.tsx b/apps/web/src/app/(protected)/donation/_components/DonationResultPopup.tsx new file mode 100644 index 0000000..da913f9 --- /dev/null +++ b/apps/web/src/app/(protected)/donation/_components/DonationResultPopup.tsx @@ -0,0 +1,30 @@ +"use client"; + +type DonationResultType = "success" | "error"; + +interface DonationResultPopupProps { + type: DonationResultType; + title: string; + message: string; +} + +export function DonationResultPopup({ + type, + title, + message, +}: DonationResultPopupProps) { + return ( +
+

+ {title} +

+

+ {message} +

+
+ ); +} diff --git a/apps/web/src/app/(protected)/donation/page.tsx b/apps/web/src/app/(protected)/donation/page.tsx new file mode 100644 index 0000000..578b534 --- /dev/null +++ b/apps/web/src/app/(protected)/donation/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from "react"; +import { DonationClient } from "./DonationClient"; +import { Spinner } from "@/shared/ui/Helpers"; + +export default function DonationPage() { + return ( + }> + + + ); +} diff --git a/apps/web/src/app/_components/header/FixedHeader.tsx b/apps/web/src/app/_components/header/FixedHeader.tsx index cd17111..9a8a3ec 100644 --- a/apps/web/src/app/_components/header/FixedHeader.tsx +++ b/apps/web/src/app/_components/header/FixedHeader.tsx @@ -91,6 +91,7 @@ export function FixedHeader() { + {user?.role === "ADMIN" && }
diff --git a/apps/web/src/app/_components/header/MenuLink.tsx b/apps/web/src/app/_components/header/MenuLink.tsx index 5016f5c..c89cc87 100644 --- a/apps/web/src/app/_components/header/MenuLink.tsx +++ b/apps/web/src/app/_components/header/MenuLink.tsx @@ -13,16 +13,19 @@ export function MenuLink({ name }: { name: string }) { const isActive = pathname.endsWith(`/${name}`); const monobankLabel = t("nav.monobank"); const adminLabel = t("nav.admin"); + const donationLabel = t("nav.donation"); const label = name === "admin" ? adminLabel === "nav.admin" ? "Admin Panel" : adminLabel - : name === "monobank" - ? monobankLabel === "nav.monobank" - ? "Monobank API" - : monobankLabel - : t(`nav.${name}`); + : name === "donation" + ? donationLabel + : name === "monobank" + ? monobankLabel === "nav.monobank" + ? "Monobank API" + : monobankLabel + : t(`nav.${name}`); const handleClick = () => { if (isMobile) closeBurger(); @@ -50,6 +53,11 @@ export function MenuLink({ name }: { name: string }) { AD )} + {name === "donation" && ( + + DS + + )} {label} diff --git a/apps/web/src/hooks/useAiAccess.ts b/apps/web/src/hooks/useAiAccess.ts new file mode 100644 index 0000000..059c23d --- /dev/null +++ b/apps/web/src/hooks/useAiAccess.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAIAccess } from "@/api/ai"; +import { useAuth } from "./useAuth"; + +export function useAiAccess() { + const { user } = useAuth(); + + return useQuery({ + queryKey: ["ai-access", user?.id], + queryFn: getAIAccess, + enabled: !!user, + }); +} diff --git a/apps/web/src/hooks/useAnalyticsAI.tsx b/apps/web/src/hooks/useAnalyticsAI.tsx index 562f0cf..3d64651 100644 --- a/apps/web/src/hooks/useAnalyticsAI.tsx +++ b/apps/web/src/hooks/useAnalyticsAI.tsx @@ -47,7 +47,10 @@ export function useAnalyticsAI() { }; }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey }); + void Promise.all([ + queryClient.invalidateQueries({ queryKey }), + queryClient.invalidateQueries({ queryKey: ["ai-access"] }), + ]); }, }); diff --git a/apps/web/src/shared/i18n/locales/de/translation.json b/apps/web/src/shared/i18n/locales/de/translation.json index 30fee5c..71afa0f 100644 --- a/apps/web/src/shared/i18n/locales/de/translation.json +++ b/apps/web/src/shared/i18n/locales/de/translation.json @@ -4,7 +4,8 @@ "analytics": "Analytik", "transactions": "Transaktionen", "monobank": "Monobank API", - "admin": "Admin-Panel" + "admin": "Admin-Panel", + "donation": "Donation Stripe" }, "common": { "loading": "Laedt...", @@ -203,5 +204,39 @@ "verificationBreakdown": "Verifizierungsverteilung", "activity14d": "Neue Benutzer und Fehler (14 Tage)" } + }, + "donation": { + "notLoggedIn": "Bitte melden Sie sich an, um die Seite Donation Stripe zu oeffnen.", + "pageTitle": "Donation Stripe", + "pageDescription": "Spenden helfen uns, FinTrack weiterzuentwickeln, und schalten unbegrenzte AI-Analysen fuer Ihr Konto frei.", + "benefitsTitle": "Was Sie erhalten", + "benefit1": "Unbegrenzte AI-Anfragen in der Analytik.", + "benefit2": "Priorisierte Unterstuetzung der Projektentwicklung.", + "benefit3": "Schnelle Aktivierung nach Stripe-Webhook-Bestaetigung.", + "donateButton": "Mit Stripe spenden", + "redirectingButton": "Weiterleitung zu Stripe...", + "accessStatusTitle": "Zugriffsstatus", + "leaderboardTitle": "Top-Spender", + "leaderboardSubtitle": "Live-Ranking nach gesamter Spendensumme.", + "leaderboardEmpty": "Noch keine Spenden. Werden Sie der erste Unterstuetzer.", + "totalDonated": "Insgesamt gespendet", + "currentTier": "Aktueller Plan", + "unlimitedActive": "Unbegrenzter Zugriff ist aktiv.", + "remainingAttempts": "Verbleibende AI-Versuche: {{remaining}} von {{limit}}", + "faq": "FAQ: Der Zugriff wird durch das Webhook-Event checkout.session.completed aktiviert. Wenn Sie die Seite geschlossen haben, oeffnen Sie die Analytik nach einigen Sekunden erneut.", + "failedTitle": "Spende fehlgeschlagen", + "failedPopupTitle": "Checkout-Session konnte nicht erstellt werden", + "failedPopupMessage": "Bitte versuchen Sie es in einigen Sekunden erneut.", + "successTitle": "Spende erfolgreich", + "successPopupTitle": "Vielen Dank fuer Ihre Unterstuetzung", + "successPopupMessage": "Die Zahlung wurde akzeptiert. Unbegrenzter AI-Zugriff wird nach Webhook-Bestaetigung aktiviert.", + "canceledTitle": "Spende abgebrochen", + "canceledPopupTitle": "Zahlung wurde abgebrochen", + "canceledPopupMessage": "Es wurde nichts abgebucht. Sie koennen die Spende jederzeit erneut starten.", + "tier": { + "admin": "Admin unbegrenzt", + "donor": "Spender unbegrenzt", + "user": "Kostenloser Plan" + } } } diff --git a/apps/web/src/shared/i18n/locales/en/translation.json b/apps/web/src/shared/i18n/locales/en/translation.json index e80eb2f..d0cf823 100644 --- a/apps/web/src/shared/i18n/locales/en/translation.json +++ b/apps/web/src/shared/i18n/locales/en/translation.json @@ -4,7 +4,8 @@ "analytics": "Analytics", "transactions": "Transactions", "monobank": "Monobank API", - "admin": "Admin Panel" + "admin": "Admin Panel", + "donation": "Donation Stripe" }, "common": { "loading": "Loading...", @@ -203,5 +204,39 @@ "verificationBreakdown": "Verification breakdown", "activity14d": "New users and errors (14 days)" } + }, + "donation": { + "notLoggedIn": "Please log in to open the Donation Stripe page.", + "pageTitle": "Donation Stripe", + "pageDescription": "Donations help us maintain FinTrack and unlock unlimited AI analytics for your account.", + "benefitsTitle": "What you get", + "benefit1": "Unlimited AI requests in Analytics.", + "benefit2": "Priority support of ongoing project development.", + "benefit3": "Fast activation after Stripe webhook confirmation.", + "donateButton": "Donate via Stripe", + "redirectingButton": "Redirecting to Stripe...", + "accessStatusTitle": "Access status", + "leaderboardTitle": "Top Donors", + "leaderboardSubtitle": "Live ranking by total donation amount.", + "leaderboardEmpty": "No donations yet. Be the first supporter.", + "totalDonated": "Total donated", + "currentTier": "Current tier", + "unlimitedActive": "Unlimited access is active.", + "remainingAttempts": "Remaining AI attempts: {{remaining}} of {{limit}}", + "faq": "FAQ: Access is activated by webhook event checkout.session.completed. If you closed the page, just reopen Analytics after a few seconds.", + "failedTitle": "Donation failed", + "failedPopupTitle": "Failed to create checkout session", + "failedPopupMessage": "Please try again in a few seconds.", + "successTitle": "Donation success", + "successPopupTitle": "Thank you for your support", + "successPopupMessage": "Payment was accepted. Unlimited AI analytics access will be activated after webhook confirmation.", + "canceledTitle": "Donation canceled", + "canceledPopupTitle": "Payment was canceled", + "canceledPopupMessage": "No charge was made. You can start donation again whenever you want.", + "tier": { + "admin": "Admin unlimited", + "donor": "Donor unlimited", + "user": "Free user plan" + } } } diff --git a/apps/web/src/shared/i18n/locales/uk/translation.json b/apps/web/src/shared/i18n/locales/uk/translation.json index 1a6cfdc..d400ea9 100644 --- a/apps/web/src/shared/i18n/locales/uk/translation.json +++ b/apps/web/src/shared/i18n/locales/uk/translation.json @@ -3,7 +3,8 @@ "dashboard": "Дашборд", "analytics": "Аналітика", "transactions": "Транзакції", - "admin": "Панель адміна" + "admin": "Панель адміна", + "donation": "Donation Stripe" }, "common": { "loading": "Завантаження...", @@ -202,5 +203,39 @@ "verificationBreakdown": "Розподіл верифікації", "activity14d": "Нові користувачі та помилки (14 днів)" } + }, + "donation": { + "notLoggedIn": "Будь ласка, увійдіть у свій акаунт, щоб відкрити сторінку Donation Stripe.", + "pageTitle": "Donation Stripe", + "pageDescription": "Донати допомагають підтримувати FinTrack і відкривають необмежений AI-аналіз для вашого акаунта.", + "benefitsTitle": "Що ви отримуєте", + "benefit1": "Необмежені AI-запити в Аналітиці.", + "benefit2": "Пріоритетну підтримку розвитку проєкту.", + "benefit3": "Швидку активацію після підтвердження webhook Stripe.", + "donateButton": "Задонатити через Stripe", + "redirectingButton": "Переадресація до Stripe...", + "accessStatusTitle": "Статус доступу", + "leaderboardTitle": "Топ донатерів", + "leaderboardSubtitle": "Живий рейтинг за сумою донатів.", + "leaderboardEmpty": "Поки немає донатів. Станьте першим підтримувачем.", + "totalDonated": "Задоначено всього", + "currentTier": "Поточний план", + "unlimitedActive": "Необмежений доступ активний.", + "remainingAttempts": "Залишилось AI-спроб: {{remaining}} з {{limit}}", + "faq": "FAQ: Доступ активується подією webhook checkout.session.completed. Якщо ви закрили сторінку, просто відкрийте Аналітику через кілька секунд.", + "failedTitle": "Помилка донату", + "failedPopupTitle": "Не вдалося створити checkout session", + "failedPopupMessage": "Спробуйте ще раз через кілька секунд.", + "successTitle": "Донат успішний", + "successPopupTitle": "Дякуємо за підтримку", + "successPopupMessage": "Платіж прийнято. Необмежений AI-доступ активується після підтвердження webhook.", + "canceledTitle": "Донат скасовано", + "canceledPopupTitle": "Оплату скасовано", + "canceledPopupMessage": "Списання не відбулося. Ви можете повторити донат у будь-який момент.", + "tier": { + "admin": "Адмін без ліміту", + "donor": "Донор без ліміту", + "user": "Безкоштовний план" + } } } diff --git a/apps/web/src/styles/style.css b/apps/web/src/styles/style.css index 21808b4..0c51427 100644 --- a/apps/web/src/styles/style.css +++ b/apps/web/src/styles/style.css @@ -81,3 +81,12 @@ h3 { overflow: hidden; height: 100vh; } + +@keyframes marqueeScroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} diff --git a/apps/web/src/types/ai.ts b/apps/web/src/types/ai.ts index d18eab8..8312f5a 100644 --- a/apps/web/src/types/ai.ts +++ b/apps/web/src/types/ai.ts @@ -11,5 +11,17 @@ export const AIResponseWithDiffSchema = AIResponseSchema.extend({ export const AIHistorySchema = z.array(AIResponseWithDiffSchema); +export const AiAccessSchema = z.object({ + role: z.enum(["USER", "ADMIN"]), + tier: z.enum(["user", "donor", "admin"]), + donationStatus: z.enum(["NONE", "ACTIVE", "EXPIRED"]), + donationExpiresAt: z.string().datetime().nullable(), + aiAnalysisUsed: z.number().int().nonnegative(), + aiAnalysisLimit: z.number().int().positive(), + remainingAttempts: z.number().int().nonnegative().nullable(), + isUnlimited: z.boolean(), +}); + export type AIResponseWithDiff = z.infer; export type AIHistory = z.infer; +export type AiAccess = z.infer; diff --git a/apps/web/src/types/donation.ts b/apps/web/src/types/donation.ts new file mode 100644 index 0000000..6e21e1f --- /dev/null +++ b/apps/web/src/types/donation.ts @@ -0,0 +1,26 @@ +import z from "zod"; + +export const DonationCheckoutSessionSchema = z.object({ + checkoutUrl: z.string().url(), + checkoutSessionId: z.string(), +}); + +export const DonationLeaderboardItemSchema = z.object({ + userId: z.string(), + name: z.string(), + photoUrl: z.string().nullable(), + totalAmountMinor: z.number().int().nonnegative(), + currency: z.string(), +}); + +export const DonationLeaderboardSchema = z.object({ + items: z.array(DonationLeaderboardItemSchema), +}); + +export type DonationCheckoutSession = z.infer< + typeof DonationCheckoutSessionSchema +>; +export type DonationLeaderboardItem = z.infer< + typeof DonationLeaderboardItemSchema +>; +export type DonationLeaderboard = z.infer; diff --git a/package-lock.json b/package-lock.json index 58000ca..d696436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "openai": "^5.18.1", + "stripe": "^22.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "zod": "^4.1.3" @@ -6870,7 +6871,7 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -15085,6 +15086,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.1.tgz", + "integrity": "sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -15871,7 +15889,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { From 4862a27c7786791d312163c2b9ae13471f247cde Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 15 Apr 2026 21:01:14 +0300 Subject: [PATCH 3/4] ci: add PR checks (lint, format, types, tests) --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++ .github/workflows/deploy.yml | 38 ------------------------------------ package.json | 5 +++-- turbo.json | 35 +++++++++++++++++++++++++++------ 4 files changed, 67 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92d1a55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI — Lint, Format, Types, Tests + +on: + pull_request: + branches: + - master + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Format check + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Type check + run: npm run check-types + + - name: Run API tests + run: npm --prefix apps/api run test \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 8676202..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Build and Deploy to GitHub Pages - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - - steps: - # Крок 1: Завантажуємо код з репозиторію - - name: Checkout code - uses: actions/checkout@v3 - - # Крок 2: Налаштовуємо Node.js середовище та кешування - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - cache: 'npm' - - # Крок 3: Встановлюємо залежності з кореня проєкту - - name: Install dependencies - run: npm ci - - # Крок 4: Створюємо білд для веб-додатку - - name: Build the web app - run: npx turbo run build --filter=fintrack-web - - # Крок 5: Деплой на gh-pages - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./apps/web/dist - publish_branch: gh-pages \ No newline at end of file diff --git a/package.json b/package.json index d87a888..5cf834d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "format:check": "prettier --check .", "lint": "eslint .", "check": "npm run format:check && npm run lint", - "check-types": "turbo run check-types" + "check-types": "turbo run check-types", + "test:api": "npm --prefix apps/api run test" }, "dependencies": { "axios": "^1.15.0", @@ -63,4 +64,4 @@ "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.57.1" } -} +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 13745da..25d96bd 100644 --- a/turbo.json +++ b/turbo.json @@ -3,19 +3,42 @@ "ui": "tui", "tasks": { "build": { - "dependsOn": ["^build"], - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "inputs": [ + "$TURBO_DEFAULT$", + ".env*" + ], + "outputs": [ + "dist/**", + ".next/**", + "!.next/cache/**" + ] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": [ + "^lint" + ] }, "check-types": { - "dependsOn": ["^check-types"] + "dependsOn": [ + "^check-types" + ] }, "dev": { "cache": false, "persistent": true + }, + "test": { + "dependsOn": [ + "^build" + ], + "inputs": [ + "src/**/*.ts", + "test/**/*.ts", + "jest.config.cjs" + ] } } -} +} \ No newline at end of file From ffd6db7c118ed546918af4245562bccb783fdd4a Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 15 Apr 2026 23:00:07 +0300 Subject: [PATCH 4/4] ci: add CI pipeline with PostgreSQL, lint, format, types, tests; fix ESM mock compatibility using jest.unstable_mockModule and jest.mocked --- .github/workflows/ci.yml | 27 +++++- apps/api/jest.config.cjs | 2 +- apps/api/package.json | 2 +- apps/api/src/config/env.ts | 2 +- .../integration/admin.integration.test.ts | 82 +++++++++++++------ .../test/integration/auth.integration.test.ts | 76 +++++++++++------ .../test/integration/user.integration.test.ts | 59 ++++++++++--- apps/api/test/setup.cjs | 4 + apps/api/test/tsconfig.json | 2 +- 9 files changed, 188 insertions(+), 68 deletions(-) create mode 100644 apps/api/test/setup.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92d1a55..9c027f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,21 @@ jobs: check: runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: fintrack_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v3 @@ -31,5 +46,15 @@ jobs: - name: Type check run: npm run check-types + - name: Generate Prisma client + run: npm run api:prisma:generate + + - name: Run migrations + run: npm run api:prisma:migrate:deploy + env: + DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test + - name: Run API tests - run: npm --prefix apps/api run test \ No newline at end of file + run: npm --prefix apps/api run test + env: + DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test \ No newline at end of file diff --git a/apps/api/jest.config.cjs b/apps/api/jest.config.cjs index 43e7437..1960c36 100644 --- a/apps/api/jest.config.cjs +++ b/apps/api/jest.config.cjs @@ -6,8 +6,8 @@ module.exports = { testMatch: ["**/*.test.ts"], extensionsToTreatAsEsm: [".ts"], setupFiles: ["/test/jest.setup.cjs"], + setupFilesAfterEnv: ["/test/setup.cjs"], clearMocks: true, - restoreMocks: true, moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", "^@fintrack/types$": "/../../packages/types/src/index.ts", diff --git a/apps/api/package.json b/apps/api/package.json index 4fe2849..9fb65fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "rimraf dist && npx tsc", "start": "node dist/server.js", - "test": "jest --runInBand", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand", "test:watch": "jest --watch", "predev": "npm run build", "dev": "concurrently \"npx tsc -w\" \"nodemon --ext js,yml dist/server.js\"", diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index b327744..36bbd8b 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -20,7 +20,7 @@ const GROQAPITOKENS = Object.keys(process.env) .map((key) => process.env[key]) .filter((token): token is string => Boolean(token)); -if (GROQAPITOKENS.length === 0) { +if (GROQAPITOKENS.length === 0 && process.env.NODE_ENV !== "test") { console.warn("Warning: No Groq API tokens (GROQ_API_KEY_x) found in .env"); } diff --git a/apps/api/test/integration/admin.integration.test.ts b/apps/api/test/integration/admin.integration.test.ts index 0229d4c..b0ebe93 100644 --- a/apps/api/test/integration/admin.integration.test.ts +++ b/apps/api/test/integration/admin.integration.test.ts @@ -1,13 +1,43 @@ +import { jest } from "@jest/globals"; import request from "supertest"; -import { app } from "../../src/app.js"; -import { generateAccessToken } from "../../src/modules/auth/controller.js"; -import * as adminService from "../../src/modules/admin/service.js"; -import * as authService from "../../src/modules/auth/service.js"; + +import type { app as AppType } from "../../src/app.js"; +import type * as AdminServiceTypes from "../../src/modules/admin/service.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; +import type { generateAccessToken as GenerateAccessTokenType } from "../../src/modules/auth/controller.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + loginWithGoogle: jest.fn(), + createSession: jest.fn(), +})); + +jest.unstable_mockModule("../../src/modules/admin/service.js", () => ({ + getAdminStats: jest.fn(), + reportErrorLog: jest.fn(), + revokeUserSessions: jest.fn(), + revokeAllSessions: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; +let adminService: typeof AdminServiceTypes; +let generateAccessToken: typeof GenerateAccessTokenType; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); + adminService = await import("../../src/modules/admin/service.js"); + ({ generateAccessToken } = + await import("../../src/modules/auth/controller.js")); +}); describe("Admin Integration", () => { beforeEach(() => { - jest.restoreAllMocks(); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + jest.resetAllMocks(); + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "e5af2f58-5f09-4c64-8e13-f5b9323248d0", userId: "1772f0ba-450d-4b81-bb7c-df6f0a7483c3", revokedAt: null, @@ -31,7 +61,8 @@ describe("Admin Integration", () => { isVerified: true, sessionId: "86f97690-d689-4fd8-9bf9-5fbf9f4cce59", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "86f97690-d689-4fd8-9bf9-5fbf9f4cce59", userId: "6e562f73-df6c-4ef0-b830-cdc5e4cd43ef", revokedAt: null, @@ -56,7 +87,7 @@ describe("Admin Integration", () => { sessionId: "e5af2f58-5f09-4c64-8e13-f5b9323248d0", }); - jest.spyOn(adminService, "getAdminStats").mockResolvedValue({ + jest.mocked(adminService.getAdminStats).mockResolvedValue({ users: { total: 10, admins: 1, verified: 8, newLast7Days: 3 }, sessions: { active: 5 }, errors: { open: 2 }, @@ -81,21 +112,20 @@ describe("Admin Integration", () => { isVerified: true, sessionId: "f2945050-c0f7-4a4b-a117-775370a2fed0", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "f2945050-c0f7-4a4b-a117-775370a2fed0", userId, revokedAt: null, expiresAt: new Date(Date.now() + 60_000), }); - const createSpy = jest - .spyOn(adminService, "reportErrorLog") - .mockResolvedValue({ - id: "87eecaf5-00d5-46b4-9f55-51f0402bc201", - title: "Dashboard crash", - status: "OPEN", - createdAt: new Date("2026-04-08T15:00:00.000Z"), - } as Awaited>); + jest.mocked(adminService.reportErrorLog).mockResolvedValue({ + id: "87eecaf5-00d5-46b4-9f55-51f0402bc201", + title: "Dashboard crash", + status: "OPEN", + createdAt: new Date("2026-04-08T15:00:00.000Z"), + } as Awaited>); const response = await request(app) .post("/api/admin/error-logs/report") @@ -107,7 +137,7 @@ describe("Admin Integration", () => { }); expect(response.status).toBe(201); - expect(createSpy).toHaveBeenCalledWith( + expect(adminService.reportErrorLog).toHaveBeenCalledWith( expect.objectContaining({ userId, title: "Dashboard crash", @@ -128,15 +158,15 @@ describe("Admin Integration", () => { sessionId: "2f06ee39-6fb3-483c-9a7c-20ef60eafbce", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "2f06ee39-6fb3-483c-9a7c-20ef60eafbce", userId: adminId, revokedAt: null, expiresAt: new Date(Date.now() + 60_000), }); - const revokeSpy = jest - .spyOn(adminService, "revokeUserSessions") + jest + .mocked(adminService.revokeUserSessions) .mockResolvedValue({ revokedCount: 3 }); const response = await request(app) @@ -145,7 +175,7 @@ describe("Admin Integration", () => { expect(response.status).toBe(200); expect(response.body.revokedCount).toBe(3); - expect(revokeSpy).toHaveBeenCalledWith(targetUserId); + expect(adminService.revokeUserSessions).toHaveBeenCalledWith(targetUserId); }); it("revokes sessions for all users", async () => { @@ -159,15 +189,15 @@ describe("Admin Integration", () => { sessionId: "6fba9450-ee14-4e5f-89f0-112d5a9883d0", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "6fba9450-ee14-4e5f-89f0-112d5a9883d0", userId: adminId, revokedAt: null, expiresAt: new Date(Date.now() + 60_000), }); - const revokeSpy = jest - .spyOn(adminService, "revokeAllSessions") + jest + .mocked(adminService.revokeAllSessions) .mockResolvedValue({ revokedCount: 7 }); const response = await request(app) @@ -176,6 +206,6 @@ describe("Admin Integration", () => { expect(response.status).toBe(200); expect(response.body.revokedCount).toBe(7); - expect(revokeSpy).toHaveBeenCalled(); + expect(adminService.revokeAllSessions).toHaveBeenCalled(); }); }); diff --git a/apps/api/test/integration/auth.integration.test.ts b/apps/api/test/integration/auth.integration.test.ts index efcafcd..52b591b 100644 --- a/apps/api/test/integration/auth.integration.test.ts +++ b/apps/api/test/integration/auth.integration.test.ts @@ -1,9 +1,38 @@ +import { jest } from "@jest/globals"; import request from "supertest"; -import { app } from "../../src/app.js"; -import { AppError } from "../../src/middleware/errorHandler.js"; -import { generateAccessToken } from "../../src/modules/auth/controller.js"; -import * as authService from "../../src/modules/auth/service.js"; -import * as userService from "../../src/modules/user/service.js"; + +import type { app as AppType } from "../../src/app.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; +import type * as UserServiceTypes from "../../src/modules/user/service.js"; +import type { AppError as AppErrorType } from "../../src/middleware/errorHandler.js"; +import type { generateAccessToken as GenerateAccessTokenType } from "../../src/modules/auth/controller.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + loginWithGoogle: jest.fn(), + createSession: jest.fn(), +})); + +jest.unstable_mockModule("../../src/modules/user/service.js", () => ({ + getUser: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; +let userService: typeof UserServiceTypes; +let generateAccessToken: typeof GenerateAccessTokenType; +let AppError: typeof AppErrorType; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); + userService = await import("../../src/modules/user/service.js"); + ({ generateAccessToken } = + await import("../../src/modules/auth/controller.js")); + ({ AppError } = await import("../../src/middleware/errorHandler.js")); +}); type UserStub = NonNullable>>; @@ -34,8 +63,8 @@ const userStub: UserStub = { describe("Auth Integration", () => { beforeEach(() => { - jest.restoreAllMocks(); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + jest.resetAllMocks(); + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", userId: userStub.id, revokedAt: null, @@ -73,12 +102,10 @@ describe("Auth Integration", () => { userId: userStub.id, }; - const findSessionSpy = jest - .spyOn(authService, "findSessionByTokenHash") + jest + .mocked(authService.findSessionByTokenHash) .mockResolvedValue(revokedSession); - const revokeFamilySpy = jest - .spyOn(authService, "revokeSessionFamily") - .mockResolvedValue(); + jest.mocked(authService.revokeSessionFamily).mockResolvedValue(undefined); const response = await request(app) .post("/api/auth/token") @@ -86,8 +113,8 @@ describe("Auth Integration", () => { expect(response.status).toBe(401); expect(response.body.error).toBe("Refresh token reuse detected"); - expect(findSessionSpy).toHaveBeenCalled(); - expect(revokeFamilySpy).toHaveBeenCalledWith("fam-123"); + expect(authService.findSessionByTokenHash).toHaveBeenCalled(); + expect(authService.revokeSessionFamily).toHaveBeenCalledWith("fam-123"); }); it("returns 400 for /api/auth/google/exchange with invalid payload", async () => { @@ -111,7 +138,7 @@ describe("Auth Integration", () => { } as Response); jest - .spyOn(authService, "loginWithGoogle") + .mocked(authService.loginWithGoogle) .mockRejectedValue(new AppError("Item already exists", 409)); const response = await request(app) @@ -134,8 +161,8 @@ describe("Auth Integration", () => { }), } as Response); - jest.spyOn(authService, "loginWithGoogle").mockResolvedValue(userStub); - jest.spyOn(authService, "createSession").mockResolvedValue({ + jest.mocked(authService.loginWithGoogle).mockResolvedValue(userStub); + jest.mocked(authService.createSession).mockResolvedValue({ sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", tokenHash: "hash", familyId: "fam-1", @@ -156,6 +183,7 @@ describe("Auth Integration", () => { expect(response.status).toBe(200); expect(response.body).toEqual({ authenticated: true }); + const setCookieHeader = response.headers["set-cookie"]; const cookies = Array.isArray(setCookieHeader) ? setCookieHeader @@ -163,12 +191,12 @@ describe("Auth Integration", () => { ? [setCookieHeader] : []; - expect( - cookies.some((cookie) => cookie.includes("fintrack_access_token=")), - ).toBe(true); - expect( - cookies.some((cookie) => cookie.includes("fintrack_refresh_token=")), - ).toBe(true); + expect(cookies.some((c) => c.includes("fintrack_access_token="))).toBe( + true, + ); + expect(cookies.some((c) => c.includes("fintrack_refresh_token="))).toBe( + true, + ); }); it("returns 200 for /api/users/me with valid access token cookie", async () => { @@ -181,7 +209,7 @@ describe("Auth Integration", () => { sessionId: "e6594ef2-7a59-4f7a-99f9-862758f624b2", }); - jest.spyOn(userService, "getUser").mockResolvedValue(userStub); + jest.mocked(userService.getUser).mockResolvedValue(userStub); const response = await request(app) .get("/api/users/me") diff --git a/apps/api/test/integration/user.integration.test.ts b/apps/api/test/integration/user.integration.test.ts index cfad362..1bc82a6 100644 --- a/apps/api/test/integration/user.integration.test.ts +++ b/apps/api/test/integration/user.integration.test.ts @@ -1,13 +1,43 @@ +import { jest } from "@jest/globals"; import request from "supertest"; -import { app } from "../../src/app.js"; -import { generateAccessToken } from "../../src/modules/auth/controller.js"; -import * as userService from "../../src/modules/user/service.js"; -import * as authService from "../../src/modules/auth/service.js"; -import { AppError } from "../../src/middleware/errorHandler.js"; + +import type { app as AppType } from "../../src/app.js"; +import type * as UserServiceTypes from "../../src/modules/user/service.js"; +import type * as AuthServiceTypes from "../../src/modules/auth/service.js"; +import type { AppError as AppErrorType } from "../../src/middleware/errorHandler.js"; +import type { generateAccessToken as GenerateAccessTokenType } from "../../src/modules/auth/controller.js"; + +jest.unstable_mockModule("../../src/modules/auth/service.js", () => ({ + findSessionById: jest.fn(), + findSessionByTokenHash: jest.fn(), + revokeSessionFamily: jest.fn(), + loginWithGoogle: jest.fn(), + createSession: jest.fn(), +})); + +jest.unstable_mockModule("../../src/modules/user/service.js", () => ({ + getUser: jest.fn(), + deleteAuthMethod: jest.fn(), +})); + +let app: typeof AppType; +let authService: typeof AuthServiceTypes; +let userService: typeof UserServiceTypes; +let generateAccessToken: typeof GenerateAccessTokenType; +let AppError: typeof AppErrorType; + +beforeAll(async () => { + ({ app } = await import("../../src/app.js")); + authService = await import("../../src/modules/auth/service.js"); + userService = await import("../../src/modules/user/service.js"); + ({ generateAccessToken } = + await import("../../src/modules/auth/controller.js")); + ({ AppError } = await import("../../src/middleware/errorHandler.js")); +}); describe("User Integration", () => { beforeEach(() => { - jest.restoreAllMocks(); + jest.resetAllMocks(); }); it("uses current user id from token for deleting auth method (/me path)", async () => { @@ -22,23 +52,25 @@ describe("User Integration", () => { isVerified: true, sessionId: "5c8dff72-a6f7-4293-af7a-7c7f6190c020", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "5c8dff72-a6f7-4293-af7a-7c7f6190c020", userId: currentUserId, revokedAt: null, expiresAt: new Date(Date.now() + 60_000), }); - const deleteSpy = jest - .spyOn(userService, "deleteAuthMethod") - .mockResolvedValue(undefined); + jest.mocked(userService.deleteAuthMethod).mockResolvedValue(undefined); const response = await request(app) .delete(`/api/users/me/auth-methods/${authMethodId}`) .set("Cookie", [`fintrack_access_token=${accessToken}`]); expect(response.status).toBe(204); - expect(deleteSpy).toHaveBeenCalledWith(currentUserId, authMethodId); + expect(userService.deleteAuthMethod).toHaveBeenCalledWith( + currentUserId, + authMethodId, + ); }); it("returns 404 for /api/users/me/auth-methods/:id when service does not find method", async () => { @@ -53,7 +85,8 @@ describe("User Integration", () => { isVerified: true, sessionId: "97552032-a5b4-4be3-90f8-b2e9f22ab44f", }); - jest.spyOn(authService, "findSessionById").mockResolvedValue({ + + jest.mocked(authService.findSessionById).mockResolvedValue({ sessionId: "97552032-a5b4-4be3-90f8-b2e9f22ab44f", userId: currentUserId, revokedAt: null, @@ -61,7 +94,7 @@ describe("User Integration", () => { }); jest - .spyOn(userService, "deleteAuthMethod") + .mocked(userService.deleteAuthMethod) .mockRejectedValue(new AppError("Auth method not found", 404)); const response = await request(app) diff --git a/apps/api/test/setup.cjs b/apps/api/test/setup.cjs new file mode 100644 index 0000000..599245a --- /dev/null +++ b/apps/api/test/setup.cjs @@ -0,0 +1,4 @@ +beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); diff --git a/apps/api/test/tsconfig.json b/apps/api/test/tsconfig.json index 52f86ad..aae9b58 100644 --- a/apps/api/test/tsconfig.json +++ b/apps/api/test/tsconfig.json @@ -11,5 +11,5 @@ "noEmit": true, "rootDir": ".." }, - "include": ["./**/*.ts", "../src/types/express.d.ts"] + "include": ["./**/*.ts", "../src/types/express.d.ts", "setup.cjs"] }