From fbfefff1b5105af97e7ae38310a87ef031b5c37b Mon Sep 17 00:00:00 2001 From: c3mzz Date: Tue, 16 Jun 2026 14:00:55 +0700 Subject: [PATCH 1/5] Add leaderboard --- apps/api/src/modules/user/user.controller.ts | 38 ++++ apps/api/src/modules/user/user.service.ts | 187 ++++++++++++++- apps/web/src/pages/leaderboard.tsx | 228 +++++++++++++++++++ apps/web/src/pages/user/[userId].tsx | 227 +++++++++++++++++- packages/contract/src/index.ts | 61 +++++ 5 files changed, 732 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/pages/leaderboard.tsx diff --git a/apps/api/src/modules/user/user.controller.ts b/apps/api/src/modules/user/user.controller.ts index b7c3fcc..2fec737 100644 --- a/apps/api/src/modules/user/user.controller.ts +++ b/apps/api/src/modules/user/user.controller.ts @@ -34,6 +34,15 @@ export class UserController { }) } + @TsRestHandler(c.getLeaderboard, { jsonQuery: true }) + getLeaderboard(@User() user: UserDTO | undefined) { + return tsRestHandler(c.getLeaderboard, async ({ query }) => { + const isAdmin = user?.role === Role.Admin + const { data, total } = await this.userService.getLeaderboard(query, user?.id, isAdmin) + return { status: 200, body: { data, total } } + }) + } + @TsRestHandler(c.getOnlineUsers) getOnlineUsers() { return tsRestHandler(c.getOnlineUsers, async () => { @@ -82,4 +91,33 @@ export class UserController { } ) } + + @TsRestHandler(c.updateLeaderboardVisibility) + @Roles(Role.Admin, Role.User) + updateLeaderboardVisibility(@User() user: UserDTO) { + return tsRestHandler( + c.updateLeaderboardVisibility, + async ({ params: { userId }, body }) => { + const id = z.coerce.number().parse(userId) + if (user.role !== Role.Admin && user.id !== id) { + return { status: 403, body: { message: 'Forbidden' } } + } + const showInLeaderboard = await this.userService.updateLeaderboardVisibilityById( + body.showInLeaderboard, + id + ) + return { status: 200, body: showInLeaderboard } + } + ) + } + + @TsRestHandler(c.getPassedProblems) + getPassedProblems(@User() user: UserDTO | undefined) { + return tsRestHandler(c.getPassedProblems, async ({ params, query }) => { + const userId = z.coerce.number().parse(params.userId) + const isAdmin = user?.role === Role.Admin + const problems = await this.userService.getPassedProblems(userId, query.sortBy, isAdmin) + return { status: 200, body: problems } + }) + } } diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index adc43ad..83b771f 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -9,7 +9,7 @@ import { PrismaService } from 'src/core/database/prisma.service' import { userList } from 'src/utils' import { searchId } from 'src/utils/search' -import { ListPaginationQuerySchema } from '@otog/contract' +import { ListPaginationQuerySchema, LeaderboardQuerySchema } from '@otog/contract' import { ContestMode, Prisma, User } from '@otog/database' export const WITHOUT_PASSWORD = { @@ -18,6 +18,7 @@ export const WITHOUT_PASSWORD = { showName: true, role: true, rating: true, + showInLeaderboard: true, } as const @Injectable() @@ -69,10 +70,123 @@ export class UserService { showName: true, role: true, rating: true, + showInLeaderboard: true, }, orderBy: { id: 'desc' }, }) } + async getLeaderboard( + args: LeaderboardQuerySchema, + requestingUserId: number | undefined, + isAdminUser: boolean, + ) { + const allUsers = await this.prisma.user.findMany({ + select: { + id: true, + username: true, + showName: true, + role: true, + rating: true, + showInLeaderboard: true, + submission: { + select: { + status: true, + problemId: true, + problem: { + select: { + show: true, + }, + }, + }, + }, + }, + }) + + const withStats = allUsers.map((user) => { + const passedPublic = new Set() + const passedAll = new Set() + + for (const sub of user.submission) { + if (!sub.problem) continue + if (sub.status !== 'accept') continue + if (sub.problem.show) passedPublic.add(sub.problemId) + passedAll.add(sub.problemId) + } + + return { + id: user.id, + username: user.username, + showName: user.showName, + role: user.role, + rating: user.rating, + showInLeaderboard: user.showInLeaderboard, + passedCount: passedPublic.size, + passedCountAll: passedAll.size, + } + }) + + const sortFn = ( + a: (typeof withStats)[0], + b: (typeof withStats)[0] + ) => { + if (b.passedCount !== a.passedCount) return b.passedCount - a.passedCount + const rA = a.rating ?? 0 + const rB = b.rating ?? 0 + if (rB !== rA) return rB - rA + return a.showName.localeCompare(b.showName) + } + + const applySearch = ( + list: T[], + search: string | undefined + ) => { + if (!search) return list + const q = search.trim().toLowerCase() + return list.filter( + (u) => + u.showName.toLowerCase().includes(q) || + u.username.toLowerCase().includes(q) + ) + } + + if (isAdminUser && args.showAll) { + const sorted = [...withStats].sort(sortFn) + const ranked = sorted.map((item, index) => ({ ...item, rank: index + 1 })) + const filtered = applySearch(ranked, args.search) + return { + data: filtered.slice(args.skip, args.skip + args.limit), + total: filtered.length, + } + } + + const visiblePool = withStats.filter((u) => u.showInLeaderboard) + + const requestingUser = requestingUserId + ? withStats.find((u) => u.id === requestingUserId) + : undefined + const isHiddenRequester = + requestingUser && !requestingUser.showInLeaderboard + + type StatsEntry = (typeof withStats)[0] + type RankedEntry = StatsEntry & { rank: number } + + let workingList: RankedEntry[] + + if (isHiddenRequester && requestingUser) { + const pool = [...visiblePool, requestingUser].sort(sortFn) + workingList = pool.map((item, index) => ({ ...item, rank: index + 1 })) + } else { + const sorted = [...visiblePool].sort(sortFn) + workingList = sorted.map((item, index) => ({ ...item, rank: index + 1 })) + } + + const filtered = applySearch(workingList, args.search) + return { + data: filtered.slice(args.skip, args.skip + args.limit), + total: filtered.length, + } + } + async getUsersCountForAdmin(args: ListPaginationQuerySchema) { return this.prisma.user.count({ where: args.search @@ -120,6 +234,15 @@ export class UserService { }) } + async updateLeaderboardVisibilityById(showInLeaderboard: boolean, id: number) { + return this.prisma.user.update({ + where: { id }, + data: { showInLeaderboard }, + select: { showInLeaderboard: true }, + }) + } + + async getUserProfileById(id: number) { return this.prisma.user.findUnique({ where: { id }, @@ -154,6 +277,9 @@ export class UserService { } async updateUser(userId: number, userData: Prisma.UserUpdateInput) { + if (userData.role === 'admin' && userData.showInLeaderboard === undefined) { + userData.showInLeaderboard = false + } if (typeof userData.password === 'string' && !!userData.password) { const hash = sha256.create() hash.update(userData.password) @@ -170,4 +296,63 @@ export class UserService { select: WITHOUT_PASSWORD, }) } + + async getPassedProblems(userId: number, sortBy: 'id' | 'solvedDate', isAdminUser: boolean) { + const submissions = await this.prisma.submission.findMany({ + where: { + userId, + status: 'accept', + problem: !isAdminUser ? { show: true } : undefined, + }, + select: { + id: true, + creationDate: true, + problem: { + select: { + id: true, + name: true, + sname: true, + score: true, + show: true, + }, + }, + }, + }) + + const problemMap = new Map() + + for (const sub of submissions) { + const p = sub.problem + const existing = problemMap.get(p.id) + if (!existing || sub.creationDate > existing.solvedDate) { + problemMap.set(p.id, { + id: p.id, + name: p.name, + sname: p.sname, + score: p.score, + show: p.show, + solvedDate: sub.creationDate, + submissionId: sub.id, + }) + } + } + + const passedProblems = Array.from(problemMap.values()) + + if (sortBy === 'id') { + passedProblems.sort((a, b) => a.id - b.id) + } else { + passedProblems.sort((a, b) => b.solvedDate.getTime() - a.solvedDate.getTime()) + } + + return passedProblems + } } diff --git a/apps/web/src/pages/leaderboard.tsx b/apps/web/src/pages/leaderboard.tsx new file mode 100644 index 0000000..4497edd --- /dev/null +++ b/apps/web/src/pages/leaderboard.tsx @@ -0,0 +1,228 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useReactTable } from '@tanstack/react-table' +import { createColumnHelper, getCoreRowModel } from '@tanstack/table-core' +import Head from 'next/head' +import NextLink from 'next/link' + +import { UserLeaderboardSchema } from '@otog/contract' +import { Badge } from '@otog/ui/badge' +import { Link } from '@otog/ui/link' +import { Switch } from '@otog/ui/switch' +import { Label } from '@otog/ui/label' +import { clsx } from '@otog/ui/utils' + +import { userKey } from '../api/query' +import { + TableComponent, + TablePagination, + TablePaginationInfo, + TableSearch, +} from '../components/table-component' +import { UserAvatar } from '../components/user-avatar' +import { useUserContext } from '../context/user-context' + +const rankColor: Record = { + 1: 'font-bold text-yellow-500 dark:text-yellow-400', + 2: 'font-bold text-slate-400 dark:text-slate-300', + 3: 'font-bold text-amber-600 dark:text-amber-500', +} + +export default function LeaderboardPage() { + const { isAdmin, user: currentUser } = useUserContext() + const [showHidden, setShowHidden] = useState(false) + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + const [search, setSearch] = useState('') + + const handleShowHiddenChange = (value: boolean) => { + setShowHidden(value) + setPagination((p) => ({ ...p, pageIndex: 0 })) + } + + const getLeaderboard = useQuery({ + ...userKey.getLeaderboard({ + query: { + limit: pagination.pageSize, + skip: pagination.pageIndex * pagination.pageSize, + search: search.trim(), + showAll: isAdmin && showHidden, + }, + }), + placeholderData: keepPreviousData, + }) + + const data = useMemo( + () => + getLeaderboard.data?.status === 200 + ? getLeaderboard.data.body.data + : [], + [getLeaderboard.data] + ) + + const rowCount = getLeaderboard.data?.body.total ?? 0 + + const columns = useMemo(() => { + const columnHelper = createColumnHelper() + return [ + columnHelper.display({ + id: 'rank', + header: '#', + cell: ({ row }) => { + const rank = row.original.rank + return ( + + {rank} + + ) + }, + meta: { + headClassName: 'w-[80px]', + cellClassName: 'w-[80px]', + }, + }), + columnHelper.accessor('showName', { + header: 'ผู้ใช้งาน', + cell: ({ getValue, row }) => { + const isSelf = row.original.id === currentUser?.id + const isHiddenSelf = isSelf && !row.original.showInLeaderboard + return ( +
+ + + + + {getValue()} + + + + {isSelf && ( + + คุณ + + )} + {isAdmin && !row.original.showInLeaderboard && !isSelf && ( + + ซ่อน + + )} + {isAdmin && row.original.role === 'admin' && ( + + Admin + + )} +
+ ) + }, + }), + columnHelper.accessor('rating', { + header: 'เรตติ้ง', + cell: ({ getValue }) => { + const rating = getValue() + if (rating === null || rating === undefined) { + return - + } + return ( + + {rating} + + ) + }, + meta: { + headClassName: 'text-end pr-6', + cellClassName: 'text-end pr-6', + }, + }), + columnHelper.accessor('passedCount', { + header: 'ข้อที่ผ่าน', + cell: ({ getValue, row }) => ( +
+ + + + {getValue()} + + + + {isAdmin && ( + + [{row.original.passedCountAll}] + + )} +
+ ), + meta: { + headClassName: 'text-end pr-4', + cellClassName: 'text-end pr-4', + }, + }), + ] + }, [isAdmin, currentUser]) + + const table = useReactTable({ + columns, + data, + state: { globalFilter: search, pagination }, + onPaginationChange: setPagination, + onGlobalFilterChange: setSearch, + getCoreRowModel: getCoreRowModel(), + enableSorting: false, + manualPagination: true, + manualFiltering: true, + rowCount, + }) + + return ( +
+ + Leaderboard | One Tambon One Grader + +

ตารางอันดับ

+ +
+
+
+ + {isAdmin && ( +
+ + +
+ )} +
+
+ +
+
+ + +
+
+ ) +} diff --git a/apps/web/src/pages/user/[userId].tsx b/apps/web/src/pages/user/[userId].tsx index 86478cb..d8f23e3 100644 --- a/apps/web/src/pages/user/[userId].tsx +++ b/apps/web/src/pages/user/[userId].tsx @@ -1,15 +1,25 @@ -import { useMemo } from 'react' - -import { useInfiniteQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import toast from 'react-hot-toast' +import dayjs from 'dayjs' import { UserProfile } from '@otog/contract' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@otog/ui/tabs' +import { Switch } from '@otog/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@otog/ui/select' +import { Badge } from '@otog/ui/badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@otog/ui/table' +import { Link } from '@otog/ui/link' +import { DialogTrigger } from '@otog/ui/dialog' -import { submissionKey, submissionQuery } from '../../api/query' +import { submissionKey, submissionQuery, userKey, userQuery } from '../../api/query' import { withQuery } from '../../api/server' import { AvatarUpload } from '../../components/avatar-upload' import { SubmissionTable } from '../../components/submission-table' import { UserAvatar } from '../../components/user-avatar' import { useUserContext } from '../../context/user-context' +import { SubmissionDialog } from '../../components/submission-dialog' interface ProfilePageProps { userProfile: UserProfile @@ -39,15 +49,57 @@ export const getServerSideProps = withQuery( ) export default function ProfilePage(props: ProfilePageProps) { + const router = useRouter() + const queryClient = useQueryClient() const { user, isAdmin } = useUserContext() + const [showInLeaderboard, setShowInLeaderboard] = useState( + props.userProfile.showInLeaderboard + ) + const updateVisibility = userQuery.updateLeaderboardVisibility.useMutation() + + const handleToggleVisibility = async (checked: boolean) => { + setShowInLeaderboard(checked) + try { + await updateVisibility.mutateAsync({ + params: { userId: props.userProfile.id.toString() }, + body: { showInLeaderboard: checked }, + }) + toast.success('อัปเดตการแสดงตัวตนสำเร็จ') + queryClient.invalidateQueries({ + queryKey: userKey.getLeaderboard._def, + }) + queryClient.invalidateQueries({ + queryKey: userKey.getUserProfile({ + params: { userId: props.userProfile.id.toString() }, + }).queryKey, + }) + } catch (err) { + console.error(err) + toast.error('เกิดข้อผิดพลาดในการอัปเดต') + setShowInLeaderboard(!checked) + } + } + + const activeTab = (router.query.tab as string) || 'submissions' + const handleTabChange = (val: string) => { + router.push( + { + pathname: router.pathname, + query: { ...router.query, tab: val }, + }, + undefined, + { shallow: true } + ) + } + return (

{props.userProfile.showName}

-
-
+
+
{/* */} + +
+ {props.userProfile.rating !== null && ( +
+ เรตติ้ง: {props.userProfile.rating} +
+ )} + {user?.id === props.userProfile.id && ( +
+
+ +
+ +

+ หากปิด คุณจะไม่ปรากฏในตารางอันดับ +

+
+
+
+ )} +
-

ผลตรวจ

{(props.userProfile.role === 'user' || isAdmin) && ( - + + + + ผลตรวจ + + + ข้อที่ผ่าน + + + + + + + + + )}
@@ -107,3 +210,111 @@ function UserSubmissionTable(props: UserSubmissionTableProps) { /> ) } + +function UserPassedProblems({ userId }: { userId: number }) { + const { user, isAdmin } = useUserContext() + const [sortBy, setSortBy] = useState<'solvedDate' | 'id'>('solvedDate') + + const getPassedProblems = useQuery({ + ...userKey.getPassedProblems({ + params: { userId: userId.toString() }, + query: { sortBy }, + }), + }) + + const problems = useMemo( + () => + getPassedProblems.data?.status === 200 + ? getPassedProblems.data.body + : [], + [getPassedProblems.data] + ) + + return ( +
+
+

+ รายการข้อที่ผ่าน ({problems.length}) +

+
+ เรียงตาม: + +
+
+ +
+ + + + เลขข้อ + ชื่อโจทย์ + คะแนน + วันที่ผ่าน + + + + {getPassedProblems.isLoading ? ( + + + กำลังโหลด... + + + ) : problems.length === 0 ? ( + + + ไม่มีข้อที่ผ่าน + + + ) : ( + problems.map((prob) => ( + + + #{prob.id} + + + + {prob.name} + + + + {user?.id === userId || isAdmin ? ( + + + + {prob.score} / {prob.score} + + + + ) : ( + + {prob.score} / {prob.score} + + )} + + + {dayjs(prob.solvedDate).format('DD/MM/YYYY HH:mm')} + + + )) + )} + +
+
+
+ ) +} diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 33ba80d..257532d 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -105,6 +105,15 @@ export type ListPaginationQuerySchema = z.infer< typeof ListPaginationQuerySchema > +export const LeaderboardQuerySchema = ListPaginationQuerySchema.extend({ + showAll: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .transform((v) => v === true || v === 'true') + .optional() + .default(false), +}) +export type LeaderboardQuerySchema = z.infer + export const ChatMessage = ChatModel.pick({ id: true, creationDate: true, @@ -136,6 +145,7 @@ export const UserSchema = UserModel.pick({ showName: true, role: true, rating: true, + showInLeaderboard: true, }) export type UserSchema = z.infer @@ -361,6 +371,24 @@ export const UserProfile = UserSchema.extend({ }) export type UserProfile = z.infer +export const UserLeaderboardSchema = UserSchema.extend({ + passedCount: z.number(), + passedCountAll: z.number(), + rank: z.number(), +}) +export type UserLeaderboardSchema = z.infer + +export const UserPassedProblemSchema = z.object({ + id: z.number(), + name: z.string(), + sname: z.string().nullable(), + score: z.number(), + show: z.boolean(), + solvedDate: z.coerce.date(), + submissionId: z.number(), +}) +export type UserPassedProblemSchema = z.infer + export const userRouter = contract.router( { getUsersForAdmin: { @@ -375,6 +403,18 @@ export const userRouter = contract.router( }, summary: 'Get paginated users for admin', }, + getLeaderboard: { + method: 'GET', + path: '/leaderboard', + query: LeaderboardQuerySchema, + responses: { + 200: z.object({ + data: z.array(UserLeaderboardSchema), + total: z.number(), + }), + }, + summary: 'Get user leaderboard (ranked by number of passed problems)', + }, getOnlineUsers: { method: 'GET', path: '/online', @@ -416,6 +456,27 @@ export const userRouter = contract.router( body: UserModel.pick({ showName: true }), summary: 'Update user show name', }, + updateLeaderboardVisibility: { + method: 'PATCH', + path: '/:userId/leaderboard-visibility', + responses: { + 200: UserModel.pick({ showInLeaderboard: true }), + 403: z.object({ message: z.string() }), + }, + body: z.object({ showInLeaderboard: z.boolean() }), + summary: 'Update user leaderboard visibility', + }, + getPassedProblems: { + method: 'GET', + path: '/:userId/passed-problems', + query: z.object({ + sortBy: z.enum(['id', 'solvedDate']).optional().default('solvedDate'), + }), + responses: { + 200: z.array(UserPassedProblemSchema), + }, + summary: 'Get user passed problems', + }, }, { pathPrefix: '/user' } ) From a2c512057a86802e5a7a2d66c4b6221ca3542c5a Mon Sep 17 00:00:00 2001 From: c3mzz Date: Tue, 16 Jun 2026 15:12:50 +0700 Subject: [PATCH 2/5] Add show_in_leaderboard, fix bugs --- apps/api/src/modules/auth/auth.service.ts | 1 + apps/api/src/modules/user/dto/user.dto.ts | 3 +++ apps/api/src/modules/user/user.service.ts | 3 +++ apps/api/tsconfig.json | 2 +- apps/web/src/components/navbar.tsx | 8 ++++++ apps/web/src/pages/api/auth/[...nextauth].ts | 4 +-- .../migration.sql | 2 ++ packages/database/prisma/schema.prisma | 27 ++++++++++--------- 8 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 packages/database/prisma/migrations/20260616022404_add_show_in_leaderboard/migration.sql diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index cebb271..8240f36 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -62,6 +62,7 @@ export class AuthService { showName: user.showName, role: user.role, rating: user.rating, + showInLeaderboard: user.showInLeaderboard, } const jwtId = uuidv4() const accessToken = this.jwtService.sign(payload, { diff --git a/apps/api/src/modules/user/dto/user.dto.ts b/apps/api/src/modules/user/dto/user.dto.ts index 84acb67..54f8e45 100644 --- a/apps/api/src/modules/user/dto/user.dto.ts +++ b/apps/api/src/modules/user/dto/user.dto.ts @@ -14,11 +14,14 @@ export class UserDTO { rating: number + showInLeaderboard: boolean + constructor(user: any) { this.id = user?.id this.username = user?.username this.showName = user?.showName this.role = user?.role this.rating = user?.rating + this.showInLeaderboard = user?.showInLeaderboard } } diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index 83b771f..3a2d8c5 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -89,6 +89,9 @@ export class UserService { rating: true, showInLeaderboard: true, submission: { + where: { + status: 'accept', + }, select: { status: true, problemId: true, diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 06e86a3..369f90e 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -5,4 +5,4 @@ }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules", "dist"] -} +} \ No newline at end of file diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index a0f23ba..5a31a7b 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -87,6 +87,9 @@ export const Navbar = () => {
  • แข่งขัน
  • +
  • + จัดอันดับ +
  • )} @@ -130,6 +133,11 @@ export const Navbar = () => { แข่งขัน +
  • + + จัดอันดับ + +
  • )}
    diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 9b1fcbe..2d3a4a2 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -140,8 +140,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { export type AccessTokenPayload = UserSchema export function getUserData(accessToken: string): AccessTokenPayload { - const { id, username, showName, role, rating } = jwtDecode< + const { id, username, showName, role, rating, showInLeaderboard } = jwtDecode< AccessTokenPayload & JwtPayload >(accessToken) - return { id, username, showName, role, rating } + return { id, username, showName, role, rating, showInLeaderboard } } diff --git a/packages/database/prisma/migrations/20260616022404_add_show_in_leaderboard/migration.sql b/packages/database/prisma/migrations/20260616022404_add_show_in_leaderboard/migration.sql new file mode 100644 index 0000000..13e472b --- /dev/null +++ b/packages/database/prisma/migrations/20260616022404_add_show_in_leaderboard/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "showInLeaderboard" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3ce15d3..dc5cea7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -158,19 +158,20 @@ model Verdict { } model User { - id Int @id @default(autoincrement()) - username String @unique @db.VarChar(255) - showName String @unique @db.VarChar(255) - email String? @unique @db.VarChar(255) - password String @db.VarChar(255) - role UserRole - rating Int? - creationDate DateTime @default(now()) @db.Timestamptz(6) - updateDate DateTime @default(now()) @updatedAt @db.Timestamptz(6) - chat Chat[] - submission Submission[] - userContest UserContest[] - contestScore ContestScore[] + id Int @id @default(autoincrement()) + username String @unique @db.VarChar(255) + showName String @unique @db.VarChar(255) + email String? @unique @db.VarChar(255) + password String @db.VarChar(255) + role UserRole + rating Int? + showInLeaderboard Boolean @default(true) + creationDate DateTime @default(now()) @db.Timestamptz(6) + updateDate DateTime @default(now()) @updatedAt @db.Timestamptz(6) + chat Chat[] + submission Submission[] + userContest UserContest[] + contestScore ContestScore[] @@map("user") } From 1c64315187be2ff5679952c5e8fb4f938c9321f6 Mon Sep 17 00:00:00 2001 From: c3mzz Date: Tue, 16 Jun 2026 15:40:57 +0700 Subject: [PATCH 3/5] Add mock database --- packages/database/src/seed.ts | 106 +++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index c91fe50..a79ce3c 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -1 +1,105 @@ -// TODO: seed mock data +/* +import { PrismaClient } from '../__generated__/prisma-client' + +const prisma = new PrismaClient() + +async function main() { + console.log('Cleaning existing database data...') + await prisma.submission.deleteMany() + await prisma.problem.deleteMany() + await prisma.user.deleteMany() + + console.log('Seeding mock database...') + + // Create admin user + // Hashed password for 'admin' is '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918' + const admin = await prisma.user.create({ + data: { + username: 'admin', + showName: 'Admin', + password: '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918', + role: 'admin', + showInLeaderboard: false, // Hidden by default + }, + }) + console.log('Admin user seeded.') + + // Create 10 normal users + // Hashed password for 'password' is '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8' + const users = [] + for (let i = 1; i <= 10; i++) { + const user = await prisma.user.create({ + data: { + username: `user${i}`, + showName: `User ${i}`, + password: '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8', + role: 'user', + showInLeaderboard: true, + rating: 0, + }, + }) + users.push(user) + } + console.log('10 Normal users seeded.') + + // Create 10 mock problems + const problems = [] + for (let i = 1; i <= 10; i++) { + const sname = `mock${i}` + const problem = await prisma.problem.create({ + data: { + name: `Mock Problem ${i}`, + sname, + score: 100, + timeLimit: 1000, + memoryLimit: 32, + show: true, + }, + }) + problems.push(problem) + } + console.log('10 Mock problems seeded.') + + // Create mock submissions to populate the leaderboard + console.log('Seeding mock submissions for leaderboard...') + for (let i = 0; i < users.length; i++) { + const user = users[i] + // User 1 solves 1 problem, User 2 solves 2 problems, ..., User 10 solves 10 problems + const solvedCount = i + 1 + + for (let j = 0; j < solvedCount; j++) { + const problem = problems[j] + await prisma.submission.create({ + data: { + userId: user.id, + problemId: problem.id, + status: 'accept', + language: 'cpp', + public: true, + }, + }) + } + } + console.log('Mock submissions seeded.') +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) + +// pnpm --filter @otog/database reset +// username : 'user1', password : 'password' +// username : 'user2', password : 'password' +// username : 'user3', password : 'password' +// username : 'user4', password : 'password' +// ... + +// username : 'admin', password : 'admin' + +*/ \ No newline at end of file From d365f602c32900eb772eada3dc21a7fd0a1e83fa Mon Sep 17 00:00:00 2001 From: c3mzz Date: Wed, 17 Jun 2026 19:35:14 +0700 Subject: [PATCH 4/5] Optimize leaderboard query --- apps/api/src/modules/user/user.service.ts | 73 ++++++++--------------- 1 file changed, 26 insertions(+), 47 deletions(-) diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index 3a2d8c5..e9c0d44 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -80,53 +80,32 @@ export class UserService { requestingUserId: number | undefined, isAdminUser: boolean, ) { - const allUsers = await this.prisma.user.findMany({ - select: { - id: true, - username: true, - showName: true, - role: true, - rating: true, - showInLeaderboard: true, - submission: { - where: { - status: 'accept', - }, - select: { - status: true, - problemId: true, - problem: { - select: { - show: true, - }, - }, - }, - }, - }, - }) - - const withStats = allUsers.map((user) => { - const passedPublic = new Set() - const passedAll = new Set() - - for (const sub of user.submission) { - if (!sub.problem) continue - if (sub.status !== 'accept') continue - if (sub.problem.show) passedPublic.add(sub.problemId) - passedAll.add(sub.problemId) - } - - return { - id: user.id, - username: user.username, - showName: user.showName, - role: user.role, - rating: user.rating, - showInLeaderboard: user.showInLeaderboard, - passedCount: passedPublic.size, - passedCountAll: passedAll.size, - } - }) + const withStats = await this.prisma.$queryRaw< + Array<{ + id: number + username: string + showName: string + role: string + rating: number | null + showInLeaderboard: boolean + passedCount: number + passedCountAll: number + }> + >` + SELECT + u.id, + u.username, + u."showName", + u.role, + u.rating, + u."showInLeaderboard", + CAST(COUNT(DISTINCT CASE WHEN p.show = true THEN s."problemId" END) AS INTEGER) as "passedCount", + CAST(COUNT(DISTINCT s."problemId") AS INTEGER) as "passedCountAll" + FROM "user" u + LEFT JOIN "submission" s ON u.id = s."userId" AND s.status = 'accept' + LEFT JOIN "problem" p ON s."problemId" = p.id + GROUP BY u.id + ` const sortFn = ( a: (typeof withStats)[0], From 9f36dd2de730a55968e826350498c1983b7aa307 Mon Sep 17 00:00:00 2001 From: c3mzz Date: Wed, 17 Jun 2026 19:51:34 +0700 Subject: [PATCH 5/5] Fix typo --- apps/api/src/modules/user/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index e9c0d44..d715e28 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -85,7 +85,7 @@ export class UserService { id: number username: string showName: string - role: string + role: 'user' | 'admin' rating: number | null showInLeaderboard: boolean passedCount: number