diff --git a/README.md b/README.md index 5e605d3..b496b18 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,434 @@ -# πŸ’Έ FinTrack Bot β€” Personal Finance Tracker with Telegram + Web Dashboard +# FinTrack -**FinTrack Bot** is a personal finance tracker that combines the simplicity of a Telegram bot with the power of a modern analytics dashboard. +> **Personal Finance Tracker** β€” a full-stack monorepo for tracking income and expenses, with AI-powered analytics, Monobank integration, multi-currency support, and a donation system. -Track your daily expenses by messaging a bot. Visualize your data through a beautiful, minimal web interface. +[![CI](https://github.com/BODMAT/FinTrack/actions/workflows/ci.yml/badge.svg)](https://github.com/BODMAT/FinTrack/actions) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Node](https://img.shields.io/badge/node-22-green)]() +[![Next.js](https://img.shields.io/badge/Next.js-16-black)]() --- -## ✨ Features +## Table of Contents -### Telegram Bot +- [Overview](#overview) +- [Screenshots](#screenshots) +- [Tech Stack](#tech-stack) +- [Project Structure](#project-structure) +- [Backend](#backend) + - [Architecture](#backend-architecture) + - [Database Schema](#database-schema) + - [API Modules](#api-modules) + - [Security](#security) + - [Running the API](#running-the-api) +- [Frontend](#frontend) + - [Architecture](#frontend-architecture) + - [Pages & Features](#pages--features) + - [State Management](#state-management) + - [Running the Web App](#running-the-web-app) +- [Shared Types Package](#shared-types-package) +- [CI/CD](#cicd) +- [Environment Variables](#environment-variables) +- [Getting Started](#getting-started) +- [License](#license) -- `/start` – quick setup and help -- `/summary` – view your daily/monthly balance -- `+500 salary` or `-120 groceries` – log income or expenses instantly -- Automatically parses and saves your transactions +--- + +## Overview + +FinTrack is a monorepo (Turborepo) personal finance application that allows users to manually track transactions or import them directly from **Monobank** via its public API. The app provides a visual dashboard, AI-powered financial analysis using LLM models (Groq / Gemini), a donation leaderboard backed by **Stripe**, and a full admin panel for user and error management. + +**Core capabilities:** + +- Manual transaction management (CRUD) with location tagging (Leaflet map) +- Monobank read-only integration β€” fetch accounts and import statement transactions +- AI chat analytics powered by Groq / Gemini with user-provided API key support +- Multi-currency support: USD, UAH, EUR +- Dashboard with income/expense summaries and interactive charts +- Google OAuth + email/password authentication with session hardening +- Internationalization: English, Ukrainian, German +- Admin panel β€” user management, error log review, system stats +- Donation system with Stripe Checkout and public leaderboard +- Dark / light theme, fully responsive + +--- + +## Screenshots + +| Dashboard | Transactions | Analytics | +| --------------------------------------- | --------------------------------------------- | --------------------------------------- | +| ![Dashboard](screenshots/dashboard.png) | ![Transactions](screenshots/transactions.png) | ![Analytics](screenshots/analytics.png) | + +| Monobank Import | Admin Panel | Donation | +| -------------------------------------------- | ------------------------------------- | ------------------------------------- | +| ![Monobank Import](screenshots/monobank.png) | ![Admin Panel](screenshots/admin.png) | ![Donation](screenshots/donation.png) | + +--- + +## Tech Stack + +### Backend (`apps/api`) + +| Layer | Technology | +| ---------- | ------------------------------------------------------ | +| Runtime | Node.js 20+, TypeScript 5.9 | +| Framework | Express 4 | +| ORM / DB | Prisma 6 + PostgreSQL 15 | +| Auth | JWT (access + session tokens), bcrypt, Google OAuth | +| Validation | Zod 4 | +| AI | OpenAI SDK (`openai`) β†’ Groq & Gemini compatible | +| Payments | Stripe | +| Docs | Swagger / OpenAPI (swagger-jsdoc + swagger-ui-express) | +| Testing | Jest + Supertest (integration tests) | +| Security | Helmet, CORS, CSRF middleware, express-rate-limit | + +### Frontend (`apps/web`) + +| Layer | Technology | +| -------------- | ------------------------------------------------- | +| Framework | Next.js 16 (App Router), React 19, TypeScript 5.8 | +| Styling | Tailwind CSS 4 | +| State | Zustand 5 | +| Server state | TanStack Query 5 | +| Auth bridge | NextAuth 4 + `@auth/prisma-adapter` | +| Charts | Chart.js 4 + react-chartjs-2 | +| Maps | Leaflet + react-leaflet | +| Animations | Framer Motion 12 | +| i18n | i18next + react-i18next (EN / UK / DE) | +| Forms / select | react-select 5 | +| HTTP client | Axios 1.15 | + +### Monorepo + +| Tool | Purpose | +| ------------------- | --------------------------------------- | +| Turborepo 2 | Task orchestration and pipeline caching | +| `packages/types` | Shared Zod schemas and TypeScript types | +| Husky + lint-staged | Pre-commit hooks | +| Prettier + ESLint | Formatting and linting | +| Docker / Dockerfile | Production containerisation (web app) | +| GitHub Actions | CI pipeline | + +--- + +## Project Structure + +``` +FinTrack/ +β”œβ”€β”€ apps/ +β”‚ β”œβ”€β”€ api/ # Express REST API +β”‚ β”‚ β”œβ”€β”€ prisma/ # Schema, migrations, seed +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ config/ # Env validation (Zod) +β”‚ β”‚ β”œβ”€β”€ docs/ # OpenAPI YAML definitions +β”‚ β”‚ β”œβ”€β”€ middleware/ # Auth, CSRF, rate-limit, error handler +β”‚ β”‚ β”œβ”€β”€ modules/ # Feature modules (auth, transaction, ai, ...) +β”‚ β”‚ β”œβ”€β”€ prisma/ # Prisma client singleton + seed +β”‚ β”‚ └── routes/ # Root router +β”‚ β”‚ +β”‚ └── web/ # Next.js 16 App Router +β”‚ └── src/ +β”‚ β”œβ”€β”€ api/ # Axios API layer (typed per domain) +β”‚ β”œβ”€β”€ app/ # Next.js routes & layouts +β”‚ β”‚ └── protected/ +β”‚ β”‚ β”œβ”€β”€ admin/ +β”‚ β”‚ β”œβ”€β”€ analytics/ +β”‚ β”‚ β”œβ”€β”€ dashboard/ +β”‚ β”‚ β”œβ”€β”€ donation/ +β”‚ β”‚ β”œβ”€β”€ monobank/ +β”‚ β”‚ └── transactions/ +β”‚ β”œβ”€β”€ components/ # Shared UI: header, auth, portals, layout +β”‚ β”œβ”€β”€ hooks/ # Custom React hooks +β”‚ β”œβ”€β”€ lib/ # NextAuth config, error capture, OAuth bridge +β”‚ β”œβ”€β”€ server/ # Server-side prefetch helpers +β”‚ β”œβ”€β”€ shared/i18n/ # i18next setup + locale JSONs (en/uk/de) +β”‚ β”œβ”€β”€ store/ # Zustand stores +β”‚ β”œβ”€β”€ styles/ # Global CSS + Tailwind entry +β”‚ β”œβ”€β”€ types/ # App-level TypeScript types +β”‚ └── utils/ # Helpers per domain +β”‚ +β”œβ”€β”€ packages/ +β”‚ └── types/ # Shared Zod schemas exported as @fintrack/types +β”‚ +β”œβ”€β”€ scripts/ # codebase-dump, db-dump, db-restore helpers +β”œβ”€β”€ turbo.json +└── package.json +``` + +--- + +## Backend + +### Backend Architecture + +The API follows a **module-based architecture** β€” each feature domain (`auth`, `transaction`, `ai`, `user`, `summary`, `monobank`, `donation`, `admin`, `user-api-key`) exposes its own `controller.ts`, `service.ts`, and `route.ts`. Business logic lives in services; controllers handle HTTP and delegate to services; routes wire up middleware and controllers. + +All incoming request bodies and query parameters are validated with **Zod** schemas sourced from the shared `packages/types` package, keeping the frontend and backend in sync. + +### Database Schema + +The database uses **PostgreSQL 15** managed via Prisma migrations. Key models: + +| Model | Description | +| ----------------- | ------------------------------------------------------------------------------------------------ | +| `User` | Core user profile; roles: `USER`, `ADMIN`; `isVerified` flag | +| `AuthMethod` | Polymorphic auth: `EMAIL`, `TELEGRAM`, `GOOGLE` per user | +| `Session` | Hardened session store with token hash, family ID, IP, user-agent, revocation | +| `Transaction` | Income/expense record; supports `MANUAL` and `MONOBANK` sources; currencies: `USD`, `UAH`, `EUR` | +| `Location` | Optional lat/lng attached to a transaction (1:1) | +| `Message` | AI conversation history per user | +| `UserApiKey` | Encrypted user-provided API keys for `GROQ` or `GEMINI` | +| `ErrorLog` | Client-reported errors; admin-reviewable with `OPEN` / `RESOLVED` status | +| `DonationPayment` | Stripe payment records: `PENDING`, `SUCCEEDED`, `CANCELED`, `FAILED` | + +### API Modules + +| Module | Route prefix | Highlights | +| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------- | +| `auth` | `/auth` | Login, register, token refresh, Google OAuth exchange, logout, session list/revoke | +| `user` | `/users` | Get/update/delete profile, manage auth methods | +| `user-api-key` | `/user-api-keys` | CRUD for user AI provider keys (AES-encrypted at rest) | +| `transaction` | `/transactions` | Full CRUD with pagination, search, date range filtering | +| `transaction (monobank)` | `/transactions/monobank/*` | Fetch accounts, preview transactions, import, delete imported | +| `summary` | `/summary` | Aggregated totals and chart time-series data | +| `ai` | `/ai` | Send prompt + transaction data to LLM, retrieve history, check limits | +| `donation` | `/donations` | Create Stripe Checkout session, webhook handler, leaderboard | +| `admin` | `/admin` | User list, role update, session revocation, error log management, stats | + +Interactive Swagger docs are available at `/api-docs` (`ENABLE_SWAGGER_IN_PROD=true` or in dev mode). + +### Security + +- **Session hardening** β€” sessions store a bcrypt-hashed token, family ID for rotation detection, IP, user-agent, and a `revokedAt` timestamp. +- **CSRF middleware** on all state-mutating routes. +- **Rate limiting** via `express-rate-limit`, configurable per route group. +- **Helmet** for HTTP security headers. +- **API key encryption** β€” user AI provider keys stored AES-encrypted (`utils/crypto.ts`). +- **CORS** restricted to configured origins via `CORS_ORIGINS` env var. +- **Role-based access** β€” `ADMIN` role gates the `/admin` namespace via `authz` middleware. + +### Running the API + +```bash +cd apps/api + +cp .env.example .env +# fill in DATABASE_URL, ACCESS_TOKEN_SECRET, GROQ_API_KEY_1, STRIPE_*, GOOGLE_CLIENT_ID ... + +npm run prisma:migrate:dev # apply migrations +npm run prisma:seed # optional seed data +npm run dev # tsc -w + nodemon +``` + +**Tests:** + +```bash +npm run test # integration tests (Jest + Supertest) +npm run test:watch # watch mode +``` + +--- + +## Frontend + +### Frontend Architecture + +The web app uses **Next.js 16 App Router** with a clear separation between server components (data prefetching via `server/prefetchProtected.ts`) and client components. All protected routes are nested under `app/protected/` and gated by `ProtectedClientGate`. + +API communication is split into typed modules under `src/api/` (one file per domain), all built on a shared Axios instance (`api.ts`) with a 401-interceptor for transparent token refresh. Every response is validated against Zod schemas from `@fintrack/types`. -### Web Dashboard +### Pages & Features -- Interactive charts (Chart.js) -- Balance overview: total income, expenses, and net -- Transactions table with filters, sorting, and search -- Clean and responsive UI (React + Tailwind) +#### Dashboard (`/dashboard`) -### 🚧 Planned Features +- Summary cards: total balance, income, expenses, savings +- Income vs. expense chart (Chart.js) with animated transitions +- Period selector: week / month / year / custom range +- Source filter: all / manual / Monobank -- Bank card integration: display real-time top-ups and spending -- Visualize card transactions directly on the map (Google Maps API) +#### Transactions (`/transactions`) + +- Paginated, searchable transaction list +- Add / edit / delete via popup forms with full validation +- Location picker (Leaflet map) for geo-tagging a transaction +- Multi-currency: USD, UAH, EUR +- Monobank-sourced transactions displayed with source badge + +#### Monobank Integration (`/monobank`) + +- Token input with validation (minimum 20 characters) +- Account selector popup (masked PAN, type, ISO currency code) +- Preview fetched transactions before importing +- Import selected transactions into the user account +- Statistics overview and per-account stored transaction list +- Cooldown timer that respects Monobank API rate limits + +#### Analytics (`/analytics`) + +- AI chat panel β€” sends transaction data + free-text prompt to LLM +- Model selector (Groq llama-3.1-8b-instant and others) +- Typing text animation for streamed responses +- History list of previous AI conversations +- Custom API key management popup (Groq / Gemini) +- Usage indicator and donor-unlocked elevated limits + +#### Donation (`/donation`) + +- Stripe Checkout integration for one-time or permanent support +- Donation result popup (success / cancel states) +- Public leaderboard showing donor names and contribution totals + +#### Admin Panel (`/admin`) β€” `ADMIN` role only + +- Overview stats: users, admins, verified accounts, active sessions, open errors +- Insight charts for growth and activity trends +- User table with role toggle and session revocation per user +- Error log table: filter by `OPEN` / `RESOLVED`, resolve with admin notes + +### State Management + +| Zustand store | Responsibility | +| ------------------ | ---------------------------------------------------- | +| `useAuthStore` | Authenticated user object and `isAuthenticated` flag | +| `theme` | Light / dark theme preference | +| `period` | Global date range used by dashboard and summary | +| `popup` | Global popup open/close state | +| `burger` | Mobile navigation menu state | +| `monobankCooldown` | Monobank fetch cooldown countdown | + +TanStack Query manages all server state with `staleTime: 5 min` and `gcTime: 30 min`. Mutations trigger targeted query invalidations. + +### Running the Web App + +```bash +cd apps/web + +cp .env.example .env +# set NEXT_PUBLIC_API_URL, NEXTAUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET + +npm run dev # http://localhost:5173/FinTrack +``` + +**Production (Docker):** + +```bash +docker build \ + --build-arg NEXT_PUBLIC_API_URL=https://your-api.example.com \ + -t fintrack-web \ + apps/web + +docker run -p 5173:5173 fintrack-web +``` --- -## πŸ›  Tech Stack +## Shared Types Package -### Frontend +`packages/types` is a private TypeScript package (`@fintrack/types`) that exports all **Zod schemas and inferred TypeScript types** shared between the API and the web app. Both apps import from this package at compile time, guaranteeing type safety across the full stack. -- React + TypeScript -- Zustand for state management -- TanStack Query -- TailwindCSS +Domains covered: `auth`, `user`, `transaction`, `summary`, `ai`, `monobank`, `admin`, `donation`. -### Backend +```bash +# build before running any app +npm --prefix packages/types run build +``` -- Node.js + Express -- PostgreSQL -- Telegram Bot via Telegraf +--- + +## CI/CD + +GitHub Actions runs the following checks on every pull request to `master`: + +1. **Format check** β€” `prettier --check` +2. **Lint** β€” ESLint +3. **Type check** β€” `tsc --noEmit` +4. **Prisma generate + migrate** β€” against a PostgreSQL 15 service container +5. **API integration tests** β€” Jest + Supertest + +See [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) for the full configuration. --- -## πŸ”§ How It Works +## Environment Variables + +### `apps/api/.env` + +```env +NODE_ENV=development +ENABLE_SWAGGER_IN_PROD=false + +HOST=localhost +PORT=8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +DATABASE_URL=postgresql://user:password@localhost:5432/yourdb?schema=yourschema + +ACCESS_TOKEN_SECRET=your-jwt-access-token-secret-here + +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com + +GROQ_API_KEY_1=your-groq-api-key +API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here + +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_DONATION_PRICE_ID=price_xxx +STRIPE_DONATION_AMOUNT=300 +STRIPE_DONATION_CURRENCY=usd +STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?state=success +STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?state=cancel +STRIPE_DONATION_DURATION_DAYS=0 +``` + +### `apps/web/.env` + +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXTAUTH_URL=http://localhost:5173/FinTrack +NEXTAUTH_SECRET=your-nextauth-secret +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-client-secret +``` + +--- + +## Getting Started + +**Prerequisites:** Node.js 20+, PostgreSQL 15, npm 10+ + +```bash +# 1. Clone the repository +git clone https://github.com/BODMAT/FinTrack.git +cd FinTrack + +# 2. Install all dependencies +npm ci + +# 3. Build the shared types package +npm --prefix packages/types run build + +# 4. Configure and migrate the API +cp apps/api/.env.example apps/api/.env +# β†’ edit apps/api/.env + +npm run api:prisma:migrate:deploy + +# 5. (Optional) Seed the database +npm run api:prisma:seed + +# 6. Configure the web app +cp apps/web/.env.example apps/web/.env +# β†’ edit apps/web/.env + +# 7. Start both apps via Turborepo +npx turbo run dev +``` + +API: `http://localhost:8000` +Web: `http://localhost:5173/FinTrack` + +--- -1. User sends transactions via Telegram bot -2. Bot saves them to a PostgreSQL database -3. Web app fetches and displays data via REST API +## License -[Figma UI Kit – Finance Dashboard (Community)](https://www.figma.com/design/R6IxlELyBVpBH00ndBfKAD/Figma-UI-kit---Finance-Dashboard--Community---Copy-?node-id=6501-451&t=OZEXhxgDdwmzQ4P3-1) +[MIT](./LICENSE) Β© Makar Dzhehur, Bohdan Matula diff --git a/apps/api/package.json b/apps/api/package.json index 9fb65fe..e3a04df 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": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand", + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --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/modules/ai/controller.ts b/apps/api/src/modules/ai/controller.ts index e181e30..fcba017 100644 --- a/apps/api/src/modules/ai/controller.ts +++ b/apps/api/src/modules/ai/controller.ts @@ -1,8 +1,8 @@ import type { Request, Response, NextFunction } from "express"; import { AppError } from "../../middleware/errorHandler.js"; -import { prisma } from "../../prisma/client.js"; import { getAiResponse, + getAIHistory as getAIHistoryFromService, AiServiceError, ensureAiAccessOrThrow, getAiAccessStatus, @@ -19,27 +19,8 @@ export async function getAIHistory( if (!userId) throw new AppError("Unauthorized: User not found in request", 401); - const messages = await prisma.message.findMany({ - where: { userId }, - orderBy: { created_at: "asc" }, - }); - - const paired = []; - for (let i = 0; i < messages.length; i++) { - const currentMsg = messages[i]; - if (currentMsg && currentMsg.role === "user") { - const nextMsg = messages[i + 1]; - paired.push({ - id: currentMsg.id, - prompt: currentMsg.content, - result: nextMsg?.role === "assistant" ? nextMsg.content : "", - created_at: currentMsg.created_at, - }); - if (nextMsg?.role === "assistant") i++; - } - } - - return res.json(paired.reverse()); + const history = await getAIHistoryFromService(userId); + return res.json(history); } catch (err) { next(err); } diff --git a/apps/api/src/modules/ai/service.ts b/apps/api/src/modules/ai/service.ts index a329e32..b44b009 100644 --- a/apps/api/src/modules/ai/service.ts +++ b/apps/api/src/modules/ai/service.ts @@ -15,6 +15,30 @@ Ukrainian β†’ Ukrainian. English β†’ English. NEVER mention the language, NEVER NEVER meta-comment. Just answer directly. Format rules: plain text only, no markdown, no tables, no bold, no emojis, no bullet points. 2–3 sentences max. Use exact numbers from the data.`; +function truncateData(data: object, maxTokens = 1500): string { + const full = JSON.stringify(data); + const maxChars = maxTokens * 4; + + if (full.length <= maxChars) return full; + + if (Array.isArray(data)) { + const result = []; + let totalChars = 0; + for (const item of data) { + const itemStr = JSON.stringify(item); + if (totalChars + itemStr.length > maxChars) break; + result.push(item); + totalChars += itemStr.length; + } + return ( + JSON.stringify(result) + + ` /* +${data.length - result.length} more items truncated */` + ); + } + + return full.slice(0, maxChars) + "... [truncated]"; +} + type ServiceAiErrorCode = Exclude; export type AiAccessTier = "user" | "donor" | "admin"; @@ -157,6 +181,30 @@ export async function incrementAiAnalysisUsage( }); } +export async function getAIHistory(userId: string) { + const messages = await prisma.message.findMany({ + where: { userId }, + orderBy: { created_at: "asc" }, + }); + + const paired = []; + for (let i = 0; i < messages.length; i++) { + const currentMsg = messages[i]; + if (currentMsg && currentMsg.role === "user") { + const nextMsg = messages[i + 1]; + paired.push({ + id: currentMsg.id, + prompt: currentMsg.content, + result: nextMsg?.role === "assistant" ? nextMsg.content : "", + created_at: currentMsg.created_at, + }); + if (nextMsg?.role === "assistant") i++; + } + } + + return paired.reverse(); +} + async function callGroq( apiKey: string, modelToUse: string, @@ -173,7 +221,10 @@ async function callGroq( messages: [ { role: "system", content: systemContent }, ...contextMessages, - { role: "user", content: `${prompt}\n\nData:\n${JSON.stringify(data)}` }, + { + role: "user", + content: `${prompt}\n\nData:\n${truncateData(data, 1500)}`, + }, ], }); return completion; @@ -195,7 +246,10 @@ async function callGemini( messages: [ { role: "system", content: systemContent }, ...contextMessages, - { role: "user", content: `${prompt}\n\nData:\n${JSON.stringify(data)}` }, + { + role: "user", + content: `${prompt}\n\nData:\n${truncateData(data, 4000)}`, + }, ], }); return completion; @@ -239,10 +293,15 @@ export async function getAiResponse( data: object, model?: string, ) { + const activeUserKey = await prisma.userApiKey.findFirst({ + where: { userId, isActive: true }, + orderBy: { updatedAt: "desc" }, + }); + const historyMessages = await prisma.message.findMany({ where: { userId }, orderBy: { created_at: "desc" }, - take: CONTEXT_LIMIT, + take: activeUserKey?.provider === "GROQ" ? 6 : CONTEXT_LIMIT, }); historyMessages.reverse(); const contextMessages = historyMessages.map((msg) => ({ @@ -250,11 +309,6 @@ export async function getAiResponse( content: msg.content, })); - const activeUserKey = await prisma.userApiKey.findFirst({ - where: { userId, isActive: true }, - orderBy: { updatedAt: "desc" }, - }); - if (activeUserKey) { try { const decrypted = decryptApiKey(activeUserKey.apiKey); @@ -296,29 +350,46 @@ export async function getAiResponse( await saveMessages(userId, prompt, content); return { model: completion.model, result: content }; } catch (err: unknown) { + console.error("[AI] Provider error:", { + status: (err as { status?: number }).status, + code: (err as { code?: string }).code, + message: err instanceof Error ? err.message : String(err), + userId, + }); const msg = err instanceof Error ? err.message : String(err); const status = (err as { status?: number }).status; - if (status === 429 || msg.includes("rate_limit")) { + + const code = (err as { code?: string }).code; + if ( + status === 429 || + code === "rate_limit_exceeded" || + msg.includes("rate_limit") + ) { throw new AiServiceError( "USER_KEY_LIMIT", "Your API key has reached its rate limit.", ); } - if ( - status === 400 || - status === 401 || - status === 403 || - status === 404 - ) { + if (status === 401 || status === 403) { throw new AiServiceError( "USER_KEY_INVALID", - "Your API key is invalid, unauthorized, or model access is unavailable.", + "Your API key is invalid or unauthorized.", ); } - throw new AiServiceError( - "USER_KEY_INVALID", - "Your API key is invalid or expired.", - ); + if (status === 404) { + throw new AiServiceError( + "USER_KEY_INVALID", + "Model not found. Check your selected model.", + ); + } + if (status === 400) { + throw new AiServiceError( + "USER_KEY_INVALID", + "Bad request to AI provider.", + ); + } + // Network / unknown β€” Π½Π΅ ΠΊΠ°ΠΆΠ΅ΠΌΠΎ "invalid key" + throw new AiServiceError("USER_KEY_INVALID", `AI provider error: ${msg}`); } } diff --git a/apps/api/src/modules/donation/service.ts b/apps/api/src/modules/donation/service.ts index 80a0e17..ffc12c6 100644 --- a/apps/api/src/modules/donation/service.ts +++ b/apps/api/src/modules/donation/service.ts @@ -1,5 +1,6 @@ import Stripe from "stripe"; import { Prisma } from "@prisma/client"; +import type { DonationLeaderboardItem } from "@fintrack/types"; import { ENV } from "../../config/env.js"; import { prisma } from "../../prisma/client.js"; import { AppError } from "../../middleware/errorHandler.js"; @@ -180,14 +181,6 @@ export async function createDonationCheckoutSession( }; } -export interface DonationLeaderboardItem { - userId: string; - name: string; - photoUrl: string | null; - totalAmountMinor: number; - currency: string; -} - export async function getDonationLeaderboard( limit = 20, ): Promise { diff --git a/apps/api/src/modules/transaction/controller.ts b/apps/api/src/modules/transaction/controller.ts index 93623ce..87cbcd7 100644 --- a/apps/api/src/modules/transaction/controller.ts +++ b/apps/api/src/modules/transaction/controller.ts @@ -1,535 +1,14 @@ -import type { Request, Response, NextFunction } from "express"; -import { Prisma, TransactionSource, TransactionType } from "@prisma/client"; -import z from "zod"; -import * as transactionService from "./service.js"; -import { AppError } from "../../middleware/errorHandler.js"; -import { - createTransactionSchema, - manualCurrencyCodeSchema, - monobankFetchPayloadSchema, - monobankImportPayloadSchema, - monobankTokenSchema, - type MonobankAccount, - type MonobankClientInfoResponse, - type MonobankStatementItem, - updateTransactionSchema, -} from "@fintrack/types"; - -const MONOBANK_API_BASE_URL = "https://api.monobank.ua"; -const MONOBANK_FETCH_COOLDOWN_SECONDS = 60; -const MONOBANK_MAX_STATEMENT_RANGE_SECONDS = 2_682_000; -const monobankClientInfoByUser = new Map(); -const monobankStatementByUser = new Map(); - -const sourceQuerySchema = z - .enum([TransactionSource.MANUAL, TransactionSource.MONOBANK]) - .optional(); - -function normalizeCurrencyCode(input?: string) { - if (!input) return "USD"; - const normalized = input.trim().toUpperCase(); - const parsed = manualCurrencyCodeSchema.safeParse(normalized); - return parsed.success ? parsed.data : "USD"; -} - -function numericCurrencyToCode(code?: number) { - if (code === 980) return "UAH"; - if (code === 840) return "USD"; - if (code === 978) return "EUR"; - return "USD"; -} - -function getSourceFilter(rawSource: unknown): TransactionSource | undefined { - const parsed = sourceQuerySchema.safeParse(rawSource); - if (!parsed.success) { - throw new AppError("Invalid source. Allowed values: MANUAL, MONOBANK", 400); - } - - return parsed.data; -} - -function resolveStatementRange(from?: number, to?: number) { - const nowUnix = Math.floor(Date.now() / 1000); - const resolvedTo = to ?? nowUnix; - const resolvedFrom = - from ?? resolvedTo - (MONOBANK_MAX_STATEMENT_RANGE_SECONDS - 3600); - - if (resolvedFrom >= resolvedTo) { - throw new AppError( - "Invalid time range: 'from' must be less than 'to'", - 400, - ); - } - - if (resolvedTo - resolvedFrom > MONOBANK_MAX_STATEMENT_RANGE_SECONDS) { - throw new AppError( - `Invalid time range: max allowed interval is ${MONOBANK_MAX_STATEMENT_RANGE_SECONDS} seconds`, - 400, - ); - } - - return { from: resolvedFrom, to: resolvedTo }; -} - -function enforceMonobankCooldown( - userId: string, - operation: "client-info" | "statement", -) { - const map = - operation === "client-info" - ? monobankClientInfoByUser - : monobankStatementByUser; - const nowMs = Date.now(); - const previousRequestMs = map.get(userId); - - if (previousRequestMs) { - const diffMs = nowMs - previousRequestMs; - const cooldownMs = MONOBANK_FETCH_COOLDOWN_SECONDS * 1000; - if (diffMs < cooldownMs) { - const retryAfter = Math.ceil((cooldownMs - diffMs) / 1000); - throw new AppError( - `Monobank request cooldown is ${MONOBANK_FETCH_COOLDOWN_SECONDS}s. Retry after ${retryAfter}s.`, - 429, - { retryAfter }, - ); - } - } - - map.set(userId, nowMs); -} - -async function fetchMonobankJson(url: string, token: string): Promise { - const response = await fetch(url, { - method: "GET", - headers: { - "X-Token": token, - Accept: "application/json", - }, - }); - - if (!response.ok) { - let errorPayload: unknown = null; - try { - errorPayload = await response.json(); - } catch { - errorPayload = await response.text(); - } - - if (response.status === 429) { - throw new AppError( - "Monobank rate limit exceeded. Retry in 60 seconds.", - 429, - { - monobank: errorPayload, - }, - ); - } - - if (response.status === 401 || response.status === 403) { - throw new AppError("Invalid Monobank token", 401, { - monobank: errorPayload, - }); - } - - throw new AppError( - "Failed to fetch data from Monobank API", - response.status, - { - monobank: errorPayload, - }, - ); - } - - return (await response.json()) as T; -} - -function mapMonobankStatementToPreview( - sourceAccountId: string, - fallbackCurrencyCodeNumeric: number | undefined, - tx: MonobankStatementItem, -) { - const rawAmount = Number(tx.amount); - const normalizedAmount = Math.abs(rawAmount) / 100; - const type = - rawAmount >= 0 ? TransactionType.INCOME : TransactionType.EXPENSE; - - return { - title: - tx.description?.trim() || tx.comment?.trim() || "Monobank transaction", - type, - amount: normalizedAmount, - currencyCode: numericCurrencyToCode( - tx.currencyCode ?? fallbackCurrencyCodeNumeric, - ), - created_at: new Date(tx.time * 1000), - sourceTransactionId: tx.id, - sourceAccountId, - source: TransactionSource.MONOBANK, - }; -} - -export async function getAllTransactions( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - const source = getSourceFilter(req.query.source); - - const hasPage = req.query.page !== undefined; - const hasPerPage = req.query.perPage !== undefined; - - if (hasPage !== hasPerPage) { - throw new AppError( - "Both 'page' and 'perPage' are required for pagination", - 400, - ); - } - - if (hasPage && hasPerPage) { - const page = Number(req.query.page); - const perPage = Number(req.query.perPage); - if ( - Number.isNaN(page) || - Number.isNaN(perPage) || - page < 1 || - perPage < 1 - ) { - throw new AppError("Invalid pagination params", 400); - } - const transactions = await transactionService.getTransactionsPerPage( - userId, - page, - perPage, - source, - ); - res.status(200).json(transactions); - return; - } - - const transactions = await transactionService.getAllTransactions( - userId, - source, - ); - res.status(200).json(transactions); - } catch (err) { - next(err); - } -} - -export async function getTransaction( - req: Request, - res: Response, - next: NextFunction, -) { - try { - let transaction: object | null = {}; - const { id } = req.params; - if (!id) throw new AppError("Transaction id is required", 400); - if (Array.isArray(id)) { - throw new AppError("Invalid id", 400); - } - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - transaction = await transactionService.getTransaction(id, userId); - if (!transaction) throw new AppError("Not found", 404); - res.status(200).json(transaction); - } catch (err) { - next(err); - } -} - -export async function createTransaction( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const validatedBody = createTransactionSchema.parse(req.body); - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const prismaData: Prisma.TransactionCreateInput = { - title: validatedBody.title, - type: validatedBody.type, - amount: validatedBody.amount, - currencyCode: normalizeCurrencyCode(validatedBody.currencyCode), - ...(validatedBody.created_at - ? { created_at: validatedBody.created_at } - : {}), - ...(validatedBody.updated_at - ? { updated_at: validatedBody.updated_at } - : {}), - user: { - connect: { id: userId }, - }, - ...(validatedBody.location && { - location: { - create: { - latitude: validatedBody.location.latitude, - longitude: validatedBody.location.longitude, - }, - }, - }), - }; - const transaction = await transactionService.createTransaction(prismaData); - res.status(201).json(transaction); - } catch (err) { - next(err); - } -} - -export async function updateTransaction( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const { id } = req.params; - if (!id) throw new AppError("Transaction id is required", 400); - if (Array.isArray(id)) { - throw new AppError("Invalid id", 400); - } - const validatedBody = updateTransactionSchema.parse(req.body); - - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const transactionOfCurrentUser = await transactionService.getTransaction( - id, - userId, - ); - if (!transactionOfCurrentUser) throw new AppError("Not found", 404); - if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) { - throw new AppError("Monobank transactions are read-only", 403); - } - - const prismaData: Prisma.TransactionUpdateInput = {}; - if (validatedBody.title !== undefined) - prismaData.title = validatedBody.title; - if (validatedBody.type !== undefined) prismaData.type = validatedBody.type; - if (validatedBody.amount !== undefined) - prismaData.amount = validatedBody.amount; - if (validatedBody.currencyCode !== undefined) - prismaData.currencyCode = normalizeCurrencyCode( - validatedBody.currencyCode, - ); - if (validatedBody.created_at !== undefined) - prismaData.created_at = validatedBody.created_at; - if (validatedBody.updated_at !== undefined) - prismaData.updated_at = validatedBody.updated_at; - if (validatedBody.location) { - prismaData.location = { - upsert: { - create: { - latitude: validatedBody.location.latitude, - longitude: validatedBody.location.longitude, - }, - update: { - latitude: validatedBody.location.latitude, - longitude: validatedBody.location.longitude, - }, - }, - }; - } - if (validatedBody.location === null) { - prismaData.location = { delete: true }; - } - - const transaction = await transactionService.updateTransaction( - id, - prismaData, - ); - res.status(200).json(transaction); - } catch (err) { - next(err); - } -} - -export async function deleteTransaction( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const { id } = req.params; - if (!id) throw new AppError("Transaction id is required", 400); - if (Array.isArray(id)) { - throw new AppError("Invalid id", 400); - } - - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const transactionOfCurrentUser = await transactionService.getTransaction( - id, - userId, - ); - if (!transactionOfCurrentUser) throw new AppError("Not found", 404); - if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) { - throw new AppError("Monobank transactions are read-only", 403); - } - - await transactionService.deleteTransaction(id); - res.sendStatus(204); - } catch (err) { - next(err); - } -} - -export async function fetchMonobankTransactions( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const payload = monobankFetchPayloadSchema.parse(req.body); - const { from, to } = resolveStatementRange(payload.from, payload.to); - - enforceMonobankCooldown(userId, "statement"); - - let selectedAccountId = payload.accountId; - let selectedAccount: MonobankAccount | null = null; - - if (!selectedAccountId) { - enforceMonobankCooldown(userId, "client-info"); - const clientInfo = await fetchMonobankJson( - `${MONOBANK_API_BASE_URL}/personal/client-info`, - payload.token, - ); - const accounts = clientInfo.accounts ?? []; - if (accounts.length === 0) { - throw new AppError( - "No Monobank accounts available for this token", - 404, - ); - } - selectedAccount = accounts[0] ?? null; - selectedAccountId = selectedAccount?.id; - } - - if (!selectedAccountId) { - throw new AppError("Monobank account id is required", 400); - } - - const statement = await fetchMonobankJson( - `${MONOBANK_API_BASE_URL}/personal/statement/${selectedAccountId}/${from}/${to}`, - payload.token, - ); - - const transactions = statement.map((tx) => - mapMonobankStatementToPreview( - selectedAccountId, - payload.accountCurrencyCode, - tx, - ), - ); - - res.status(200).json({ - account: selectedAccount ?? { - id: selectedAccountId, - }, - accounts: [], - from, - to, - transactions, - meta: { - source: TransactionSource.MONOBANK, - cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS, - maxRangeSeconds: MONOBANK_MAX_STATEMENT_RANGE_SECONDS, - }, - }); - } catch (err) { - next(err); - } -} - -export async function fetchMonobankAccounts( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const payload = monobankTokenSchema.parse(req.body); - enforceMonobankCooldown(userId, "client-info"); - - const clientInfo = await fetchMonobankJson( - `${MONOBANK_API_BASE_URL}/personal/client-info`, - payload.token, - ); - - const accounts = clientInfo.accounts ?? []; - if (accounts.length === 0) { - throw new AppError("No Monobank accounts available for this token", 404); - } - - res.status(200).json({ - accounts: accounts.map((account) => ({ - id: account.id, - type: account.type, - currencyCode: account.currencyCode, - cashbackType: account.cashbackType, - balance: account.balance, - creditLimit: account.creditLimit, - maskedPan: account.maskedPan ?? [], - iban: account.iban, - })), - meta: { - source: TransactionSource.MONOBANK, - cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS, - }, - }); - } catch (err) { - next(err); - } -} - -export async function importMonobankTransactions( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const payload = monobankImportPayloadSchema.parse(req.body); - - const result = await transactionService.importMonobankTransactions( - userId, - payload.transactions, - ); - - res.status(200).json({ - ...result, - source: TransactionSource.MONOBANK, - }); - } catch (err) { - next(err); - } -} - -export async function deleteMonobankTransactions( - req: Request, - res: Response, - next: NextFunction, -) { - try { - const userId = req.user?.id; - if (!userId) throw new AppError("Unauthorized", 401); - - const result = - await transactionService.deleteAllMonobankTransactions(userId); - res.status(200).json(result); - } catch (err) { - next(err); - } -} +export { + getAllTransactions, + getTransaction, + createTransaction, + updateTransaction, + deleteTransaction, +} from "./transactions.controller.js"; + +export { + fetchMonobankAccounts, + fetchMonobankTransactions, + importMonobankTransactions, + deleteMonobankTransactions, +} from "./monobank.controller.js"; diff --git a/apps/api/src/modules/transaction/monobank.controller.ts b/apps/api/src/modules/transaction/monobank.controller.ts new file mode 100644 index 0000000..048d625 --- /dev/null +++ b/apps/api/src/modules/transaction/monobank.controller.ts @@ -0,0 +1,300 @@ +import type { Request, Response, NextFunction } from "express"; +import { TransactionSource, TransactionType } from "@prisma/client"; +import * as transactionService from "./service.js"; +import { AppError } from "../../middleware/errorHandler.js"; +import { + monobankFetchPayloadSchema, + monobankImportPayloadSchema, + monobankTokenSchema, + type MonobankAccount, + type MonobankClientInfoResponse, + type MonobankStatementItem, +} from "@fintrack/types"; + +const MONOBANK_API_BASE_URL = "https://api.monobank.ua"; +const MONOBANK_FETCH_COOLDOWN_SECONDS = 60; +const MONOBANK_MAX_STATEMENT_RANGE_SECONDS = 2_682_000; +const monobankClientInfoByUser = new Map(); +const monobankStatementByUser = new Map(); + +function numericCurrencyToCode(code?: number) { + if (code === 980) return "UAH"; + if (code === 840) return "USD"; + if (code === 978) return "EUR"; + return "USD"; +} + +function resolveStatementRange(from?: number, to?: number) { + const nowUnix = Math.floor(Date.now() / 1000); + const resolvedTo = to ?? nowUnix; + const resolvedFrom = + from ?? resolvedTo - (MONOBANK_MAX_STATEMENT_RANGE_SECONDS - 3600); + + if (resolvedFrom >= resolvedTo) { + throw new AppError( + "Invalid time range: 'from' must be less than 'to'", + 400, + ); + } + + if (resolvedTo - resolvedFrom > MONOBANK_MAX_STATEMENT_RANGE_SECONDS) { + throw new AppError( + `Invalid time range: max allowed interval is ${MONOBANK_MAX_STATEMENT_RANGE_SECONDS} seconds`, + 400, + ); + } + + return { from: resolvedFrom, to: resolvedTo }; +} + +function enforceMonobankCooldown( + userId: string, + operation: "client-info" | "statement", +) { + const map = + operation === "client-info" + ? monobankClientInfoByUser + : monobankStatementByUser; + const nowMs = Date.now(); + const previousRequestMs = map.get(userId); + + if (previousRequestMs) { + const diffMs = nowMs - previousRequestMs; + const cooldownMs = MONOBANK_FETCH_COOLDOWN_SECONDS * 1000; + if (diffMs < cooldownMs) { + const retryAfter = Math.ceil((cooldownMs - diffMs) / 1000); + throw new AppError( + `Monobank request cooldown is ${MONOBANK_FETCH_COOLDOWN_SECONDS}s. Retry after ${retryAfter}s.`, + 429, + { retryAfter }, + ); + } + } + + map.set(userId, nowMs); +} + +async function fetchMonobankJson(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "X-Token": token, + Accept: "application/json", + }, + }); + + if (!response.ok) { + let errorPayload: unknown = null; + try { + errorPayload = await response.json(); + } catch { + errorPayload = await response.text(); + } + + if (response.status === 429) { + throw new AppError( + "Monobank rate limit exceeded. Retry in 60 seconds.", + 429, + { + monobank: errorPayload, + }, + ); + } + + if (response.status === 401 || response.status === 403) { + throw new AppError("Invalid Monobank token", 401, { + monobank: errorPayload, + }); + } + + throw new AppError( + "Failed to fetch data from Monobank API", + response.status, + { + monobank: errorPayload, + }, + ); + } + + return (await response.json()) as T; +} + +function mapMonobankStatementToPreview( + sourceAccountId: string, + fallbackCurrencyCodeNumeric: number | undefined, + tx: MonobankStatementItem, +) { + const rawAmount = Number(tx.amount); + const normalizedAmount = Math.abs(rawAmount) / 100; + const type = + rawAmount >= 0 ? TransactionType.INCOME : TransactionType.EXPENSE; + + return { + title: + tx.description?.trim() || tx.comment?.trim() || "Monobank transaction", + type, + amount: normalizedAmount, + currencyCode: numericCurrencyToCode( + tx.currencyCode ?? fallbackCurrencyCodeNumeric, + ), + created_at: new Date(tx.time * 1000), + sourceTransactionId: tx.id, + sourceAccountId, + source: TransactionSource.MONOBANK, + }; +} + +export async function fetchMonobankTransactions( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const payload = monobankFetchPayloadSchema.parse(req.body); + const { from, to } = resolveStatementRange(payload.from, payload.to); + + enforceMonobankCooldown(userId, "statement"); + + let selectedAccountId = payload.accountId; + let selectedAccount: MonobankAccount | null = null; + + if (!selectedAccountId) { + enforceMonobankCooldown(userId, "client-info"); + const clientInfo = await fetchMonobankJson( + `${MONOBANK_API_BASE_URL}/personal/client-info`, + payload.token, + ); + const accounts = clientInfo.accounts ?? []; + if (accounts.length === 0) { + throw new AppError( + "No Monobank accounts available for this token", + 404, + ); + } + selectedAccount = accounts[0] ?? null; + selectedAccountId = selectedAccount?.id; + } + + if (!selectedAccountId) { + throw new AppError("Monobank account id is required", 400); + } + + const statement = await fetchMonobankJson( + `${MONOBANK_API_BASE_URL}/personal/statement/${selectedAccountId}/${from}/${to}`, + payload.token, + ); + + const transactions = statement.map((tx) => + mapMonobankStatementToPreview( + selectedAccountId, + payload.accountCurrencyCode, + tx, + ), + ); + + res.status(200).json({ + account: selectedAccount ?? { + id: selectedAccountId, + }, + accounts: [], + from, + to, + transactions, + meta: { + source: TransactionSource.MONOBANK, + cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS, + maxRangeSeconds: MONOBANK_MAX_STATEMENT_RANGE_SECONDS, + }, + }); + } catch (err) { + next(err); + } +} + +export async function fetchMonobankAccounts( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const payload = monobankTokenSchema.parse(req.body); + enforceMonobankCooldown(userId, "client-info"); + + const clientInfo = await fetchMonobankJson( + `${MONOBANK_API_BASE_URL}/personal/client-info`, + payload.token, + ); + + const accounts = clientInfo.accounts ?? []; + if (accounts.length === 0) { + throw new AppError("No Monobank accounts available for this token", 404); + } + + res.status(200).json({ + accounts: accounts.map((account) => ({ + id: account.id, + type: account.type, + currencyCode: account.currencyCode, + cashbackType: account.cashbackType, + balance: account.balance, + creditLimit: account.creditLimit, + maskedPan: account.maskedPan ?? [], + iban: account.iban, + })), + meta: { + source: TransactionSource.MONOBANK, + cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS, + }, + }); + } catch (err) { + next(err); + } +} + +export async function importMonobankTransactions( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const payload = monobankImportPayloadSchema.parse(req.body); + + const result = await transactionService.importMonobankTransactions( + userId, + payload.transactions, + ); + + res.status(200).json({ + ...result, + source: TransactionSource.MONOBANK, + }); + } catch (err) { + next(err); + } +} + +export async function deleteMonobankTransactions( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const result = + await transactionService.deleteAllMonobankTransactions(userId); + res.status(200).json(result); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/modules/transaction/transactions.controller.ts b/apps/api/src/modules/transaction/transactions.controller.ts new file mode 100644 index 0000000..049f6fb --- /dev/null +++ b/apps/api/src/modules/transaction/transactions.controller.ts @@ -0,0 +1,241 @@ +import type { Request, Response, NextFunction } from "express"; +import { Prisma, TransactionSource } from "@prisma/client"; +import z from "zod"; +import * as transactionService from "./service.js"; +import { AppError } from "../../middleware/errorHandler.js"; +import { + createTransactionSchema, + manualCurrencyCodeSchema, + updateTransactionSchema, +} from "@fintrack/types"; + +const sourceQuerySchema = z + .enum([TransactionSource.MANUAL, TransactionSource.MONOBANK]) + .optional(); + +function normalizeCurrencyCode(input?: string) { + if (!input) return "USD"; + const normalized = input.trim().toUpperCase(); + const parsed = manualCurrencyCodeSchema.safeParse(normalized); + return parsed.success ? parsed.data : "USD"; +} + +function getSourceFilter(rawSource: unknown): TransactionSource | undefined { + const parsed = sourceQuerySchema.safeParse(rawSource); + if (!parsed.success) { + throw new AppError("Invalid source. Allowed values: MANUAL, MONOBANK", 400); + } + + return parsed.data; +} + +export async function getAllTransactions( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + const source = getSourceFilter(req.query.source); + + const hasPage = req.query.page !== undefined; + const hasPerPage = req.query.perPage !== undefined; + + if (hasPage !== hasPerPage) { + throw new AppError( + "Both 'page' and 'perPage' are required for pagination", + 400, + ); + } + + if (hasPage && hasPerPage) { + const page = Number(req.query.page); + const perPage = Number(req.query.perPage); + if ( + Number.isNaN(page) || + Number.isNaN(perPage) || + page < 1 || + perPage < 1 + ) { + throw new AppError("Invalid pagination params", 400); + } + const transactions = await transactionService.getTransactionsPerPage( + userId, + page, + perPage, + source, + ); + res.status(200).json(transactions); + return; + } + + const transactions = await transactionService.getAllTransactions( + userId, + source, + ); + res.status(200).json(transactions); + } catch (err) { + next(err); + } +} + +export async function getTransaction( + req: Request, + res: Response, + next: NextFunction, +) { + try { + let transaction: object | null = {}; + const { id } = req.params; + if (!id) throw new AppError("Transaction id is required", 400); + if (Array.isArray(id)) { + throw new AppError("Invalid id", 400); + } + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + transaction = await transactionService.getTransaction(id, userId); + if (!transaction) throw new AppError("Not found", 404); + res.status(200).json(transaction); + } catch (err) { + next(err); + } +} + +export async function createTransaction( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const validatedBody = createTransactionSchema.parse(req.body); + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const prismaData: Prisma.TransactionCreateInput = { + title: validatedBody.title, + type: validatedBody.type, + amount: validatedBody.amount, + currencyCode: normalizeCurrencyCode(validatedBody.currencyCode), + ...(validatedBody.created_at + ? { created_at: validatedBody.created_at } + : {}), + ...(validatedBody.updated_at + ? { updated_at: validatedBody.updated_at } + : {}), + user: { + connect: { id: userId }, + }, + ...(validatedBody.location && { + location: { + create: { + latitude: validatedBody.location.latitude, + longitude: validatedBody.location.longitude, + }, + }, + }), + }; + const transaction = await transactionService.createTransaction(prismaData); + res.status(201).json(transaction); + } catch (err) { + next(err); + } +} + +export async function updateTransaction( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { id } = req.params; + if (!id) throw new AppError("Transaction id is required", 400); + if (Array.isArray(id)) { + throw new AppError("Invalid id", 400); + } + const validatedBody = updateTransactionSchema.parse(req.body); + + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const transactionOfCurrentUser = await transactionService.getTransaction( + id, + userId, + ); + if (!transactionOfCurrentUser) throw new AppError("Not found", 404); + if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) { + throw new AppError("Monobank transactions are read-only", 403); + } + + const prismaData: Prisma.TransactionUpdateInput = {}; + if (validatedBody.title !== undefined) + prismaData.title = validatedBody.title; + if (validatedBody.type !== undefined) prismaData.type = validatedBody.type; + if (validatedBody.amount !== undefined) + prismaData.amount = validatedBody.amount; + if (validatedBody.currencyCode !== undefined) + prismaData.currencyCode = normalizeCurrencyCode( + validatedBody.currencyCode, + ); + if (validatedBody.created_at !== undefined) + prismaData.created_at = validatedBody.created_at; + if (validatedBody.updated_at !== undefined) + prismaData.updated_at = validatedBody.updated_at; + if (validatedBody.location) { + prismaData.location = { + upsert: { + create: { + latitude: validatedBody.location.latitude, + longitude: validatedBody.location.longitude, + }, + update: { + latitude: validatedBody.location.latitude, + longitude: validatedBody.location.longitude, + }, + }, + }; + } + if (validatedBody.location === null) { + prismaData.location = { delete: true }; + } + + const transaction = await transactionService.updateTransaction( + id, + prismaData, + ); + res.status(200).json(transaction); + } catch (err) { + next(err); + } +} + +export async function deleteTransaction( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { id } = req.params; + if (!id) throw new AppError("Transaction id is required", 400); + if (Array.isArray(id)) { + throw new AppError("Invalid id", 400); + } + + const userId = req.user?.id; + if (!userId) throw new AppError("Unauthorized", 401); + + const transactionOfCurrentUser = await transactionService.getTransaction( + id, + userId, + ); + if (!transactionOfCurrentUser) throw new AppError("Not found", 404); + if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) { + throw new AppError("Monobank transactions are read-only", 403); + } + + await transactionService.deleteTransaction(id); + res.sendStatus(204); + } catch (err) { + next(err); + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6ffade7..a18219f 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -3,13 +3,13 @@ WORKDIR /app FROM base AS deps RUN apk add --no-cache libc6-compat +ENV HUSKY=0 COPY package.json package-lock.json turbo.json ./ COPY apps/web/package.json apps/web/package.json COPY packages/types/package.json packages/types/package.json RUN npm ci --include=dev --install-strategy=nested -RUN npm ci --workspace fintrack-web --include=dev --install-strategy=nested FROM base AS builder ENV NEXT_TELEMETRY_DISABLED=1 @@ -47,4 +47,4 @@ COPY --from=builder /app/apps/web/public ./apps/web/public USER nextjs EXPOSE 5173 -CMD ["node", "apps/web/server.js"] +CMD ["node", "apps/web/server.js"] \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 5bd3fd0..91240b4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,9 @@ "typescript-eslint": "^8.35.1" }, "optionalDependencies": { - "lightningcss-linux-x64-gnu": "*" + "lightningcss-linux-x64-gnu": "*", + "lightningcss-linux-x64-musl": "*", + "@tailwindcss/oxide-linux-x64-gnu": "*", + "@tailwindcss/oxide-linux-x64-musl": "*" } } diff --git a/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx b/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx new file mode 100644 index 0000000..2b2cc62 --- /dev/null +++ b/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx @@ -0,0 +1,85 @@ +import type { ChartData } from "chart.js"; +import { Bar, Doughnut } from "react-chartjs-2"; + +export function AdminDoughnutCard({ + title, + data, +}: { + title: string; + data: ChartData<"doughnut">; +}) { + return ( +
+

