diff --git a/apps/backend/package.json b/apps/backend/package.json index 488687e..6cca688 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,5 +39,8 @@ "prisma": "6.19.0", "typescript": "^5.9.3", "zod": "^4.1.13" + }, + "prisma": { + "seed": "tsx ./prisma/seed.ts" } } diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 45af025..139281e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -45,7 +45,7 @@ model Wallet { transactions Transaction[] swapSettings SwapSettings? - @@unique([name, userId]) //Композитний унікальний індекс - в межах одного користувача (userId) + @@unique([name, userId]) // Composite unique index within one user (userId) } model User { @@ -81,3 +81,4 @@ model SwapSettings { stableCoins String[] @default(["usdt", "usdc"]) wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) } + diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts new file mode 100644 index 0000000..72eca8a --- /dev/null +++ b/apps/backend/prisma/seed.ts @@ -0,0 +1,556 @@ +import { PrismaClient, BuyOrSell } from "@prisma/client"; +import { randomUUID } from "node:crypto"; +import bcrypt from "bcrypt"; + +const prisma = new PrismaClient(); + +const NOW = new Date("2026-05-20T12:00:00.000Z"); +const PIVOT_DATE = new Date("2026-03-01T00:00:00.000Z"); + +type TxInput = { + coinSymbol: "btc" | "eth" | "sol" | "bnb"; + buyOrSell: BuyOrSell; + price: string; + quantity: string; + daysAgo: number; + swapGroupId?: string; +}; + +function dateDaysAgo(daysAgo: number): Date { + return new Date(NOW.getTime() - daysAgo * 24 * 60 * 60 * 1000); +} + +async function main() { + await prisma.transaction.deleteMany(); + await prisma.wallet.deleteMany(); + await prisma.user.deleteMany(); + + const seedPassword = "Test12345"; + const passwordHash = await bcrypt.hash(seedPassword, 10); + + const users = [ + { + id: randomUUID(), + login: "bohdan", + password: passwordHash, + email: "bohdan@coinradar.local", + provider: "local", + }, + { + id: randomUUID(), + login: "natalia", + password: passwordHash, + email: "natalia@coinradar.local", + provider: "local", + }, + { + id: randomUUID(), + login: "taras", + password: passwordHash, + email: "taras@coinradar.local", + provider: "local", + }, + ]; + + await prisma.user.createMany({ data: users }); + + const wallets = users.flatMap((user) => [ + { + id: randomUUID(), + userId: user.id, + name: "spot", + }, + { + id: randomUUID(), + userId: user.id, + name: "swing", + }, + ]); + + await prisma.wallet.createMany({ data: wallets }); + + const swap1 = randomUUID(); + const swap2 = randomUUID(); + const swap3 = randomUUID(); + const swap4 = randomUUID(); + + const txTemplates: TxInput[][] = [ + [ + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "70400", + quantity: "0.0180000000", + daysAgo: 0, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "3650", + quantity: "0.3500000000", + daysAgo: 0, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "182", + quantity: "1.3000000000", + daysAgo: 1, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "141", + quantity: "1.3000000000", + daysAgo: 1, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "670", + quantity: "0.4500000000", + daysAgo: 2, + }, + { + coinSymbol: "eth", + buyOrSell: "sell", + price: "2710", + quantity: "0.1200000000", + daysAgo: 3, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "58100", + quantity: "0.0200000000", + daysAgo: 5, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "528", + quantity: "0.3000000000", + daysAgo: 6, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "58200", + quantity: "0.0400000000", + daysAgo: 91, + }, + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "62000", + quantity: "0.0100000000", + daysAgo: 146, + }, + ], + [ + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "3540", + quantity: "0.6000000000", + daysAgo: 0, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "665", + quantity: "0.7000000000", + daysAgo: 1, + }, + { + coinSymbol: "bnb", + buyOrSell: "sell", + price: "538", + quantity: "0.2000000000", + daysAgo: 1, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "177", + quantity: "2.2000000000", + daysAgo: 2, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "126", + quantity: "1.0000000000", + daysAgo: 4, + }, + { + coinSymbol: "eth", + buyOrSell: "sell", + price: "2680", + quantity: "0.2000000000", + daysAgo: 4, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "59600", + quantity: "0.0150000000", + daysAgo: 6, + }, + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "68800", + quantity: "0.0050000000", + daysAgo: 7, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2680", + quantity: "0.4500000000", + daysAgo: 33, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "132", + quantity: "0.6000000000", + daysAgo: 170, + }, + ], + [ + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "69800", + quantity: "0.0100000000", + daysAgo: 0, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "648", + quantity: "0.3000000000", + daysAgo: 0, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "172", + quantity: "1.5000000000", + daysAgo: 2, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "134", + quantity: "0.6000000000", + daysAgo: 3, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "3360", + quantity: "0.5500000000", + daysAgo: 3, + }, + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "57950", + quantity: "0.0040000000", + daysAgo: 5, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2570", + quantity: "0.1500000000", + daysAgo: 6, + }, + { + coinSymbol: "bnb", + buyOrSell: "sell", + price: "681", + quantity: "0.1000000000", + daysAgo: 7, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2520", + quantity: "0.9000000000", + daysAgo: 74, + }, + { + coinSymbol: "eth", + buyOrSell: "sell", + price: "2860", + quantity: "0.9000000000", + daysAgo: 157, + }, + ], + [ + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "186", + quantity: "1.0000000000", + daysAgo: 0, + swapGroupId: swap1, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "522", + quantity: "0.2600000000", + daysAgo: 0, + swapGroupId: swap1, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "59300", + quantity: "0.0120000000", + daysAgo: 1, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "3420", + quantity: "0.4200000000", + daysAgo: 2, + }, + { + coinSymbol: "eth", + buyOrSell: "sell", + price: "2640", + quantity: "0.1200000000", + daysAgo: 2, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "129", + quantity: "0.9000000000", + daysAgo: 4, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "676", + quantity: "0.3000000000", + daysAgo: 6, + }, + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "68700", + quantity: "0.0040000000", + daysAgo: 7, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "118", + quantity: "2.0000000000", + daysAgo: 120, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "141", + quantity: "2.0000000000", + daysAgo: 172, + }, + ], + [ + { + coinSymbol: "bnb", + buyOrSell: "sell", + price: "682", + quantity: "0.4000000000", + daysAgo: 0, + swapGroupId: swap2, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2520", + quantity: "0.0750000000", + daysAgo: 0, + swapGroupId: swap2, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "70200", + quantity: "0.0200000000", + daysAgo: 1, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "133", + quantity: "2.1000000000", + daysAgo: 1, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "3470", + quantity: "0.2400000000", + daysAgo: 3, + }, + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "58400", + quantity: "0.0060000000", + daysAgo: 5, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "175", + quantity: "0.9000000000", + daysAgo: 6, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "536", + quantity: "0.3500000000", + daysAgo: 7, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2710", + quantity: "0.8000000000", + daysAgo: 52, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "488", + quantity: "0.6000000000", + daysAgo: 136, + }, + ], + [ + { + coinSymbol: "btc", + buyOrSell: "sell", + price: "70900", + quantity: "0.0070000000", + daysAgo: 0, + swapGroupId: swap3, + }, + { + coinSymbol: "sol", + buyOrSell: "buy", + price: "121", + quantity: "2.8400000000", + daysAgo: 0, + swapGroupId: swap3, + }, + { + coinSymbol: "eth", + buyOrSell: "sell", + price: "3510", + quantity: "0.1800000000", + daysAgo: 2, + swapGroupId: swap4, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "58700", + quantity: "0.0088000000", + daysAgo: 2, + swapGroupId: swap4, + }, + { + coinSymbol: "bnb", + buyOrSell: "buy", + price: "542", + quantity: "0.5000000000", + daysAgo: 3, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2660", + quantity: "0.3300000000", + daysAgo: 4, + }, + { + coinSymbol: "sol", + buyOrSell: "sell", + price: "181", + quantity: "0.9000000000", + daysAgo: 6, + }, + { + coinSymbol: "bnb", + buyOrSell: "sell", + price: "674", + quantity: "0.1200000000", + daysAgo: 7, + }, + { + coinSymbol: "btc", + buyOrSell: "buy", + price: "60100", + quantity: "0.0140000000", + daysAgo: 85, + }, + { + coinSymbol: "eth", + buyOrSell: "buy", + price: "2460", + quantity: "0.7000000000", + daysAgo: 178, + }, + ], + ]; + + const transactions = wallets.flatMap((wallet, walletIndex) => + txTemplates[walletIndex].map((tx) => ({ + id: randomUUID(), + walletId: wallet.id, + coinSymbol: tx.coinSymbol, + swapGroupId: tx.swapGroupId ?? null, + buyOrSell: tx.buyOrSell, + price: tx.price, + quantity: tx.quantity, + createdAt: dateDaysAgo(tx.daysAgo), + })), + ); + + await prisma.transaction.createMany({ data: transactions }); + + const beforePivot = transactions.filter( + (tx) => tx.createdAt < PIVOT_DATE, + ).length; + const afterPivot = transactions.length - beforePivot; + + console.log(`Seed completed: +- users: ${users.length} +- wallets: ${wallets.length} +- transactions: ${transactions.length} +- password for all seeded users: ${seedPassword} +- before ${PIVOT_DATE.toISOString()}: ${beforePivot} +- on/after ${PIVOT_DATE.toISOString()}: ${afterPivot}`); +} + +main() + .catch((error) => { + console.error("Seed failed:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 37ea1d1..6b3b84f 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -2,22 +2,30 @@ import { z } from "zod"; import { WalletListItemResponseSchema } from "../models/WalletSchema.js"; export const UserSchema = z.object({ - uid: z.string().uuid("UID має бути UUID").optional(), - login: z.string().trim().min(3, "Логін має бути не менше 3 символів").max(30), + uid: z.string().uuid("UID must be UUID").optional(), + login: z + .string() + .trim() + .min(3, "Login must be at least 3 characters") + .max(30), email: z.string().email().nullable().optional(), - token: z.string().optional(), // Токен (Access Token) + token: z.string().optional(), // Access token wallets: z.array(WalletListItemResponseSchema).optional(), // Optional (without - from auth service) }); export const RegisterSchema = z.object({ - login: z.string().trim().min(3, "Логін має бути не менше 3 символів").max(30), - password: z.string().min(6, "Пароль має бути не менше 6 символів"), - email: z.string().email("Невірний формат email").optional().or(z.literal("")), + login: z + .string() + .trim() + .min(3, "Login must be at least 3 characters") + .max(30), + password: z.string().min(6, "Password must be at least 6 characters"), + email: z.string().email("Invalid email format").optional().or(z.literal("")), }); export const LoginSchema = z.object({ - login: z.string().trim().min(3, "Логін обов'язковий"), - password: z.string().min(1, "Пароль обов'язковий"), + login: z.string().trim().min(3, "Login is required"), + password: z.string().min(1, "Password is required"), }); diff --git a/apps/backend/src/models/WalletSchema.ts b/apps/backend/src/models/WalletSchema.ts index 8aa7a3e..581cb5d 100644 --- a/apps/backend/src/models/WalletSchema.ts +++ b/apps/backend/src/models/WalletSchema.ts @@ -1,14 +1,14 @@ import { z } from "zod"; export const WalletSchema = z.object({ - id: z.string().uuid("ID має бути UUID"), + id: z.string().uuid("ID must be UUID"), name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), createdAt: z.date(), - userId: z.string().uuid("ID має бути UUID"), + userId: z.string().uuid("ID must be UUID"), //! transactions: z.array(TransactionResponseSchema).optional(), totalInvested: z.number().optional().default(0), @@ -19,24 +19,24 @@ export const WalletCreateSchema = z.object({ name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), }); export const WalletPatchSchema = z.object({ name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів") + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters") .optional(), }); export const WalletListItemResponseSchema = z.object({ - id: z.string().uuid("ID має бути UUID"), + id: z.string().uuid("ID must be UUID"), name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), }); diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts index aec2bf9..f38e3d6 100644 --- a/apps/backend/src/types/express.d.ts +++ b/apps/backend/src/types/express.d.ts @@ -1,6 +1,6 @@ import { Request } from "express"; -// Розширюю стандартний інтерфейс Request додавши поле user з middleware +// Extend the standard Request interface with userId from auth middleware. declare module "express" { export interface Request { userId?: string; diff --git a/apps/frontend/src/modules/Auth/AuthPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup.tsx index cf00833..bbb677c 100644 --- a/apps/frontend/src/modules/Auth/AuthPopup.tsx +++ b/apps/frontend/src/modules/Auth/AuthPopup.tsx @@ -24,6 +24,7 @@ export function AuthPopup() { "https://coinradar-wmzg.onrender.com/api/"; const [isLoginMode, setIsLoginMode] = useState(true); + const [showPassword, setShowPassword] = useState(false); const [logoutUser] = useLogoutUserMutation(); const [loginData, setLoginData] = useState({ login: "", @@ -173,7 +174,7 @@ export function AuthPopup() { Password + {formErrors.password && (

{formErrors.password} diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index 2a49332..35768c5 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -2,25 +2,33 @@ import { z } from "zod"; import { WalletListItemResponseSchema } from "../Wallet/wallet.schema"; export const UserSchema = z.object({ - uid: z.string().uuid("UID має бути UUID").optional(), - login: z.string().trim().min(3, "Логін має бути не менше 3 символів").max(30), + uid: z.string().uuid("UID must be UUID").optional(), + login: z + .string() + .trim() + .min(3, "Login must be at least 3 characters") + .max(30), email: z.string().email().nullable().optional(), - token: z.string().optional(), // Токен (Access Token) + token: z.string().optional(), wallets: z.array(WalletListItemResponseSchema).optional(), }); export type UserSafe = z.infer; export const RegisterSchema = z.object({ - login: z.string().trim().min(3, "Логін має бути не менше 3 символів").max(30), - password: z.string().min(6, "Пароль має бути не менше 6 символів"), - email: z.string().email("Невірний формат email").optional().or(z.literal("")), + login: z + .string() + .trim() + .min(3, "Login must be at least 3 characters") + .max(30), + password: z.string().min(6, "Password must be at least 6 characters"), + email: z.string().email("Invalid email format").optional().or(z.literal("")), }); export const LoginSchema = z.object({ - login: z.string().trim().min(3, "Логін обов'язковий"), - password: z.string().min(1, "Пароль обов'язковий"), + login: z.string().trim().min(3, "Login is required"), + password: z.string().min(1, "Password is required"), }); export type Register = z.infer; diff --git a/apps/frontend/src/modules/Transactions/transaction.api.ts b/apps/frontend/src/modules/Transactions/transaction.api.ts index ac2e95f..f3edd07 100644 --- a/apps/frontend/src/modules/Transactions/transaction.api.ts +++ b/apps/frontend/src/modules/Transactions/transaction.api.ts @@ -213,7 +213,7 @@ export const transactionApi = createApi({ }, providesTags: (_, __, { walletId }) => [ { type: "Transaction", id: "LIST" }, - { type: "Transaction", id: `CHART-${walletId}` }, // Специфічний тег + { type: "Transaction", id: `CHART-${walletId}` }, // Specific chart tag ], transformResponse: (response: unknown) => { return CoinForChartArraySchema.parse(response); diff --git a/apps/frontend/src/modules/Wallet/wallet.schema.ts b/apps/frontend/src/modules/Wallet/wallet.schema.ts index 55dac12..ea24d56 100644 --- a/apps/frontend/src/modules/Wallet/wallet.schema.ts +++ b/apps/frontend/src/modules/Wallet/wallet.schema.ts @@ -1,14 +1,14 @@ import { z } from "zod"; export const WalletSchema = z.object({ - id: z.string().uuid("ID має бути UUID"), + id: z.string().uuid("ID must be UUID"), name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), createdAt: z.coerce.date(), - userId: z.string().uuid("ID має бути UUID"), + userId: z.string().uuid("ID must be UUID"), totalInvested: z.number().optional().default(0), totalRealizedPnL: z.number().optional().default(0), @@ -18,26 +18,26 @@ export const WalletCreateSchema = z.object({ name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), }); export const WalletPatchSchema = z.object({ name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів") + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters") .optional(), }); export const WalletListItemResponseSchema = z.object({ - id: z.string().uuid("ID має бути UUID"), + id: z.string().uuid("ID must be UUID"), name: z .string() .trim() - .min(1, "Назва не може бути пустою") - .max(20, "Назва має бути не більше 20 символів"), + .min(1, "Name cannot be empty") + .max(20, "Name must be at most 20 characters"), totalInvested: z.number().optional().default(0), totalRealizedPnL: z.number().optional().default(0), diff --git a/apps/frontend/src/utils/functions.ts b/apps/frontend/src/utils/functions.ts index f5c2ae2..4345fab 100644 --- a/apps/frontend/src/utils/functions.ts +++ b/apps/frontend/src/utils/functions.ts @@ -102,7 +102,7 @@ export const getLocalDatetime = (dateInput?: string | Date): string => { } if (isNaN(date.getTime())) { - console.error("Помилка: Недійсний вхідний параметр дати."); + console.error("Error: Invalid date input parameter."); return ""; } const pad = (num: number): string => String(num).padStart(2, "0");