Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions backend/src/controllers/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`);
Expand Down
79 changes: 79 additions & 0 deletions backend/src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions backend/src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 }),
Expand Down
4 changes: 4 additions & 0 deletions backend/src/schema/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
2 changes: 1 addition & 1 deletion backend/src/schema/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 6 additions & 1 deletion backend/tests/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand Down
67 changes: 64 additions & 3 deletions frontend/src/components/chart/chart-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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 (
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border/40 px-2">
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="gap-1.5 px-2 text-xs">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5 px-2 text-xs focus-visible:border-transparent focus-visible:brightness-100 focus-visible:ring-0"
>
<span className="w-10">{interval}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-24">
<DropdownMenuContent align="start" className="min-w-24" portalled={false}>
<DropdownMenuRadioGroup
value={interval}
onValueChange={(value) => onIntervalChange(value as CandleInterval)}
Expand All @@ -54,6 +83,38 @@ export function ChartToolbar({

<div className="mx-1 h-4 w-px bg-border/70" />

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-28 justify-between gap-1.5 px-2 text-xs focus-visible:border-transparent focus-visible:brightness-100 focus-visible:ring-0"
>
<span className="flex items-center gap-1.5">
<ActiveChartIcon className="size-3.5" />
{activeChartStyle.label}
</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-28 min-w-28" portalled={false}>
<DropdownMenuRadioGroup
value={chartStyle}
onValueChange={(value) => onChartStyleChange(value as ChartStyle)}
>
{CHART_STYLES.map(({ value, label, Icon }) => (
<DropdownMenuRadioItem key={value} value={value} className="text-xs">
<Icon className="size-3.5" />
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>

<div className="mx-1 hidden h-4 w-px bg-border/70 sm:block" />

<Button
type="button"
variant={showVolume ? "secondary" : "ghost"}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/chart/chart-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Candle } from "@/types";

export const CHART_RANGES = ["All", "3M", "1M", "3D", "1D"] as const;
export type ChartRange = (typeof CHART_RANGES)[number];
export type ChartStyle = "candlestick" | "line" | "area";

const RANGE_SECONDS: Record<ChartRange, number | null> = {
All: null,
Expand All @@ -21,6 +22,9 @@ export function getChartColors() {
text: token("--muted-foreground", "#8e8e93"),
grid: token("--border", "#1e1e24"),
crosshair: token("--chart-crosshair", "#3e3e48"),
line: token("--chart-line", "#3f6db5"),
lineFillTop: token("--chart-line-fill-top", "rgba(63, 109, 181, 0.14)"),
lineFillBottom: token("--chart-line-fill-bottom", "rgba(63, 109, 181, 0)"),
up: token("--green-text", "#00c087"),
down: token("--red-text", "#ff3b30"),
volumeUp: token("--green-bg", "#1a3a2a"),
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/chart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type CandleInterval, useCandles } from "../../hooks/use-candles";
import { useMarket } from "@/context/MarketContext";
import { cn } from "@/lib/utils";
import { ChartToolbar } from "./chart-toolbar";
import { CHART_RANGES, type ChartRange } from "./chart-utils";
import { CHART_RANGES, type ChartRange, type ChartStyle } from "./chart-utils";
import { DepthChart } from "./depth-chart";
import { MarketInfo } from "./market-info";
import { PriceChart } from "./price-chart";
Expand Down Expand Up @@ -53,6 +53,7 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
const panelRef = useRef<HTMLDivElement>(null);
const [tab, setTab] = useState<ChartTab>("chart");
const [interval, setInterval] = useState<CandleInterval>("1H");
const [chartStyle, setChartStyle] = useState<ChartStyle>("candlestick");
const [range, setRange] = useState<ChartRange>("All");
const [showVolume, setShowVolume] = useState(true);
const [hoveredCandle, setHoveredCandle] = useState<Candle | null>(null);
Expand Down Expand Up @@ -107,8 +108,10 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
<>
<ChartToolbar
interval={interval}
chartStyle={chartStyle}
showVolume={showVolume}
onIntervalChange={setInterval}
onChartStyleChange={setChartStyle}
onToggleVolume={() => setShowVolume((visible) => !visible)}
onGoLive={() => setGoLiveKey((key) => key + 1)}
onReset={resetChart}
Expand Down Expand Up @@ -182,6 +185,7 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
) : (
<PriceChart
candles={candles}
chartStyle={chartStyle}
showVolume={showVolume}
range={range}
resetKey={resetKey}
Expand Down
Loading
Loading