Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/api/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/modules/user/dto/user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
38 changes: 38 additions & 0 deletions apps/api/src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 }
})
}
}
169 changes: 168 additions & 1 deletion apps/api/src/modules/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -18,6 +18,7 @@ export const WITHOUT_PASSWORD = {
showName: true,
role: true,
rating: true,
showInLeaderboard: true,
} as const

@Injectable()
Expand Down Expand Up @@ -69,10 +70,105 @@ export class UserService {
showName: true,
role: true,
rating: true,
showInLeaderboard: true,
},
orderBy: { id: 'desc' },
})
}
async getLeaderboard(
args: LeaderboardQuerySchema,
requestingUserId: number | undefined,
isAdminUser: boolean,
) {
const withStats = await this.prisma.$queryRaw<
Array<{
id: number
username: string
showName: string
role: 'user' | 'admin'
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],
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 = <T extends { showName: string; username: string }>(
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
Expand Down Expand Up @@ -120,6 +216,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 },
Expand Down Expand Up @@ -154,6 +259,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)
Expand All @@ -170,4 +278,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<number, {
id: number
name: string
sname: string | null
score: number
show: boolean
solvedDate: Date
submissionId: number
}>()

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
}
}
2 changes: 1 addition & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
}
8 changes: 8 additions & 0 deletions apps/web/src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export const Navbar = () => {
<li>
<NavButton href="/contest">แข่งขัน</NavButton>
</li>
<li>
<NavButton href="/leaderboard">จัดอันดับ</NavButton>
</li>
</ul>
)}
</div>
Expand Down Expand Up @@ -130,6 +133,11 @@ export const Navbar = () => {
แข่งขัน
</NavButton>
</li>
<li>
<NavButton className="block" href="/leaderboard">
จัดอันดับ
</NavButton>
</li>
</ul>
)}
<div className="flex gap-2">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Loading