diff --git a/backend/src/controllers/market.ts b/backend/src/controllers/market.ts index 701b495..6e8d17b 100644 --- a/backend/src/controllers/market.ts +++ b/backend/src/controllers/market.ts @@ -16,10 +16,10 @@ import { marketStore } from "../store/market"; import { sendApiError, sendEngineError } from "../utils/apiError"; const intervalMap = { - "1M": "1 minute", - "5M": "5 minutes", - "15M": "15 minutes", - "30M": "30 minutes", + "1m": "1 minute", + "5m": "5 minutes", + "15m": "15 minutes", + "30m": "30 minutes", "1H": "1 hour", "4H": "4 hours", "1D": "1 day", @@ -107,7 +107,7 @@ export async function getCandles(req: Request, res: Response) { } const { symbol } = parsedParams.data; - const { interval = "15M" } = parsedQueries.data; + const { interval = "15m" } = parsedQueries.data; const bucket = intervalMap[interval]; if (!marketStore.has(symbol)) { sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index e21e4d6..b88ca03 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -11,6 +11,10 @@ import { getReferencePrices } from "../utils/referencePrice"; import { config } from "../config"; import { ApiError, sendApiError, sendEngineError } from "../utils/apiError"; import { claimDailyCredit } from "../services/dailyCredit"; +import { updateEmailSchema } from "../schema/auth"; +import { isEmailDeliveryEnabled, sendVerificationEmail } from "../utils/emailClient"; +import { Prisma } from "../../generated/prisma/client"; +import crypto from "crypto"; export function getUserId(req: Request): string { const userId = req.principal?.userId; @@ -50,6 +54,81 @@ export async function getUserData(req: Request, res: Response) { } } +export async function updateUserEmail(req: Request, res: Response) { + const parsedBody = updateEmailSchema.safeParse(req.body); + + if (!parsedBody.success) { + sendValidationError(res, parsedBody.error); + return; + } + + const userId = getUserId(req); + const { email } = parsedBody.data; + + if (!isEmailDeliveryEnabled()) { + sendApiError(res, 503, "SERVICE_UNAVAILABLE", "Email delivery is currently unavailable"); + return; + } + + try { + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (!user) { + res.clearCookie("token", config.cookie); + sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required"); + return; + } + + if (user.emailVerified) { + sendApiError(res, 409, "EMAIL_ALREADY_VERIFIED", "Verified email cannot be changed here"); + return; + } + + if (user.email === email) { + sendApiError(res, 409, "EMAIL_UNCHANGED", "Enter a different email address"); + return; + } + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + sendApiError(res, 409, "EMAIL_IN_USE", "An account with this email already exists"); + return; + } + + const token = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + const updatedUser = await prisma.$transaction(async (tx) => { + await tx.verificationToken.deleteMany({ where: { userId } }); + const updated = await tx.user.update({ where: { id: userId }, data: { email } }); + await tx.verificationToken.create({ + data: { + userId, + tokenHash, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + return updated; + }); + + void sendVerificationEmail(updatedUser.name, updatedUser.email, token); + + res.status(200).json({ + id: updatedUser.id, + name: updatedUser.name, + email: updatedUser.email, + emailVerified: updatedUser.emailVerified, + message: "Email updated. Check your inbox for a new verification link.", + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + sendApiError(res, 409, "EMAIL_IN_USE", "An account with this email already exists"); + return; + } + logger.error("Email update failed", error); + sendApiError(res, 500, "INTERNAL_ERROR", "Email could not be updated"); + } +} + export async function getTradeHistory(req: Request, res: Response) { const userId = getUserId(req); const parsedQueries = tradeHistoryQuerySchema.safeParse(req.query); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 6feab5e..f65a4d6 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -7,8 +7,10 @@ import { getBalance, getPortfolio, getUserData, + updateUserEmail, } from "../controllers/user"; import { getTradeHistory } from "../controllers/user"; +import { authLimiter } from "../middleware/rateLimit"; export const userRouter = Router(); @@ -17,6 +19,12 @@ userRouter.get( requireAccess({ types: ["session"], allowUnverified: true }), asyncHandler(getUserData), ); +userRouter.patch( + "/users/me/email", + authLimiter, + requireAccess({ types: ["session"], allowUnverified: true }), + asyncHandler(updateUserEmail), +); userRouter.get( "/trades", requireAccess({ scopes: ["ORDER_READ"], allowUnverified: true }), diff --git a/backend/src/schema/auth.ts b/backend/src/schema/auth.ts index 2776662..97b060d 100644 --- a/backend/src/schema/auth.ts +++ b/backend/src/schema/auth.ts @@ -18,3 +18,7 @@ export const verifyEmailSchema = z.object({ export const resendVerificationEmailSchema = z.object({ email: z.email().trim().min(1, "email is required"), }); + +export const updateEmailSchema = z.object({ + email: z.email().trim().min(1, "email is required"), +}); diff --git a/backend/src/schema/exchange.ts b/backend/src/schema/exchange.ts index 051ad21..9d8e72c 100644 --- a/backend/src/schema/exchange.ts +++ b/backend/src/schema/exchange.ts @@ -34,7 +34,7 @@ export const orderBodySchema = z.discriminatedUnion("type", [ ]); export const candleQuerySchema = z.object({ - interval: z.enum(["1M", "5M", "15M", "30M", "1H", "4H", "1D"]).optional(), + interval: z.enum(["1m", "5m", "15m", "30m", "1H", "4H", "1D"]).optional(), }); export const orderQuerySchema = z.object({ diff --git a/backend/tests/schemas.test.ts b/backend/tests/schemas.test.ts index 40be101..666940b 100644 --- a/backend/tests/schemas.test.ts +++ b/backend/tests/schemas.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { signinSchema, signupSchema } from "../src/schema/auth"; +import { signinSchema, signupSchema, updateEmailSchema } from "../src/schema/auth"; import { orderBodySchema, orderQuerySchema } from "../src/schema/exchange"; import { leaderboardQuerySchema } from "../src/schema/leaderboard"; @@ -18,6 +18,11 @@ test("account credentials reject malformed or missing identity fields", () => { expect(signinSchema.safeParse({ email: "alice@test.com", password: "" }).success).toBe(false); }); +test("email correction accepts only a valid email", () => { + expect(updateEmailSchema.safeParse({ email: "correct@example.com" }).success).toBe(true); + expect(updateEmailSchema.safeParse({ email: "wrong-email" }).success).toBe(false); +}); + test("leaderboard pagination applies public query limits", () => { expect(leaderboardQuerySchema.safeParse({ limit: "25", offset: "0" }).success).toBe(true); expect(leaderboardQuerySchema.safeParse({ limit: "101" }).success).toBe(false); diff --git a/frontend/src/components/chart/chart-toolbar.tsx b/frontend/src/components/chart/chart-toolbar.tsx index 7fe2a32..3fd539f 100644 --- a/frontend/src/components/chart/chart-toolbar.tsx +++ b/frontend/src/components/chart/chart-toolbar.tsx @@ -1,4 +1,13 @@ -import { ChartNoAxesColumn, ChevronDown, LocateFixed, Maximize2, RotateCcw } from "lucide-react"; +import { + ChartArea, + ChartCandlestick, + ChartLine, + ChartNoAxesColumn, + ChevronDown, + LocateFixed, + Maximize2, + RotateCcw, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,11 +17,20 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { CANDLE_INTERVALS, type CandleInterval } from "../../hooks/use-candles"; +import type { ChartStyle } from "./chart-utils"; + +const CHART_STYLES = [ + { value: "candlestick", label: "Candle", Icon: ChartCandlestick }, + { value: "line", label: "Line", Icon: ChartLine }, + { value: "area", label: "Area", Icon: ChartArea }, +] as const; type ChartToolbarProps = { interval: CandleInterval; + chartStyle: ChartStyle; showVolume: boolean; onIntervalChange: (interval: CandleInterval) => void; + onChartStyleChange: (style: ChartStyle) => void; onToggleVolume: () => void; onGoLive: () => void; onReset: () => void; @@ -21,24 +39,35 @@ type ChartToolbarProps = { export function ChartToolbar({ interval, + chartStyle, showVolume, onIntervalChange, + onChartStyleChange, onToggleVolume, onGoLive, onReset, onFullscreen, }: ChartToolbarProps) { + const activeChartStyle = + CHART_STYLES.find((option) => option.value === chartStyle) ?? CHART_STYLES[0]; + const ActiveChartIcon = activeChartStyle.Icon; + return (
- - + onIntervalChange(value as CandleInterval)} @@ -54,6 +83,38 @@ export function ChartToolbar({
+ + + + + + onChartStyleChange(value as ChartStyle)} + > + {CHART_STYLES.map(({ value, label, Icon }) => ( + + + {label} + + ))} + + + + +
+ + +
+ + ) : ( + + )} Don't see the email in your inbox or spam folder?