diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c027f7..8d415dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,29 @@ jobs: 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 env: - DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test \ No newline at end of file + DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test + + docker-build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (web) + uses: docker/build-push-action@v5 + with: + context: . + file: apps/web/Dockerfile + push: false + build-args: | + NEXT_PUBLIC_API_URL=http://localhost:8000/api + tags: fintrack-web:ci + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file 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..d8ae2b4 100644 --- a/apps/api/src/modules/ai/service.ts +++ b/apps/api/src/modules/ai/service.ts @@ -157,6 +157,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, 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/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/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} -
-
-
- ); - })} -
- - - -
-