diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/package.json b/package.json index 0204287..f436dd2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node dist/index.js", "build": "tsc", + "sync-prisma": "npx prisma migrate dev && npm run prisma-generate", "prisma-generate": "npx prisma generate", "prisma-studio": "npx prisma studio", "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts", @@ -50,6 +51,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "lodash.get": "^4.4.2", + "uuid": "^11.0.3", "yup": "^1.3.2" } } diff --git a/prisma/migrations/20231121101343_init/migration.sql b/prisma/migrations/20231121101343_init/migration.sql deleted file mode 100644 index 52cdb73..0000000 --- a/prisma/migrations/20231121101343_init/migration.sql +++ /dev/null @@ -1,41 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "username" TEXT NOT NULL, - "email" TEXT NOT NULL, - "application_user_id" TEXT NOT NULL, - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL -); - --- CreateTable -CREATE TABLE "Wallet" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "user_id" INTEGER NOT NULL, - "balance" INTEGER NOT NULL, - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL, - CONSTRAINT "Wallet_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "Transaction" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "transaction_type" TEXT NOT NULL, - "user_id" INTEGER NOT NULL, - "wallet_id" INTEGER NOT NULL, - "amount" INTEGER NOT NULL, - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL, - CONSTRAINT "Transaction_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT "Transaction_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "Wallet" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "User_application_user_id_key" ON "User"("application_user_id"); diff --git a/prisma/migrations/20241113013717_/migration.sql b/prisma/migrations/20241113013717_/migration.sql new file mode 100644 index 0000000..5fb2cfd --- /dev/null +++ b/prisma/migrations/20241113013717_/migration.sql @@ -0,0 +1,76 @@ +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('DEPOSIT', 'WITHDRAWAL'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "application_user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Transaction" ( + "id" TEXT NOT NULL, + "transaction_type" "TransactionType" NOT NULL, + "user_id" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "coin_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Coin" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Coin_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CoinRate" ( + "id" TEXT NOT NULL, + "coin_one_id" TEXT NOT NULL, + "coin_two_id" TEXT NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CoinRate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_application_user_id_key" ON "User"("application_user_id"); + +-- CreateIndex +CREATE INDEX "Transaction_user_id_coin_id_idx" ON "Transaction"("user_id", "coin_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Coin_name_key" ON "Coin"("name"); + +-- AddForeignKey +ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_coin_id_fkey" FOREIGN KEY ("coin_id") REFERENCES "Coin"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoinRate" ADD CONSTRAINT "CoinRate_coin_one_id_fkey" FOREIGN KEY ("coin_one_id") REFERENCES "Coin"("name") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoinRate" ADD CONSTRAINT "CoinRate_coin_two_id_fkey" FOREIGN KEY ("coin_two_id") REFERENCES "Coin"("name") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20241224233404_/migration.sql b/prisma/migrations/20241224233404_/migration.sql new file mode 100644 index 0000000..2aab8bb --- /dev/null +++ b/prisma/migrations/20241224233404_/migration.sql @@ -0,0 +1,53 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username,email]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateTable +CREATE TABLE "League" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "demotion_rate" DOUBLE PRECISION NOT NULL, + "promotion_rate" DOUBLE PRECISION NOT NULL, + "league_start_date" TIMESTAMP(3) NOT NULL, + "league_end_date" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "League_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LeagueUser" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "league_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LeagueUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserExperience" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserExperience_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_email_key" ON "User"("username", "email"); + +-- AddForeignKey +ALTER TABLE "LeagueUser" ADD CONSTRAINT "LeagueUser_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LeagueUser" ADD CONSTRAINT "LeagueUser_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "League"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserExperience" ADD CONSTRAINT "UserExperience_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index e5e5c47..fbffa92 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "sqlite" \ No newline at end of file +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 904bb12..4da0109 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,39 +3,94 @@ generator client { } datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } +enum TransactionType { + DEPOSIT + WITHDRAWAL +} + model User { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) username String @unique email String @unique application_user_id String @unique - Wallet Wallet[] Transaction Transaction[] created_at DateTime @default(now()) updated_at DateTime @updatedAt -} -model Wallet { - id Int @id @default(autoincrement()) - user_id Int - user User @relation(fields: [user_id], references: [id]) - balance Int - Transaction Transaction[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + LeagueUser LeagueUser[] + UserExperience UserExperience[] + + @@unique([username, email]) } model Transaction { - id Int @id @default(autoincrement()) - transaction_type String - user_id Int - user User @relation(fields: [user_id], references: [id]) - wallet_id Int - wallet Wallet @relation(fields: [wallet_id], references: [id]) + id String @id @default(uuid()) + transaction_type TransactionType + user_id String + user User @relation(fields: [user_id], references: [id]) amount Int - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + coin_id String + coin Coin @relation(fields: [coin_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, coin_id]) +} + +model Coin { + id String @id @default(uuid()) + name String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + Transactions Transaction[] + CoinRatesOne CoinRate[] @relation("coin_one") + CoinRatesTwo CoinRate[] @relation("coin_two") + + @@unique([name]) +} + +model CoinRate { + id String @id @default(uuid()) + coin_one_id String + coin_one Coin @relation("coin_one", fields: [coin_one_id], references: [name]) + coin_two_id String + coin_two Coin @relation("coin_two", fields: [coin_two_id], references: [name]) + rate Float + created_at DateTime @default(now()) + updated_at DateTime @updatedAt +} + +model League { + id String @id @default(uuid()) + name String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + demotion_rate Float + promotion_rate Float + league_start_date DateTime + league_end_date DateTime + Users LeagueUser[] +} + +model LeagueUser { + id String @id @default(uuid()) + user_id String + user User @relation(fields: [user_id], references: [id]) + league_id String + league League @relation(fields: [league_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt +} + +model UserExperience { + id String @id @default(uuid()) + user_id String + amount Float + user User @relation(fields: [user_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt } diff --git a/src/index.ts b/src/index.ts index a0a7879..7c2b37f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,9 @@ import express, { Request, Response } from "express"; import cors from "cors"; import packageJSON from "./../package.json"; import v1Routes from "./v1/routes"; -import { User } from "@prisma/client"; +import { Prisma, User } from "@prisma/client"; import { requestLogger } from "./utils/middlewares"; +import prisma from "./database/prisma"; declare global { namespace Express { @@ -29,6 +30,17 @@ app.get("/", (req, res) => { }); }); +app.get("/clean", async (req, res) => { + const tables = ["User", "Transaction", "Coin", "CoinRate"]; + for (const table of tables) { + console.log(`Truncating table ${table}`); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE;`); + } + res.status(200).json({ + message: "Database cleaned", + }); +}); + app.use("/api/v1", v1Routes); app.use((err: any, req: Request, res: Response, next: Function) => { diff --git a/src/utils/middlewares.ts b/src/utils/middlewares.ts index d3c9f21..2f99f2f 100644 --- a/src/utils/middlewares.ts +++ b/src/utils/middlewares.ts @@ -21,6 +21,7 @@ export const validateYupSchemaAgainstAnObject = async function (schema: any, obj export const middlewareValidateYupSchemaAgainstReqBody = function (schema: any) { return async function (req: any, res: any, next: any) { + console.log("validation middleware", req.body); let validate = await validateYupSchemaAgainstAnObject(schema, req.body.input); if (validate.length > 0) { return res.status(400).json({ diff --git a/src/v1/coin.ts b/src/v1/coin.ts new file mode 100644 index 0000000..e560734 --- /dev/null +++ b/src/v1/coin.ts @@ -0,0 +1,25 @@ +import { Response, Request } from "express"; +import prisma from "../database/prisma"; + +export const createCoin = async (req: Request, res: Response) => { + const { input } = req.body; + const coin = await prisma.coin.create({ + data: input, + }); + res.status(200).json({ + message: "Coin created successfully", + coin, + }); +}; + + +export const createCoinRate = async (req: Request, res: Response) => { + const { input } = req.body; + const coinRate = await prisma.coinRate.create({ + data: input, + }); + res.status(200).json({ + message: "Coin rate created successfully", + coinRate, + }); +}; diff --git a/src/v1/league.ts b/src/v1/league.ts new file mode 100644 index 0000000..603e35e --- /dev/null +++ b/src/v1/league.ts @@ -0,0 +1,168 @@ +import { Request, Response } from "express"; +import prisma from "../database/prisma"; + +export const createLeague = async (req: Request, res: Response) => { + try { + const { input } = req.body; + + const league = await prisma.league.create({ + data: input, + }); + return res.status(200).json({ + message: "League created successfully", + league, + }); + } catch (error) { + console.error("Error creating league:", error); + return res.status(500).json({ + message: "Error creating league", + error, + }); + } +}; + +export const assignUserToLeague = async (req: Request, res: Response) => { + try { + const { input } = req.body; + const leagueUser = await prisma.leagueUser.create({ + data: input, + }); + return res.status(200).json({ + message: "User assigned to league successfully", + leagueUser, + }); + } catch (error) { + console.error("Error assigning user to league:", error); + return res.status(500).json({ + message: "Error assigning user to league", + error, + }); + } +}; + +export const addUserExperience = async (req: Request, res: Response) => { + try { + const { input } = req.body; + const userExperience = await prisma.userExperience.create({ + data: input, + }); + return res.status(200).json({ + message: "User experience added successfully", + userExperience, + }); + } catch (error) { + console.error("Error adding user experience:", error); + return res.status(500).json({ + message: "Error adding user experience", + error, + }); + } +}; + +export const getLeagueLeaderboard = async (req: Request, res: Response) => { + try { + const { leagueId } = req.params; + const leaderboard = await prisma.userExperience.findMany({ + where: { + user: { + LeagueUser: { + some: { + league_id: leagueId, + }, + }, + }, + }, + orderBy: { + amount: 'desc', + }, + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }); + + return res.status(200).json({ + leaderboard: leaderboard.map(entry => ({ + user_id: entry.user.id, + username: entry.user.username, + experience: entry.amount, + })), + }); + } catch (error) { + console.error("Error getting league leaderboard:", error); + return res.status(500).json({ + message: "Error getting league leaderboard", + error, + }); + } +}; + +export const getUserLeagueStatus = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + + const userStatus = await prisma.leagueUser.findFirst({ + where: { + user_id: userId, + }, + include: { + league: true, + user: { + include: { + UserExperience: { + orderBy: { + created_at: 'desc', + }, + take: 1, + }, + }, + }, + }, + }); + + if (!userStatus) { + return res.status(404).json({ + message: "User league status not found", + }); + } + + // Get user's rank in the league + const leagueUsers = await prisma.userExperience.findMany({ + where: { + user: { + LeagueUser: { + some: { + league_id: userStatus.league_id, + }, + }, + }, + }, + orderBy: { + amount: 'desc', + }, + }); + + const userRank = leagueUsers.findIndex( + (user) => user.user_id === userId + ) + 1; + + return res.status(200).json({ + userStatus: { + league_id: userStatus.league_id, + current_rank: userRank, + experience: userStatus.user.UserExperience[0]?.amount || 0, + league_name: userStatus.league.name, + }, + }); + } catch (error) { + console.error("Error getting user league status:", error); + return res.status(500).json({ + message: "Error getting user league status", + error, + }); + } +}; \ No newline at end of file diff --git a/src/v1/routes.ts b/src/v1/routes.ts index d22f501..b7a2afc 100644 --- a/src/v1/routes.ts +++ b/src/v1/routes.ts @@ -1,46 +1,53 @@ import { middlewareValidateYupSchemaAgainstReqBody, validateUserExistsSentThroughReqBody } from "../utils/middlewares"; -import { createUserSchema, updateUserSchema } from "./user.validation"; -import { createUser, getUserBalance, updateUser } from "./user"; +import { createUserSchema, updateUserSchema } from "./validations/user.validation"; +import { createUser, getUserBalance, getUserBalanceByCoin, updateUser } from "./user"; +import { + createLeagueSchema, + assignUserToLeagueSchema, + addUserExperienceSchema, + getUserLeagueStatusSchema +} from "./validations/league.validation"; +import { + createLeague, + assignUserToLeague, + addUserExperience, + getLeagueLeaderboard, + getUserLeagueStatus +} from "./league"; import express from "express"; +import { + createTransactionSchema, + deleteTransactionSchema, + getTransactionSchema, + getTransactionsSchema, + updateTransactionSchema, +} from "./validations/transaction.validation"; +import { deleteTransaction, getTransaction, getTransactions } from "./transaction"; +import { updateTransaction } from "./transaction"; +import { createTransaction } from "./transaction"; +import { createCoinRateSchema, createCoinSchema } from "./validations/coin.validation"; +import { createCoin, createCoinRate } from "./coin"; +import { getLeagueLeaderboardSchema } from "./validations/league.validation"; const v1Routes = express.Router(); -v1Routes.get("/createTransaction", function (req, res) { - res.status(200).json({ - message: "createTransaction", - }); -}); -v1Routes.get("/updateTransaction", function (req, res) { - res.status(200).json({ - message: "updateTransaction", - }); -}); -v1Routes.delete("/deleteTransaction", function (req, res) { - res.status(200).json({ - message: "deleteTransaction", - }); -}); -v1Routes.get("/getTransaction", function (req, res) { - res.status(200).json({ - message: "getTransaction", - }); -}); -v1Routes.get("/getTransactions", function (req, res) { - res.status(200).json({ - message: "getTransactions", - }); -}); -v1Routes.get("/getTransactionsByUser", function (req, res) { - res.status(200).json({ - message: "getTransactionsByUser", - }); -}); - -v1Routes.post("/createWallet", function (req, res) { - res.status(200).json({ - message: "createWallet", - }); -}); +v1Routes.post( + "/createTransaction", + middlewareValidateYupSchemaAgainstReqBody(createTransactionSchema), + createTransaction, +); +v1Routes.put( + "/updateTransaction", + middlewareValidateYupSchemaAgainstReqBody(updateTransactionSchema), + updateTransaction, +); +v1Routes.delete( + "/deleteTransaction", + middlewareValidateYupSchemaAgainstReqBody(deleteTransactionSchema), + deleteTransaction, +); +v1Routes.get("/getTransaction", middlewareValidateYupSchemaAgainstReqBody(getTransactionSchema), getTransaction); +v1Routes.get("/getTransactions", middlewareValidateYupSchemaAgainstReqBody(getTransactionsSchema), getTransactions); v1Routes.post("/createUser", middlewareValidateYupSchemaAgainstReqBody(createUserSchema), createUser); v1Routes.put( @@ -49,5 +56,42 @@ v1Routes.put( middlewareValidateYupSchemaAgainstReqBody(updateUserSchema), updateUser, ); +v1Routes.get("/getUserBalanceByCoin", validateUserExistsSentThroughReqBody("body.input.id"), getUserBalanceByCoin); v1Routes.get("/getUserBalance", validateUserExistsSentThroughReqBody("body.input.id"), getUserBalance); + + +v1Routes.post("/createCoin", middlewareValidateYupSchemaAgainstReqBody(createCoinSchema), createCoin); +v1Routes.post("/createCoinRate", middlewareValidateYupSchemaAgainstReqBody(createCoinRateSchema), createCoinRate); + +// League routes +v1Routes.post( + "/createLeague", + middlewareValidateYupSchemaAgainstReqBody(createLeagueSchema), + createLeague +); + +v1Routes.post( + "/assignUserToLeague", + middlewareValidateYupSchemaAgainstReqBody(assignUserToLeagueSchema), + assignUserToLeague +); + +v1Routes.post( + "/addUserExperience", + middlewareValidateYupSchemaAgainstReqBody(addUserExperienceSchema), + addUserExperience +); + +v1Routes.get( + "/leagueLeaderboard/:leagueId", + middlewareValidateYupSchemaAgainstReqBody(getLeagueLeaderboardSchema), + getLeagueLeaderboard +); + +v1Routes.get( + "/userLeagueStatus/:userId", + middlewareValidateYupSchemaAgainstReqBody(getUserLeagueStatusSchema), + getUserLeagueStatus +); + export default v1Routes; diff --git a/src/v1/transaction.ts b/src/v1/transaction.ts index 7da3afa..a7efe8c 100644 --- a/src/v1/transaction.ts +++ b/src/v1/transaction.ts @@ -1,6 +1,76 @@ -export const createTransaction = () => {}; -export const updateTransaction = () => {}; -export const deleteTransaction = () => {}; -export const getTransaction = () => {}; -export const getTransactions = () => {}; -export const getTransactionsByUser = () => {}; +import prisma from "../database/prisma"; +import { generateError } from "../utils/errors"; +import { Request, Response } from "express"; + +export const createTransaction = async (req: Request, res: Response) => { + try { + const transaction = await prisma.transaction.create({ + data: req.body.input, + }); + res.status(200).json({ + message: "Transaction created successfully", + transaction: transaction, + }); + } catch (e) { + generateError(res, e); + } +}; +export const updateTransaction = async (req: Request, res: Response) => { + try { + const transaction = await prisma.transaction.update({ + where: { id: req.body.input.id }, + data: req.body.input, + }); + res.status(200).json({ + message: "Transaction updated successfully", + transaction: transaction, + }); + } catch (e) { + generateError(res, e); + } +}; +export const deleteTransaction = async (req: Request, res: Response) => { + try { + const transaction = await prisma.transaction.delete({ + where: { id: req.body.input.id }, + }); + res.status(200).json({ + message: "Transaction deleted successfully", + transaction: transaction, + }); + } catch (e) { + generateError(res, e); + } +}; +export const getTransaction = async (req: Request, res: Response) => { + try { + const transaction = await prisma.transaction.findUnique({ + where: { id: req.body.input.id }, + }); + res.status(200).json({ + message: "Transaction retrieved successfully", + transaction: transaction, + }); + } catch (e) { + generateError(res, e); + } +}; +export const getTransactions = async (req: Request, res: Response) => { + try { + const { user_id } = req.body.input; + let transactions; + if (user_id) { + transactions = await prisma.transaction.findMany({ + where: { user_id: user_id }, + }); + } else { + transactions = await prisma.transaction.findMany(); + } + res.status(200).json({ + message: "Transactions retrieved successfully", + transactions: transactions, + }); + } catch (e) { + generateError(res, e); + } +}; \ No newline at end of file diff --git a/src/v1/user.ts b/src/v1/user.ts index d86e6e9..c343864 100644 --- a/src/v1/user.ts +++ b/src/v1/user.ts @@ -5,17 +5,14 @@ import { generateError } from "../utils/errors"; export const createUser = async (req: Request, res: Response) => { try { let response = await prisma.user.create({ - data: { - email: req.body.input.email, - username: req.body.input.username, - application_user_id: req.body.input.application_user_id, - }, + data: req.body.input, }); res.status(200).json({ message: "User created successfully", user: response, }); } catch (e) { + console.log(e); generateError(res, e); } }; @@ -38,8 +35,76 @@ export const updateUser = async (req: Request, res: Response) => { } }; -export const getUserBalance = (req: Request, res: Response) => { +export const getUserBalanceByCoin = async (req: Request, res: Response) => { + const { coin_id } = req.body.input; + const user = req.CurrentRequestUser; + const deposits = await prisma.transaction.findMany({ + where: { + user_id: user.id, + coin_id: coin_id, + transaction_type: "DEPOSIT", + }, + }); + const withdrawals = await prisma.transaction.findMany({ + where: { + user_id: user.id, + coin_id: coin_id, + transaction_type: "WITHDRAWAL", + }, + }); + const coin = await prisma.coin.findUnique({ + where: { + id: coin_id, + }, + }); + + if (!coin) { + res.status(404).json({ + message: "Coin not found", + }); + } + + const balance = + deposits.reduce((acc, transaction) => acc + transaction.amount, 0) - + withdrawals.reduce((acc, transaction) => acc + transaction.amount, 0); + res.status(200).json({ + message: "getUserBalance", + balance: balance, + coin: coin, + }); +}; + +export const getUserBalance = async (req: Request, res: Response) => { + const user = req.CurrentRequestUser; + const userAllTransactions = await prisma.transaction.findMany({ + where: { + user_id: user.id, + }, + }); + + const coins = userAllTransactions.map((transaction) => transaction.coin_id); + const uniqueCoins = [...new Set(coins)]; + const balances: Record = {}; + + for (const coinId of uniqueCoins) { + const coin = await prisma.coin.findUnique({ + where: { + id: coinId, + }, + }); + if (!coin) { + continue; + } + const transactions = userAllTransactions.filter((transaction) => transaction.coin_id === coinId); + const deposits = transactions.filter((transaction) => transaction.transaction_type === "DEPOSIT"); + const withdrawals = transactions.filter((transaction) => transaction.transaction_type === "WITHDRAWAL"); + const balance = + deposits.reduce((acc, transaction) => acc + transaction.amount, 0) - + withdrawals.reduce((acc, transaction) => acc + transaction.amount, 0); + balances[coin.name] = balance; + } res.status(200).json({ message: "getUserBalance", + balances: balances, }); }; diff --git a/src/v1/validations/coin.validation.ts b/src/v1/validations/coin.validation.ts new file mode 100644 index 0000000..ff7b8b5 --- /dev/null +++ b/src/v1/validations/coin.validation.ts @@ -0,0 +1,28 @@ +import { InferType, number, object, string } from "yup"; + +export const createCoinSchema = object({ + name: string().required("Name is required"), +}); +export type CreateCoinSchema = InferType; + +export const updateCoinSchema = object({ + id: number().required("ID is required"), + name: string().required("Name is required"), +}); +export type UpdateCoinSchema = InferType; + + +export const createCoinRateSchema = object({ + coin_one_id: string().required("Coin One ID is required"), + coin_two_id: string().required("Coin Two ID is required"), + rate: number().required("Rate is required"), +}); +export type CreateCoinRateSchema = InferType; + +export const updateCoinRateSchema = object({ + id: number().required("ID is required"), + coin_one_id: string().required("Coin One ID is required"), + coin_two_id: string().required("Coin Two ID is required"), + rate: number().required("Rate is required"), +}); +export type UpdateCoinRateSchema = InferType; diff --git a/src/v1/validations/league.validation.ts b/src/v1/validations/league.validation.ts new file mode 100644 index 0000000..c1ca5d7 --- /dev/null +++ b/src/v1/validations/league.validation.ts @@ -0,0 +1,114 @@ +import { InferType, object, string, number, date } from "yup"; +import prisma from "../../database/prisma"; + +// Validation for creating a new league +export const createLeagueSchema = object({ + id: string().uuid(), + name: string() + .required("League name is required") + .min(3, "League name must be at least 3 characters") + .max(50, "League name cannot exceed 50 characters"), + demotion_rate: number() + .required("Demotion rate is required") + .min(0, "Demotion rate must be between 0 and 1") + .max(1, "Demotion rate must be between 0 and 1") + .test( + "is-decimal", + "Demotion rate must be a decimal between 0 and 1", + (value) => value !== undefined && value >= 0 && value <= 1 + ), + promotion_rate: number() + .required("Promotion rate is required") + .min(0, "Promotion rate must be between 0 and 1") + .max(1, "Promotion rate must be between 0 and 1") + .test( + "is-decimal", + "Promotion rate must be a decimal between 0 and 1", + (value) => value !== undefined && value >= 0 && value <= 1 + ), + league_start_date: date(), + league_end_date: date() +}); +export type CreateLeagueSchema = InferType; + +// Validation for assigning a user to a league +export const assignUserToLeagueSchema = object({ + id: string().uuid(), + user_id: string() + .uuid("Invalid user ID format") + .required("User ID is required"), + league_id: string() + .uuid("Invalid league ID format") + .required("League ID is required"), +}).test( + "unique-league-assignment", + "User can only be assigned to one league at a time", + async function(value) { + if (!value.user_id || !value.league_id) return false; + + try { + const existingAssignment = await prisma.leagueUser.findFirst({ + where: { + user_id: value.user_id, + league_id: { + not: value.league_id // Allow updating the same league + } + } + }); + return !existingAssignment; + } catch (error) { + return false; + } + } +); +export type AssignUserToLeagueSchema = InferType; + +// Validation for adding user experience +export const addUserExperienceSchema = object({ + id: string().uuid(), + user_id: string() + .uuid("Invalid user ID format") + .required("User ID is required"), + amount: number() + .positive("Experience amount must be positive") + .required("Experience amount is required") + .test( + "is-valid-amount", + "Experience amount must be a reasonable number (0-1000000)", + (value) => value !== undefined && value >= 0 && value <= 1000000 + ), +}).test( + "user-in-league", + "User must be assigned to a league to gain experience", + async function(value) { + if (!value.user_id) return false; + + try { + const userLeague = await prisma.leagueUser.findFirst({ + where: { + user_id: value.user_id + } + }); + return !!userLeague; + } catch (error) { + return false; + } + } +); +export type AddUserExperienceSchema = InferType; + +// Additional validation for getting league leaderboard +export const getLeagueLeaderboardSchema = object({ + leagueId: string() + .uuid("Invalid league ID format") + .required("League ID is required"), +}); +export type GetLeagueLeaderboardSchema = InferType; + +// Additional validation for getting user league status +export const getUserLeagueStatusSchema = object({ + userId: string() + .uuid("Invalid user ID format") + .required("User ID is required"), +}); +export type GetUserLeagueStatusSchema = InferType; \ No newline at end of file diff --git a/src/v1/validations/transaction.validation.ts b/src/v1/validations/transaction.validation.ts new file mode 100644 index 0000000..d33dc9b --- /dev/null +++ b/src/v1/validations/transaction.validation.ts @@ -0,0 +1,35 @@ +import { InferType, number, object, string } from "yup"; + +export const createTransactionSchema = object({ + user_id: number().required("User ID is required"), + amount: number().required("Amount is required"), + coin_id: number().required("Coin ID is required"), + transaction_type: string().oneOf(["DEPOSIT", "WITHDRAWAL"]).required("Transaction type is required"), +}); +export type CreateTransactionSchema = InferType; + +export const updateTransactionSchema = object({ + id: number().required("ID is required"), + user_id: number().optional(), + amount: number().optional(), + coin_id: number().optional(), + transaction_type: string().oneOf(["DEPOSIT", "WITHDRAWAL"]).optional() +}); +export type UpdateTransactionSchema = InferType; + +export const deleteTransactionSchema = object({ + id: number().required("ID is required"), +}); +export type DeleteTransactionSchema = InferType; + +export const getTransactionsSchema = object({ + user_id: number().optional(), +}); +export type GetTransactionsSchema = InferType; + + +export const getTransactionSchema = object({ + id: number().required("ID is required"), +}); +export type GetTransactionSchema = InferType; + \ No newline at end of file diff --git a/src/v1/user.validation.ts b/src/v1/validations/user.validation.ts similarity index 71% rename from src/v1/user.validation.ts rename to src/v1/validations/user.validation.ts index a853490..0c71dd2 100644 --- a/src/v1/user.validation.ts +++ b/src/v1/validations/user.validation.ts @@ -1,6 +1,7 @@ import { object, string, number, date, InferType } from "yup"; export const createUserSchema = object({ + id: string().required("ID is required"), username: string().required("Username is required"), application_user_id: string().required("Application User ID is required"), email: string().email().required("Email is required"), @@ -8,9 +9,14 @@ export const createUserSchema = object({ export type CreateUserSchema = InferType; export const updateUserSchema = object({ - id: number().required("ID is required"), + id: string().required("ID is required"), username: string().required("Username is required"), application_user_id: string().required("Application User ID is required"), email: string().email().required("Email is required"), }); export type UpdateUserSchema = InferType; + +export const getUserBalanceSchema = object({ + coin_id: number().required("Coin ID is required"), +}); +export type GetUserBalanceSchema = InferType; diff --git a/src/v1/wallet.ts b/src/v1/wallet.ts deleted file mode 100644 index 56eae8a..0000000 --- a/src/v1/wallet.ts +++ /dev/null @@ -1 +0,0 @@ -export const createWallet = () => {}; diff --git a/tests/index.test.ts b/tests/index.test.ts index bd50db1..8036b65 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,8 +1,13 @@ -import {describe, expect, test} from '@jest/globals'; -import { axios } from './test-data/axios'; +import axiosRoot from "axios"; +import { describe, expect, test } from "@jest/globals"; +import { axios } from "./test-data/axios"; +import { users, coins, coinRates } from "./test-data/data"; -describe('First Steps', () => { - test('Expects server is running', async () => { +describe("First Steps", () => { + test("Expects server is running", async () => { + const clean = await axios.get("/clean"); + expect(clean.status).toBe(200); + expect(clean.data.message).toBe("Database cleaned"); const response = await axios.get("/"); const message = response.data.message; const version = response.data.version; @@ -10,4 +15,52 @@ describe('First Steps', () => { expect(message.constructor).toBe(String); expect(version.constructor).toBe(String); }); -}); \ No newline at end of file +}); + +describe("User Routes", () => { + test("Create coins", async () => { + for (const coin of coins) { + const response = await axios.post("/api/v1/createCoin", { + input: coin, + }); + expect(response.status).toBe(200); + const { data } = response; + expect(data.message).toBe("Coin created successfully"); + expect(data.coin.id).toBeDefined(); + expect(data.coin.name).toBe(coin.name); + } + for (const coinRate of coinRates) { + try { + const response = await axios.post("/api/v1/createCoinRate", { + input: coinRate, + }); + expect(response.status).toBe(200); + const { data } = response; + expect(data.message).toBe("Coin rate created successfully"); + expect(data.coinRate.id).toBeDefined(); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); + test("Create User", async () => { + for (const user of users) { + try { + const response = await axios.post("/api/v1/createUser", { + input: user, + }); + expect(response.status).toBe(200); + const { data } = response; + expect(data).toBeDefined(); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); +}); diff --git a/tests/league.test.ts b/tests/league.test.ts new file mode 100644 index 0000000..d642010 --- /dev/null +++ b/tests/league.test.ts @@ -0,0 +1,116 @@ +import axiosRoot from "axios"; +import { describe, expect, test } from "@jest/globals"; +import { axios } from "./test-data/axios"; +import { leagues, leagueUsers, userExperiences } from "./test-data/league-data"; + +describe("League Routes", () => { + test("Create Leagues", async () => { + for (const league of leagues) { + try { + console.log(league, "Asdfasdf"); + const response = await axios.post("/api/v1/createLeague", { + input: league, + }); + console.log(response, "wownice"); + expect(response.status).toBe(200); + const { data } = response; + expect(data.message).toBe("League created successfully"); + expect(data.league.id).toBeDefined(); + expect(data.league.name).toBe(league.name); + expect(data.league.demotion_rate).toBe(league.demotion_rate); + expect(data.league.promotion_rate).toBe(league.promotion_rate); + } catch (error) { + console.log((error as Error).message, "wow error happened"); + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); + + test("Assign Users to Leagues", async () => { + for (const leagueUser of leagueUsers) { + try { + const response = await axios.post("/api/v1/assignUserToLeague", { + input: leagueUser, + }); + expect(response.status).toBe(200); + const { data } = response; + expect(data.message).toBe("User assigned to league successfully"); + expect(data.leagueUser.id).toBeDefined(); + expect(data.leagueUser.user_id).toBe(leagueUser.user_id); + expect(data.leagueUser.league_id).toBe(leagueUser.league_id); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); + + test("Add User Experience", async () => { + for (const experience of userExperiences) { + try { + const response = await axios.post("/api/v1/addUserExperience", { + input: experience, + }); + expect(response.status).toBe(200); + const { data } = response; + expect(data.message).toBe("User experience added successfully"); + expect(data.userExperience.id).toBeDefined(); + expect(data.userExperience.user_id).toBe(experience.user_id); + expect(data.userExperience.amount).toBe(experience.amount); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); + + test("Get League Leaderboard", async () => { + for (const league of leagues) { + try { + const response = await axios.get(`/api/v1/leagueLeaderboard/${league.id}`); + expect(response.status).toBe(200); + const { data } = response; + expect(data.leaderboard).toBeDefined(); + expect(Array.isArray(data.leaderboard)).toBe(true); + + // Verify leaderboard is sorted by experience (highest first) + const sortedCorrectly = data.leaderboard.every((user: any, index: number, array: any[]) => { + if (index === 0) return true; + return user.experience <= array[index - 1].experience; + }); + expect(sortedCorrectly).toBe(true); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); + + test("Get User League Status", async () => { + for (const leagueUser of leagueUsers) { + try { + const response = await axios.get(`/api/v1/userLeagueStatus/${leagueUser.user_id}`); + expect(response.status).toBe(200); + const { data } = response; + expect(data.userStatus).toBeDefined(); + expect(data.userStatus.league_id).toBe(leagueUser.league_id); + expect(data.userStatus.current_rank).toBeDefined(); + expect(data.userStatus.experience).toBeDefined(); + } catch (error) { + if (axiosRoot.isAxiosError(error)) { + console.log(error.response?.data); + expect(error.response?.status).toBe(200); + } + } + } + }); +}); \ No newline at end of file diff --git a/tests/test-data/data.ts b/tests/test-data/data.ts new file mode 100644 index 0000000..a10e132 --- /dev/null +++ b/tests/test-data/data.ts @@ -0,0 +1,64 @@ +import { v4 as uuidv4 } from "uuid"; + +export const users = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710ad", + application_user_id: "11231234567890", + username: "user_1", + email: "user_1@example.com", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710ae", + username: "user_2", + application_user_id: "131", + email: "user_2@example.com", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710af", + username: "user_3", + application_user_id: "167890", + email: "user_3@example.com", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710b0", + username: "user_4", + application_user_id: "12345123167890", + email: "user_4@example.com", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710b1", + username: "user_5", + application_user_id: "1231", + email: "user_5@example.com", + }, +]; + +export const coins = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710b2", + name: "UC", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710b3", + name: "USDT", + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710b4", + name: "USDC", + }, +]; + +export const coinRates = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710b5", + coin_one_id: "UC", + coin_two_id: "USDT", + rate: 1, + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710b6", + coin_one_id: "UC", + coin_two_id: "USDC", + rate: 2, + }, +]; diff --git a/tests/test-data/league-data.ts b/tests/test-data/league-data.ts new file mode 100644 index 0000000..ee94655 --- /dev/null +++ b/tests/test-data/league-data.ts @@ -0,0 +1,86 @@ +import { v4 as uuidv4 } from "uuid"; + +export const leagues = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710c1", + name: "Bronze League", + demotion_rate: 0.2, // Bottom 20% get demoted + promotion_rate: 0.1, // Top 10% get promoted + league_start_date: new Date("2024-03-01T00:00:00Z"), + league_end_date: new Date("2024-03-31T23:59:59Z"), + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710c2", + name: "Silver League", + demotion_rate: 0.15, + promotion_rate: 0.15, + league_start_date: new Date("2024-03-01T00:00:00Z"), + league_end_date: new Date("2024-03-31T23:59:59Z"), + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710c3", + name: "Gold League", + demotion_rate: 0.1, + promotion_rate: 0.2, + league_start_date: new Date("2024-03-01T00:00:00Z"), + league_end_date: new Date("2024-03-31T23:59:59Z"), + } +]; + +// League user assignments - connecting users to leagues +export const leagueUsers = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710d1", + user_id: "b680a082-b999-4fed-871c-4a194b5710ad", // user_1 + league_id: "b680a082-b999-4fed-871c-4a194b5710c1", // Bronze League + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710d2", + user_id: "b680a082-b999-4fed-871c-4a194b5710ae", // user_2 + league_id: "b680a082-b999-4fed-871c-4a194b5710c2", // Silver League + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710d3", + user_id: "b680a082-b999-4fed-871c-4a194b5710af", // user_3 + league_id: "b680a082-b999-4fed-871c-4a194b5710c2", // Silver League + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710d4", + user_id: "b680a082-b999-4fed-871c-4a194b5710b0", // user_4 + league_id: "b680a082-b999-4fed-871c-4a194b5710c3", // Gold League + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710d5", + user_id: "b680a082-b999-4fed-871c-4a194b5710b1", // user_5 + league_id: "b680a082-b999-4fed-871c-4a194b5710c1", // Bronze League + } +]; + +// User experience data for testing leaderboard rankings +export const userExperiences = [ + { + id: "b680a082-b999-4fed-871c-4a194b5710e1", + user_id: "b680a082-b999-4fed-871c-4a194b5710ad", // user_1 + amount: 1000.0, + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710e2", + user_id: "b680a082-b999-4fed-871c-4a194b5710ae", // user_2 + amount: 2500.0, + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710e3", + user_id: "b680a082-b999-4fed-871c-4a194b5710af", // user_3 + amount: 2200.0, + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710e4", + user_id: "b680a082-b999-4fed-871c-4a194b5710b0", // user_4 + amount: 5000.0, + }, + { + id: "b680a082-b999-4fed-871c-4a194b5710e5", + user_id: "b680a082-b999-4fed-871c-4a194b5710b1", // user_5 + amount: 800.0, + } +]; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 090dbab..d34b47d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3055,6 +3055,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@^11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.3.tgz#248451cac9d1a4a4128033e765d137e2b2c49a3d" + integrity sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"