+ {title} +

+
+ +
+
+ ); +} + +export function AdminBarCard({ + title, + data, +}: { + title: string; + data: ChartData<"bar">; +}) { + return ( +
+

+ {title} +

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx new file mode 100644 index 0000000..dcec3bb --- /dev/null +++ b/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx @@ -0,0 +1,151 @@ +import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; +import type { AdminErrorLog, ErrorLogStatus } from "@fintrack/types"; +import { formatDate } from "@/utils/admin"; + +interface AdminErrorLogsSectionProps { + logStatusFilter: "ALL" | ErrorLogStatus; + setLogStatusFilter: (value: "ALL" | ErrorLogStatus) => void; + errorLogs: AdminErrorLog[]; + isLoading: boolean; + errorMessage?: string; + onToggleResolved: (errorLogId: string, resolved: boolean) => void; + isResolvePendingForLog: (errorLogId: string) => boolean; +} + +export function AdminErrorLogsSection({ + logStatusFilter, + setLogStatusFilter, + errorLogs, + isLoading, + errorMessage, + onToggleResolved, + isResolvePendingForLog, +}: AdminErrorLogsSectionProps) { + const { t } = useSafeTranslation(); + + return ( +
+
+

+ {t("admin.errors.title")} +

+
+ + + +
+
+ + {isLoading && ( +

{t("admin.errors.loading")}

+ )} + {!!errorMessage &&

{errorMessage}

} + + {!isLoading && !errorLogs.length && ( +

{t("admin.errors.empty")}

+ )} + + {!!errorLogs.length && ( +
+ {errorLogs.map((log) => ( +
+
+
+

+ {log.title} +

+

+ {log.user.name} ({log.user.role}) |{" "} + {formatDate(log.createdAt)} +

+
+ + {log.status} + +
+ +

+ {log.message} +

+ + {log.stack && ( +
+                  {log.stack}
+                
+ )} + +
+ + {t("admin.errors.source")}: {log.source || "-"} + + | + + {t("admin.errors.userAgent")}: {log.userAgent || "-"} + + | + + {t("admin.errors.resolvedAt")}:{" "} + {log.resolvedAt ? formatDate(log.resolvedAt) : "-"} + +
+ +
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx b/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx index 50b82ad..973c30b 100644 --- a/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx +++ b/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx @@ -11,10 +11,10 @@ import { Tooltip, type ChartData, } from "chart.js"; -import { Bar, Doughnut } from "react-chartjs-2"; import { useMemo } from "react"; import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; import type { AdminErrorLog, AdminUser } from "@fintrack/types"; +import { AdminBarCard, AdminDoughnutCard } from "./AdminChartCard"; ChartJS.register( CategoryScale, @@ -156,89 +156,18 @@ export function AdminInsightsCharts({

) : (
-
-

- {t("admin.charts.rolesBreakdown")} -

-
- -
-
- -
-

- {t("admin.charts.verificationBreakdown")} -

-
- -
-
- -
-

- {t("admin.charts.activity14d")} -

-
- -
-
+ + +
)} diff --git a/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx new file mode 100644 index 0000000..847d7a3 --- /dev/null +++ b/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx @@ -0,0 +1,93 @@ +import type { AdminErrorLog, AdminStats, AdminUser } from "@fintrack/types"; +import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; +import { AdminInsightsCharts } from "./AdminInsightsCharts"; + +interface AdminOverviewSectionProps { + stats?: AdminStats; + users: AdminUser[]; + chartLogs: AdminErrorLog[]; + isStatsLoading: boolean; + isChartsLoading: boolean; +} + +export function AdminOverviewSection({ + stats, + users, + chartLogs, + isStatsLoading, + isChartsLoading, +}: AdminOverviewSectionProps) { + const { t } = useSafeTranslation(); + + const metrics = !stats + ? [] + : [ + { + id: "totalUsers", + label: t("admin.metrics.totalUsers"), + value: stats.users.total, + }, + { + id: "admins", + label: t("admin.metrics.admins"), + value: stats.users.admins, + }, + { + id: "verifiedUsers", + label: t("admin.metrics.verifiedUsers"), + value: stats.users.verified, + }, + { + id: "newUsers7d", + label: t("admin.metrics.newUsers7d"), + value: stats.users.newLast7Days, + }, + { + id: "activeSessions", + label: t("admin.metrics.activeSessions"), + value: stats.sessions.active, + }, + { + id: "openErrors", + label: t("admin.metrics.openErrors"), + value: stats.errors.open, + }, + ]; + + return ( + <> +
+

+ {t("admin.title")} +

+

+ {t("admin.subtitle")} +

+
+ +
+ {isStatsLoading && ( +
+ {t("admin.loadingStats")} +
+ )} + {metrics.map((metric) => ( +
+

+ {metric.label} +

+

+ {metric.value} +

+
+ ))} +
+ + + + ); +} diff --git a/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx b/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx index d3a06ff..7702c3f 100644 --- a/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx +++ b/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getAdminErrorLogs, @@ -13,31 +13,10 @@ import { } from "@/api/admin"; import { useAuth } from "@/hooks/useAuth"; import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; -import type { AdminUser, ErrorLogStatus } from "@fintrack/types"; -import { AdminInsightsCharts } from "./AdminInsightsCharts"; - -function formatDate(value: Date | null) { - if (!value) return "-"; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(value); -} - -function getPreferredUserContact(user: AdminUser) { - const emailMethod = user.authMethods.find((item) => item.type === "EMAIL"); - if (emailMethod?.email) return emailMethod.email; - - const telegramMethod = user.authMethods.find( - (item) => item.type === "TELEGRAM", - ); - if (telegramMethod?.telegram_id) return `tg:${telegramMethod.telegram_id}`; - - const googleMethod = user.authMethods.find((item) => item.type === "GOOGLE"); - if (googleMethod?.google_sub) return `google:${googleMethod.google_sub}`; - - return "-"; -} +import type { ErrorLogStatus } from "@fintrack/types"; +import { AdminUsersSection } from "./AdminUsersSection"; +import { AdminErrorLogsSection } from "./AdminErrorLogsSection"; +import { AdminOverviewSection } from "./AdminOverviewSection"; export function AdminPanel() { const { t } = useSafeTranslation(); @@ -138,43 +117,6 @@ export function AdminPanel() { const selfUserId = user?.id; - const metrics = useMemo(() => { - if (!statsQuery.data) return []; - - return [ - { - id: "totalUsers", - label: t("admin.metrics.totalUsers"), - value: statsQuery.data.users.total, - }, - { - id: "admins", - label: t("admin.metrics.admins"), - value: statsQuery.data.users.admins, - }, - { - id: "verifiedUsers", - label: t("admin.metrics.verifiedUsers"), - value: statsQuery.data.users.verified, - }, - { - id: "newUsers7d", - label: t("admin.metrics.newUsers7d"), - value: statsQuery.data.users.newLast7Days, - }, - { - id: "activeSessions", - label: t("admin.metrics.activeSessions"), - value: statsQuery.data.sessions.active, - }, - { - id: "openErrors", - label: t("admin.metrics.openErrors"), - value: statsQuery.data.errors.open, - }, - ]; - }, [statsQuery.data, t]); - if (!isAdmin) { return (
@@ -190,419 +132,57 @@ export function AdminPanel() { return (
-
-

- {t("admin.title")} -

-

- {t("admin.subtitle")} -

-
- -
- {statsQuery.isLoading && ( -
- {t("admin.loadingStats")} -
- )} - {metrics.map((metric) => ( -
-

- {metric.label} -

-

- {metric.value} -

-
- ))} -
- - -
-
-

- {t("admin.users.title")} -

- -
- - {usersQuery.isLoading && ( -

{t("admin.users.loading")}

- )} - {usersQuery.isError && ( -

- {(usersQuery.error as Error).message || t("admin.users.loadError")} -

- )} - - {!usersQuery.isLoading && !users.length && ( -

{t("admin.users.empty")}

- )} - - {!!users.length && ( -
- - - - - - - - - - - - - {users.map((adminUser) => { - const canDemote = - adminUser.role === "ADMIN" && adminUser.id !== selfUserId; - - return ( - - - - - - - - - ); - })} - -
- {t("admin.users.columns.name")} - - {t("admin.users.columns.contact")} - - {t("admin.users.columns.role")} - - {t("admin.users.columns.verified")} - - {t("admin.users.columns.created")} - - {t("admin.users.columns.actions")} -
- {adminUser.name} - - {getPreferredUserContact(adminUser)} - - {adminUser.role} - - {adminUser.isVerified - ? t("admin.users.yes") - : t("admin.users.no")} - - {formatDate(adminUser.created_at)} - -
- {adminUser.role === "USER" && ( - - )} - - {canDemote && ( - - )} - - -
-
-
- )} - - {!!users.length && ( -
- {users.map((adminUser) => { - const canDemote = - adminUser.role === "ADMIN" && adminUser.id !== selfUserId; - - return ( -
-
-
-
-

- {adminUser.name} -

-
-
-

- {getPreferredUserContact(adminUser)} -

-
-
- - {adminUser.role} - -
- -
- - {t("admin.users.columns.verified")}:{" "} - {adminUser.isVerified - ? t("admin.users.yes") - : t("admin.users.no")} - - - {t("admin.users.columns.created")}:{" "} - {formatDate(adminUser.created_at)} - -
- -
- {adminUser.role === "USER" && ( - - )} - - {canDemote && ( - - )} - - -
-
- ); - })} -
- )} -
- -
-
-

- {t("admin.errors.title")} -

-
- - - -
-
- - {errorLogsQuery.isLoading && ( -

{t("admin.errors.loading")}

- )} - {errorLogsQuery.isError && ( -

- {(errorLogsQuery.error as Error).message || - t("admin.errors.loadError")} -

- )} - - {!errorLogsQuery.isLoading && !errorLogs.length && ( -

{t("admin.errors.empty")}

- )} - - {!!errorLogs.length && ( -
- {errorLogs.map((log) => ( -
-
-
-

- {log.title} -

-

- {log.user.name} ({log.user.role}) |{" "} - {formatDate(log.createdAt)} -

-
- - {log.status} - -
- -

- {log.message} -

- - {log.stack && ( -
-                    {log.stack}
-                  
- )} - -
- - {t("admin.errors.source")}: {log.source || "-"} - - | - - {t("admin.errors.userAgent")}: {log.userAgent || "-"} - - | - - {t("admin.errors.resolvedAt")}:{" "} - {log.resolvedAt ? formatDate(log.resolvedAt) : "-"} - -
+ revokeAllSessionsMutation.mutate()} + onPromote={(userId) => roleMutation.mutate({ userId, role: "ADMIN" })} + onDemote={(userId) => roleMutation.mutate({ userId, role: "USER" })} + onRevokeUserSessions={(userId) => + revokeUserSessionsMutation.mutate(userId) + } + isRolePendingForUser={(userId) => + roleMutation.isPending && roleChangingUserId === userId + } + isSessionPendingForUser={(userId) => + revokeUserSessionsMutation.isPending && + sessionRevokingUserId === userId + } + /> -
- -
-
- ))} -
- )} -
+ + resolveLogMutation.mutate({ errorLogId, resolved }) + } + isResolvePendingForLog={(errorLogId) => + resolveLogMutation.isPending && resolvingLogId === errorLogId + } + />
); } diff --git a/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx b/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx new file mode 100644 index 0000000..8441147 --- /dev/null +++ b/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx @@ -0,0 +1,59 @@ +import type { AdminUser } from "@fintrack/types"; +import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; + +interface AdminUserActionsProps { + adminUser: AdminUser; + canDemote: boolean; + onPromote: (userId: string) => void; + onDemote: (userId: string) => void; + onRevokeUserSessions: (userId: string) => void; + isRolePending: boolean; + isSessionPending: boolean; +} + +export function AdminUserActions({ + adminUser, + canDemote, + onPromote, + onDemote, + onRevokeUserSessions, + isRolePending, + isSessionPending, +}: AdminUserActionsProps) { + const { t } = useSafeTranslation(); + + return ( +
+ {adminUser.role === "USER" && ( + + )} + + {canDemote && ( + + )} + + +
+ ); +} diff --git a/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx new file mode 100644 index 0000000..b4a173c --- /dev/null +++ b/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx @@ -0,0 +1,193 @@ +import type { AdminUser } from "@fintrack/types"; +import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation"; +import { AdminUserActions } from "./AdminUserActions"; +import { formatDate, getPreferredUserContact } from "@/utils/admin"; + +interface AdminUsersSectionProps { + users: AdminUser[]; + selfUserId?: string; + isLoading: boolean; + errorMessage?: string; + isRevokingAll: boolean; + onRevokeAll: () => void; + onPromote: (userId: string) => void; + onDemote: (userId: string) => void; + onRevokeUserSessions: (userId: string) => void; + isRolePendingForUser: (userId: string) => boolean; + isSessionPendingForUser: (userId: string) => boolean; +} + +export function AdminUsersSection({ + users, + selfUserId, + isLoading, + errorMessage, + isRevokingAll, + onRevokeAll, + onPromote, + onDemote, + onRevokeUserSessions, + isRolePendingForUser, + isSessionPendingForUser, +}: AdminUsersSectionProps) { + const { t } = useSafeTranslation(); + + return ( +
+
+

+ {t("admin.users.title")} +

+ +
+ + {isLoading && ( +

{t("admin.users.loading")}

+ )} + {!!errorMessage &&

{errorMessage}

} + + {!isLoading && !users.length && ( +

{t("admin.users.empty")}

+ )} + + {!!users.length && ( +
+ + + + + + + + + + + + + {users.map((adminUser) => { + const canDemote = + adminUser.role === "ADMIN" && adminUser.id !== selfUserId; + + return ( + + + + + + + + + ); + })} + +
+ {t("admin.users.columns.name")} + + {t("admin.users.columns.contact")} + + {t("admin.users.columns.role")} + + {t("admin.users.columns.verified")} + + {t("admin.users.columns.created")} + + {t("admin.users.columns.actions")} +
+ {adminUser.name} + + {getPreferredUserContact(adminUser)} + + {adminUser.role} + + {adminUser.isVerified + ? t("admin.users.yes") + : t("admin.users.no")} + + {formatDate(adminUser.created_at)} + + +
+
+ )} + + {!!users.length && ( +
+ {users.map((adminUser) => { + const canDemote = + adminUser.role === "ADMIN" && adminUser.id !== selfUserId; + + return ( +
+
+
+
+

+ {adminUser.name} +

+
+
+

+ {getPreferredUserContact(adminUser)} +

+
+
+ + {adminUser.role} + +
+ +
+ + {t("admin.users.columns.verified")}:{" "} + {adminUser.isVerified + ? t("admin.users.yes") + : t("admin.users.no")} + + + {t("admin.users.columns.created")}:{" "} + {formatDate(adminUser.created_at)} + +
+ +
+ +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx index f77a6bf..5ddb4cb 100644 --- a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx +++ b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx @@ -1,22 +1,19 @@ 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"; -import { sanitizeText, toLocalDatetimeString } from "@/utils/components"; -import { TypingText } from "./TypingText"; -import { FixedPanel } from "@/shared/portals/FixedPanel"; import { useAnalyticsAI } from "@/hooks/useAnalyticsAI"; 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 { AiErrorCode } from "@/types/ai"; import type { ApiError } from "@/types/api"; +import { ApiKeyPopup } from "./ApiKeyPopup"; +import { AiErrorPopup } from "./AiErrorPopup"; +import { AiLimitPopup } from "./AiLimitPopup"; +import { AnalyticsContent } from "./AnalyticsContent"; +import { AnalyticsStateBoundary } from "./AnalyticsStateBoundary"; +import { getLatestMessageIndex, isAiErrorCode } from "@/utils/analytics"; export function Analytics() { const { t } = useSafeTranslation(); @@ -40,16 +37,6 @@ export function Analytics() { const { history, isLoading: isLoadingAI, getResponse } = useAnalyticsAI(); - function isAiErrorCode(value: unknown): value is AiErrorCode { - return ( - value === "USER_KEY_LIMIT" || - value === "USER_KEY_INVALID" || - value === "DEFAULT_KEY_LIMIT" || - value === "ALL_KEYS_FAILED" || - value === "USING_DEFAULT_KEY" - ); - } - const handleOpenErrorPopup = useCallback( (errorCode: AiErrorCode) => { open(t("errors.error"), ); @@ -57,19 +44,7 @@ export function Analytics() { [open, t], ); - const latestMessageIndex = history.reduce((latestIndex, message, index) => { - const currentTime = new Date(message.getted_at).getTime(); - if (!Number.isFinite(currentTime)) return latestIndex; - - if (latestIndex < 0) return index; - - const latestTime = new Date(history[latestIndex]?.getted_at).getTime(); - if (!Number.isFinite(latestTime) || currentTime > latestTime) { - return index; - } - - return latestIndex; - }, -1); + const latestMessageIndex = getLatestMessageIndex(history); const handleAnalyze = useCallback(async () => { if (!prompt || !transactionData?.data.length) return; @@ -130,184 +105,29 @@ export function Analytics() { open(t("analytics.addApiKey"), ); }; - const formatAnalyticsDate = (dateInput: Date) => { - const date = new Date(dateInput); - if (Number.isFinite(date.getTime())) { - return toLocalDatetimeString(date, true); - } - return toLocalDatetimeString(new Date(), true); - }; - - if (isLoading || isLoadingTransactions) return ; - if (!user) return ; - if (error) return ; - if (!transactionData) return ; - return ( -
-
- {/* Header */} -
-

- {t("analytics.title")} -

- - -
- -
- {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 && ( -
- -
- )} - - {/* History analysis */} -
- {history.map((item: AIResponseWithDiff, index) => { - const currentDate = formatAnalyticsDate(item.getted_at); - const isLatestMessage = index === latestMessageIndex; - - return ( - - {/* User Message */} -
-
- {user.photo_url && ( - {user.name - )} -
{item.prompt}
-
-
- {currentDate} -
-
- - {/* AI Response */} -
- {isLatestMessage ? ( - - ) : ( -

- {sanitizeText(item.result)} -

- )} -
- {currentDate} -
-
-
- ); - })} -
- - - -
-