diff --git a/.prettierrc.cjs b/.prettierrc.cjs index 4f6f312..f391e8e 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -1,7 +1,13 @@ module.exports = { printWidth: 100, singleQuote: true, - importOrder: ['^ace-builds/(.*)$', '^@prisma/(.*)$', '^@core/(.*)$', '^[./]'], + importOrder: [ + '^ace-builds/(.*)$', + '^@prisma/(.*)$', + '^@tun-judge/(.*)$', + '^@core/(.*)$', + '^[./]', + ], importOrderSeparation: true, importOrderSortSpecifiers: true, importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'], diff --git a/apps/client/src/core/components/ProblemSet.tsx b/apps/client/src/core/components/ProblemSet.tsx index cb16d30..affacb3 100644 --- a/apps/client/src/core/components/ProblemSet.tsx +++ b/apps/client/src/core/components/ProblemSet.tsx @@ -16,10 +16,10 @@ import { Prisma, ScoreCache, Submission } from '@prisma/client'; import { useActiveContest, useAuthContext } from '@core/contexts'; import { contestStartedAndNotOver, dateComparator, formatBytes } from '@core/utils'; -import Balloon from '../../../public/assets/balloon.svg?react'; import { NoActiveContest } from './NoActiveContest'; import { PageTemplate } from './PageTemplate'; import { SubmitForm } from './SubmitForm'; +import Balloon from './balloon.svg?react'; type ContestProblem = Prisma.ContestProblemGetPayload<{ include: { problem: true }; diff --git a/apps/client/src/core/components/Scoreboard.tsx b/apps/client/src/core/components/Scoreboard.tsx index ee61cfe..088b59e 100644 --- a/apps/client/src/core/components/Scoreboard.tsx +++ b/apps/client/src/core/components/Scoreboard.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom'; import { Flex, Tooltip, cn } from 'tw-react-components'; import { useActiveContest, useAuthContext } from '@core/contexts'; +import { useOnWebSocketEvent } from '@core/hooks'; import { formatRestTime, getRGBColorContrast, request } from '@core/utils'; import { NoActiveContest } from './NoActiveContest'; @@ -34,7 +35,7 @@ export const Scoreboard: FC = ({ className, compact }) => { const { profile, isUserJury } = useAuthContext(); const { id } = useParams<{ id: string }>(); - const { currentContest, activeContests } = useActiveContest(); + const { currentContest, activeContests, refreshContests } = useActiveContest(); const contest = useMemo( () => (id ? activeContests.find((c) => c.id === +id) : currentContest), @@ -43,6 +44,8 @@ export const Scoreboard: FC = ({ className, compact }) => { const [standing, setStanding] = useState([]); + useOnWebSocketEvent('scoreboard', refreshContests); + useEffect(() => { if (contest?.scoreCaches) { const cache: { [teamId: string]: TeamStandingRow } = {}; diff --git a/apps/client/src/core/components/SubmitForm.tsx b/apps/client/src/core/components/SubmitForm.tsx index 32c0ab5..827c01a 100644 --- a/apps/client/src/core/components/SubmitForm.tsx +++ b/apps/client/src/core/components/SubmitForm.tsx @@ -5,7 +5,7 @@ import { FormDialog, FormInputs } from 'tw-react-components'; import { FileKind, Submission } from '@prisma/client'; import { useActiveContest, useAuthContext } from '@core/contexts'; -import { useCreateSubmission, useFindManyLanguage } from '@core/queries'; +import { useCountSubmission, useCreateSubmission, useFindManyLanguage } from '@core/queries'; import { uploadFile } from '@core/utils'; type Props = { @@ -21,6 +21,17 @@ export const SubmitForm: FC = ({ submission, onClose }) => { const form = useForm({ defaultValues: structuredClone(submission) }); + const { data: submissionsCount } = useCountSubmission( + { + where: { + contestId: currentContest?.id, + teamId: profile?.teamId ?? undefined, + problemId: form.watch('problemId'), + }, + }, + { enabled: !!form.watch('problemId') }, + ); + const { data: languages = [] } = useFindManyLanguage(); const { mutateAsync } = useCreateSubmission(); @@ -33,7 +44,7 @@ export const SubmitForm: FC = ({ submission, onClose }) => { if (sourceFile) { const source = await uploadFile(sourceFile, { - name: `Submissions/${profile.team.name}/${sourceFile.name}`, + name: `Submissions/${profile.team.name}/p-${submission.problemId}-n-${submissionsCount}-${sourceFile.name}`, type: sourceFile.type, size: sourceFile.size, md5Sum: '', diff --git a/apps/client/public/assets/balloon.svg b/apps/client/src/core/components/balloon.svg similarity index 100% rename from apps/client/public/assets/balloon.svg rename to apps/client/src/core/components/balloon.svg diff --git a/apps/client/src/core/contexts/active-contest/context.tsx b/apps/client/src/core/contexts/active-contest/context.tsx index cb1d416..1568e21 100644 --- a/apps/client/src/core/contexts/active-contest/context.tsx +++ b/apps/client/src/core/contexts/active-contest/context.tsx @@ -15,6 +15,7 @@ export type ActiveContest = { activeContests: Contest[]; currentContest?: Contest; setCurrentContest: (contest?: Contest) => void; + refreshContests: () => void; }; export const ActiveContestContext = createContext(undefined); @@ -23,7 +24,7 @@ export const ActiveContestProvider: FC = ({ children }) => { const [now, setNow] = useState(new Date()); const [currentContest, setCurrentContest] = useState(); - const { data: contests = [] } = useFindManyContest({ + const { data: contests = [], refetch } = useFindManyContest({ where: { enabled: true, activateTime: { lte: now }, @@ -51,7 +52,12 @@ export const ActiveContestProvider: FC = ({ children }) => { return ( {children} diff --git a/apps/client/src/core/hooks/index.ts b/apps/client/src/core/hooks/index.ts index 0c54dc6..799a720 100644 --- a/apps/client/src/core/hooks/index.ts +++ b/apps/client/src/core/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useDownloadedFile'; +export * from './useOnWebSocketEvent'; export * from './usePagination'; export * from './useSorting'; export * from './useTimeLeftToContest'; diff --git a/apps/client/src/core/hooks/useOnWebSocketEvent.ts b/apps/client/src/core/hooks/useOnWebSocketEvent.ts new file mode 100644 index 0000000..a2f73f4 --- /dev/null +++ b/apps/client/src/core/hooks/useOnWebSocketEvent.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +import { WebSocketEvent } from '@tun-judge/shared'; + +import { useWebSocketContext } from '@core/contexts'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useOnWebSocketEvent any>( + event: WebSocketEvent, + callback: T, +) { + const { socket } = useWebSocketContext(); + + useEffect(() => { + if (!socket) return; + + socket.on(event, callback); + + return () => { + socket.off(event, callback); + }; + }, [socket, event, callback]); +} diff --git a/apps/client/src/pages/admin/views/clarifications/ClarificationsSidebar.tsx b/apps/client/src/pages/admin/views/clarifications/ClarificationsSidebar.tsx index 96cb2e0..6885374 100644 --- a/apps/client/src/pages/admin/views/clarifications/ClarificationsSidebar.tsx +++ b/apps/client/src/pages/admin/views/clarifications/ClarificationsSidebar.tsx @@ -3,6 +3,7 @@ import { FC } from 'react'; import { Link, useParams } from 'react-router-dom'; import { Button, Sidebar } from 'tw-react-components'; +import { useOnWebSocketEvent } from '@core/hooks'; import { useFindFirstContest, useFindManyClarification } from '@core/queries'; import { ClarificationGroupTab } from './ClarificationGroupTab'; @@ -15,7 +16,7 @@ export const ClarificationsSidebar: FC = () => { include: { problems: { include: { problem: true } } }, }); - const { data: clarifications = [] } = useFindManyClarification( + const { data: clarifications = [], refetch } = useFindManyClarification( { where: { contestId: parseInt(contestId ?? '-1') }, include: { @@ -27,6 +28,8 @@ export const ClarificationsSidebar: FC = () => { { enabled: !!contestId }, ); + useOnWebSocketEvent('clarifications', refetch); + return ( diff --git a/apps/client/src/pages/admin/views/judge-hosts/JudgeHostLogsViewer.tsx b/apps/client/src/pages/admin/views/judge-hosts/JudgeHostLogsViewer.tsx index f4861b5..0f0b424 100644 --- a/apps/client/src/pages/admin/views/judge-hosts/JudgeHostLogsViewer.tsx +++ b/apps/client/src/pages/admin/views/judge-hosts/JudgeHostLogsViewer.tsx @@ -1,8 +1,8 @@ import Ansi from 'ansi-to-react'; -import { FC, useEffect, useState } from 'react'; -import { Sheet } from 'tw-react-components'; +import { FC, useCallback, useState } from 'react'; +import { Button, Flex, Sheet } from 'tw-react-components'; -import { useWebSocketContext } from '@core/contexts'; +import { useOnWebSocketEvent } from '@core/hooks'; type JudgeHostLogsViewerProps = { hostname?: string; @@ -10,25 +10,15 @@ type JudgeHostLogsViewerProps = { }; export const JudgeHostLogsViewer: FC = ({ hostname, onClose }) => { - const { socket } = useWebSocketContext(); - const [logs, setLogs] = useState([]); - useEffect(() => { - if (!hostname) return; - - const event = `judgeHost-${hostname}-logs`; - - socket.on(event, (logLine: string) => { - setLogs((logs) => [...logs, logLine]); - const terminalSegment = document.getElementById('terminal-logs'); - terminalSegment && (terminalSegment.scrollTop = terminalSegment.scrollHeight); - }); + const updateLogs = useCallback((logLine: string) => { + setLogs((logs) => [...logs, logLine]); + const terminalSegment = document.getElementById('terminal-logs'); + terminalSegment && (terminalSegment.scrollTop = terminalSegment.scrollHeight); + }, []); - return () => { - socket.off(event); - }; - }, [hostname, socket]); + useOnWebSocketEvent(`judgeHost-${hostname}-logs`, updateLogs); return ( !value && onClose()}> @@ -36,14 +26,24 @@ export const JudgeHostLogsViewer: FC = ({ hostname, on Judge Host '{hostname}' logs -
+ {logs.map((log, index) => ( - - {log} -
-
+ {log} ))} -
+ + + + + +
); diff --git a/apps/client/src/pages/admin/views/submissions/SubmissionView.tsx b/apps/client/src/pages/admin/views/submissions/SubmissionView.tsx index 64702dc..5810346 100644 --- a/apps/client/src/pages/admin/views/submissions/SubmissionView.tsx +++ b/apps/client/src/pages/admin/views/submissions/SubmissionView.tsx @@ -17,8 +17,9 @@ import { Prisma, User } from '@prisma/client'; import { CodeEditor, PageTemplate } from '@core/components'; import { LANGUAGES_MAP } from '@core/constants'; import { useAuthContext } from '@core/contexts'; -import { useDownloadedFile } from '@core/hooks'; +import { useDownloadedFile, useOnWebSocketEvent } from '@core/hooks'; import { useFindFirstSubmission, useUpdateJudging, useUpdateSubmission } from '@core/queries'; +import { request } from '@core/utils'; import { SubmissionViewDetails } from './SubmissionViewDetails'; import { SubmissionsViewJudgingRuns } from './SubmissionViewJudgingRuns'; @@ -47,7 +48,7 @@ export type Testcase = JudgingRun['testcase']; export const SubmissionsView: FC = () => { const { contestId } = useParams(); - const { profile } = useAuthContext(); + const { profile, isUserAdmin } = useAuthContext(); const navigate = useNavigate(); const { submissionId } = useParams(); @@ -76,6 +77,8 @@ export const SubmissionsView: FC = () => { const { mutateAsync: updateJudging } = useUpdateJudging(); const { mutateAsync: updateSubmission } = useUpdateSubmission(); + useOnWebSocketEvent('judgings', refetch); + const content = useDownloadedFile(submission?.sourceFileName); const latestJudging = useMemo(() => { const validJudgings = submission?.judgings.filter((j) => j.valid); @@ -87,20 +90,23 @@ export const SubmissionsView: FC = () => { ); }, [submission?.judgings]); - const toggleSubmissionValid = () => { + const toggleSubmissionValid = async () => { if (!submission) return; - updateSubmission({ where: { id: submission.id }, data: { valid: !submission.valid } }); + await updateSubmission({ where: { id: submission.id }, data: { valid: !submission.valid } }); + await request('api/scoreboard/refresh-score-cache', 'PATCH'); }; const rejudge = async () => { if (!submission) return; await updateSubmission({ where: { id: submission.id }, data: { judgeHostId: null } }); + await request('api/scoreboard/refresh-score-cache', 'PATCH'); }; - const markVerified = (judgingId: number) => { - updateJudging({ where: { id: judgingId }, data: { verified: true } }); + const markVerified = async (judgingId: number) => { + await updateJudging({ where: { id: judgingId }, data: { verified: true } }); + await request('api/scoreboard/refresh-score-cache', 'PATCH'); navigate(`/contests/${contestId}/submissions`); }; @@ -137,7 +143,7 @@ export const SubmissionsView: FC = () => { title={`Submission ${submission.id}`} actions={ <> - {isSubmissionClaimedByMe(latestJudging, profile) && ( + {(isSubmissionClaimedByMe(latestJudging, profile) || isUserAdmin) && ( diff --git a/apps/client/src/pages/admin/views/submissions/SubmissionsList.tsx b/apps/client/src/pages/admin/views/submissions/SubmissionsList.tsx index 774229d..b0481da 100644 --- a/apps/client/src/pages/admin/views/submissions/SubmissionsList.tsx +++ b/apps/client/src/pages/admin/views/submissions/SubmissionsList.tsx @@ -7,7 +7,7 @@ import { Prisma, Testcase } from '@prisma/client'; import { SubmissionResult } from '@core/components'; import { useAuthContext } from '@core/contexts'; -import { usePagination } from '@core/hooks'; +import { useOnWebSocketEvent, usePagination } from '@core/hooks'; import { useCountSubmission, useFindFirstContest, @@ -36,7 +36,11 @@ export const SubmissionsList: FC = () => { const { data: totalItems = 0 } = useCountSubmission({ where: { contestId: parseInt(contestId ?? '-1') }, }); - const { data: submissions = [], isLoading } = useFindManySubmission({ + const { + data: submissions = [], + isLoading, + refetch, + } = useFindManySubmission({ where: { contestId: parseInt(contestId ?? '-1') }, include: { team: true, @@ -54,6 +58,10 @@ export const SubmissionsList: FC = () => { }); const { mutateAsync: updateJudging } = useUpdateJudging(); + useOnWebSocketEvent('judgings', refetch); + useOnWebSocketEvent('judgingRuns', refetch); + useOnWebSocketEvent('submissions', refetch); + const claimSubmission = async (judgingId: number) => { if (!profile) return; diff --git a/apps/client/src/pages/team/views/ClarificationsList.tsx b/apps/client/src/pages/team/views/ClarificationsList.tsx index 3652dad..2253c33 100644 --- a/apps/client/src/pages/team/views/ClarificationsList.tsx +++ b/apps/client/src/pages/team/views/ClarificationsList.tsx @@ -4,6 +4,7 @@ import { Button, DataTable, DataTableColumn, Flex } from 'tw-react-components'; import { ChatBoxDialog, Clarification, PageTemplate } from '@core/components'; import { useActiveContest, useAuthContext } from '@core/contexts'; +import { useOnWebSocketEvent } from '@core/hooks'; import { useFindManyClarification } from '@core/queries'; import { countUnseenMessages } from '@core/utils'; @@ -13,7 +14,7 @@ export const ClarificationsList: FC<{ className?: string }> = ({ className }) => const [clarificationId, setClarificationId] = useState(); - const { data: clarifications = [] } = useFindManyClarification( + const { data: clarifications = [], refetch } = useFindManyClarification( { include: { team: true, @@ -25,6 +26,8 @@ export const ClarificationsList: FC<{ className?: string }> = ({ className }) => { refetchInterval: import.meta.env.MODE !== 'development' && 5000 }, ); + useOnWebSocketEvent('clarifications', refetch); + const columns: DataTableColumn[] = [ { header: '#', diff --git a/apps/client/src/pages/team/views/SubmissionsList.tsx b/apps/client/src/pages/team/views/SubmissionsList.tsx index df79233..589789a 100644 --- a/apps/client/src/pages/team/views/SubmissionsList.tsx +++ b/apps/client/src/pages/team/views/SubmissionsList.tsx @@ -5,6 +5,7 @@ import { Judging, Prisma } from '@prisma/client'; import { PageTemplate, SubmissionResult } from '@core/components'; import { useActiveContest, useAuthContext } from '@core/contexts'; +import { useOnWebSocketEvent } from '@core/hooks'; import { useFindManySubmission } from '@core/queries'; import { dateComparator, formatRestTime, isContestRunning } from '@core/utils'; @@ -16,7 +17,11 @@ export const SubmissionsList: FC<{ className?: string }> = ({ className }) => { const { profile } = useAuthContext(); const { currentContest } = useActiveContest(); - const { data: submissions, isLoading } = useFindManySubmission( + const { + data: submissions, + isLoading, + refetch, + } = useFindManySubmission( { where: { contestId: currentContest?.id, teamId: profile?.teamId ?? undefined }, include: { problem: true, language: true, judgings: true }, @@ -24,6 +29,9 @@ export const SubmissionsList: FC<{ className?: string }> = ({ className }) => { { enabled: isContestRunning(currentContest) }, ); + useOnWebSocketEvent('scoreboard', refetch); + useOnWebSocketEvent('submissions', refetch); + const columns: DataTableColumn[] = [ { header: '#', diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json index 51b46c1..6aa2031 100644 --- a/apps/client/tsconfig.json +++ b/apps/client/tsconfig.json @@ -12,7 +12,8 @@ "@core/contexts": ["apps/client/src/core/contexts/index.ts"], "@core/hooks": ["apps/client/src/core/hooks/index.ts"], "@core/utils": ["apps/client/src/core/utils/index.ts"], - "@core/queries": ["apps/client/src/core/queries/index.ts"] + "@core/queries": ["apps/client/src/core/queries/index.ts"], + "@tun-judge/shared": ["libs/shared/src/index.ts"] } }, "files": [], diff --git a/apps/client/vite.config.mts b/apps/client/vite.config.mts index 7df0a56..9710d1f 100644 --- a/apps/client/vite.config.mts +++ b/apps/client/vite.config.mts @@ -38,6 +38,7 @@ export default defineConfig({ '@core/hooks': '/src/core/hooks', '@core/utils': '/src/core/utils', '@core/queries': '/src/core/queries', + '@tun-judge/shared': 'libs/shared/src', }, }, diff --git a/apps/judge/src/http/http.client.ts b/apps/judge/src/http/http.client.ts index 5cace1a..48770e5 100644 --- a/apps/judge/src/http/http.client.ts +++ b/apps/judge/src/http/http.client.ts @@ -22,6 +22,10 @@ export class HttpClient { return this.request(path, 'PUT', { body: JSON.stringify(body), ...options }); } + patch(path: string, body?: unknown, options?: RequestInit): Promise { + return this.request(path, 'PATCH', { body: JSON.stringify(body), ...options }); + } + stream(path: string, options?: RequestInit): Promise> { return this.request>(path, 'GET', options, true); } diff --git a/apps/judge/src/judging-steps/compiler.ts b/apps/judge/src/judging-steps/compiler.ts index 19ed125..2323925 100644 --- a/apps/judge/src/judging-steps/compiler.ts +++ b/apps/judge/src/judging-steps/compiler.ts @@ -154,20 +154,23 @@ export class Compiler { } private async setJudgingCompileOutput(judging: Judging, result: ExecResult): Promise { + const parentDirectoryName = `Submissions/${judging.submission.team.name}/${judging.submission.id}/Judgings/${judging.id}`; + const blob = new Blob([result.stdout.trim()], { type: 'text/plain' }); const file = new File([blob], 'compile.out', { type: 'text/plain' }); const compileOutput = await uploadFile(file, { - name: `Submissions/${judging.submission.id}/Judgings/${judging.id}/${file.name}`, + name: `${parentDirectoryName}/${file.name}`, type: file.type, size: file.size, md5Sum: '', kind: FileKind.FILE, - parentDirectoryName: `Submissions/${judging.submission.id}/Judgings/${judging.id}`, + parentDirectoryName, }); await prisma.judging.update({ where: { id: judging.id }, data: { compileOutputFileName: compileOutput.name }, + include: { submission: true }, }); } } diff --git a/apps/judge/src/judging-steps/executor.ts b/apps/judge/src/judging-steps/executor.ts index b799641..a9521ac 100644 --- a/apps/judge/src/judging-steps/executor.ts +++ b/apps/judge/src/judging-steps/executor.ts @@ -139,7 +139,7 @@ export class Executor { judgingId: judging.id, endTime: new Date(), }; - const parentDirectoryName = `Submissions/${judging.submission.id}/Judgings/${judging.id}/Runs/${testcase.rank}`; + const parentDirectoryName = `Submissions/${judging.submission.team.name}/${judging.submission.id}/Judgings/${judging.id}/Runs/${testcase.rank}`; const runOutputPath = this.submissionHelper.extraFilesPath('test.out'); const runOutput = (await fs.readFile(runOutputPath)).toString().trim(); diff --git a/apps/judge/src/main.ts b/apps/judge/src/main.ts index 63e065d..4927f20 100644 --- a/apps/judge/src/main.ts +++ b/apps/judge/src/main.ts @@ -9,7 +9,7 @@ import { JudgeLogger } from './logger'; async function bootstrap() { const logger = new JudgeLogger('main'); try { - console.log('Connecting to TunJudge...', config); + logger.log('Connecting to TunJudge...'); await http.post(`api/auth/login`, { username: config.username, diff --git a/apps/judge/src/models.ts b/apps/judge/src/models.ts index 2671333..ef40891 100644 --- a/apps/judge/src/models.ts +++ b/apps/judge/src/models.ts @@ -4,6 +4,7 @@ export type Judging = Prisma.JudgingGetPayload<{ include: { submission: { include: { + team: true; problem: { include: { problem: { diff --git a/apps/judge/src/services/judging.service.ts b/apps/judge/src/services/judging.service.ts index c32f229..52b3e58 100644 --- a/apps/judge/src/services/judging.service.ts +++ b/apps/judge/src/services/judging.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, forwardRef } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { SubmissionHelper, fixError } from '../helpers'; +import http from '../http/http.client'; import { Compiler, Executor, Initializer } from '../judging-steps'; import { JudgeLogger, getOnLog } from '../logger'; import { Judging } from '../models'; @@ -57,6 +58,7 @@ export class JudgingService { await this.initializer.run(judging); const compilationSucceeded = await this.compiler.run(judging); compilationSucceeded && (await this.executor.run(judging)); + await http.patch('api/scoreboard/refresh-score-cache'); } catch (error: unknown) { const newError = fixError(error); this.logger.error(newError.message, newError.stack); diff --git a/apps/judge/src/services/system.service.ts b/apps/judge/src/services/system.service.ts index d174670..0990adc 100644 --- a/apps/judge/src/services/system.service.ts +++ b/apps/judge/src/services/system.service.ts @@ -9,7 +9,7 @@ import { prisma } from './prisma.service'; @Injectable() export class SystemService { async getNextJudging(): Promise { - const judgeHost = await prisma.judgeHost.findFirst({ where: { hostname: config.hostname } }); + const judgeHost = await this.getOrCreateJudgeHost(config.hostname); if (!judgeHost?.active) return; @@ -40,6 +40,7 @@ export class SystemService { language: { allowJudge: true }, }, include: { + team: true, problem: { include: { problem: { @@ -69,6 +70,15 @@ export class SystemService { }); } + async getOrCreateJudgeHost(hostname: string) { + return ( + (await prisma.judgeHost.findFirst({ where: { hostname: config.hostname } })) ?? + prisma.judgeHost.create({ + data: { hostname, user: { connect: { username: config.username } } }, + }) + ); + } + async getOrCreateSubmissionJudging(submission: Submission, judgeHost: JudgeHost) { return ( (await prisma.judging.findFirst({ @@ -84,6 +94,7 @@ export class SystemService { judgeHostId: judgeHost.id, submissionId: submission.id, }, + include: { submission: true }, }) ); } @@ -96,6 +107,7 @@ export class SystemService { result: result, systemError: errorMessage, }, + include: { submission: true }, }); } diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 99819f6..91ec885 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -18,6 +18,7 @@ import { RolesGuard } from './guards'; import { InitializersModule } from './initializers'; import { RequestLoggerMiddleware } from './logger'; import { ScoreboardModule } from './scoreboard'; +import { WebsocketGateway } from './websocket/websocket.gateway'; import { WebsocketModule } from './websocket/websocket.module'; @Module({ @@ -36,14 +37,16 @@ import { WebsocketModule } from './websocket/websocket.module'; }), ZenStackModule.registerAsync({ global: true, - useFactory: (prisma: PrismaService, cls: ClsService) => ({ - getEnhancedPrisma: () => enhance(prisma, { user: cls.get('auth') }), - }), - inject: [PrismaService, ClsService], + useFactory: (prisma: PrismaService, cls: ClsService, socketService: WebsocketGateway) => { + const spiedPrisma = socketService.spyOn(prisma); + + return { getEnhancedPrisma: () => enhance(spiedPrisma, { user: cls.get('auth') }) }; + }, + inject: [PrismaService, ClsService, WebsocketGateway], extraProviders: [PrismaService], }), DatabaseModule, - WebsocketModule, + WebsocketModule.forRoot(), InitializersModule, AuthModule, FilesModule, diff --git a/apps/server/src/app.service.ts b/apps/server/src/app.service.ts index 9202f5b..2244858 100644 --- a/apps/server/src/app.service.ts +++ b/apps/server/src/app.service.ts @@ -8,7 +8,7 @@ import { LogClass } from './logger'; @LogClass @Injectable() export class AppService { - constructor(initializer: MainInitializer) { - initializer._run(new PrismaClient()); + constructor(private readonly initializer: MainInitializer) { + this.initializer._run(new PrismaClient()); } } diff --git a/apps/server/src/scoreboard/scoreboard.controller.ts b/apps/server/src/scoreboard/scoreboard.controller.ts index 92987bb..5558139 100644 --- a/apps/server/src/scoreboard/scoreboard.controller.ts +++ b/apps/server/src/scoreboard/scoreboard.controller.ts @@ -11,7 +11,7 @@ export class ScoreboardController { constructor(private readonly scoreboardService: ScoreboardService) {} @Patch('refresh-score-cache') - @Roles('jury', 'admin') + @Roles('admin', 'jury', 'judge-host') refreshScoreCache() { return this.scoreboardService.refreshScores(); } diff --git a/apps/server/src/scoreboard/scoreboard.module.ts b/apps/server/src/scoreboard/scoreboard.module.ts index 39d69f2..93e40d2 100644 --- a/apps/server/src/scoreboard/scoreboard.module.ts +++ b/apps/server/src/scoreboard/scoreboard.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { WebsocketModule } from '../websocket/websocket.module'; import { ScoreboardController } from './scoreboard.controller'; import { ScoreboardService } from './scoreboard.service'; @Module({ + imports: [WebsocketModule], controllers: [ScoreboardController], providers: [ScoreboardService], exports: [ScoreboardService], diff --git a/apps/server/src/scoreboard/scoreboard.service.ts b/apps/server/src/scoreboard/scoreboard.service.ts index 918d5dc..89eb844 100644 --- a/apps/server/src/scoreboard/scoreboard.service.ts +++ b/apps/server/src/scoreboard/scoreboard.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; -import { Judging, JudgingResult, Prisma, ScoreCache } from '@prisma/client'; +import { JudgingResult, Prisma, ScoreCache } from '@prisma/client'; import { PrismaService } from '../db'; import { LogClass } from '../logger'; +import { WebsocketGateway } from '../websocket/websocket.gateway'; type Contest = Prisma.ContestGetPayload<{ include: { teams: true; problems: true } }>; @@ -16,6 +17,8 @@ export class ScoreboardService { private readonly prisma: PrismaService = new PrismaService(); private refreshing = false; + constructor(private readonly socketService: WebsocketGateway) {} + @Interval(60 * 1000) async refreshScores(): Promise { if (this.refreshing) return; @@ -28,6 +31,8 @@ export class ScoreboardService { include: { teams: true, problems: true }, }); await Promise.all(contests.map((contest) => this.refreshScoreForContest(contest))); + + this.socketService.pingForUpdates('all', 'scoreboard'); } finally { this.refreshing = false; } diff --git a/apps/server/src/websocket/websocket.gateway.ts b/apps/server/src/websocket/websocket.gateway.ts index dfacc49..816d8f5 100644 --- a/apps/server/src/websocket/websocket.gateway.ts +++ b/apps/server/src/websocket/websocket.gateway.ts @@ -6,21 +6,16 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Request } from 'express'; +import { ClsService } from 'nestjs-cls'; import { Server, Socket } from 'socket.io'; -import { PrismaClient } from '@prisma/client'; +import { Clarification, Prisma, PrismaClient } from '@prisma/client'; + +import { WebSocketEvent } from '@tun-judge/shared'; import { LogClass } from '../logger'; import { Session, User } from '../types'; -type UpdateEvents = - | 'contests' - | 'scoreboard' - | 'submissions' - | 'judgings' - | 'judgeRuns' - | 'clarifications'; - type Rooms = 'juries' | 'judgeHosts' | `team-${number}`; @LogClass @@ -30,7 +25,9 @@ export class WebsocketGateway implements OnGatewayConnection { readonly server: Server; readonly prisma: PrismaClient = new PrismaClient(); - pingForUpdates(room: Rooms | 'all', ...events: UpdateEvents[]) { + constructor(private readonly cls: ClsService) {} + + pingForUpdates(room: Rooms | 'all', ...events: WebSocketEvent[]) { events.forEach((event) => room === 'all' ? this.server.emit(event, true) : this.server.in(room).emit(event, true), ); @@ -68,4 +65,85 @@ export class WebsocketGateway implements OnGatewayConnection { judgeHostLogs(@MessageBody() { hostname, log }: { hostname: string; log: string }) { this.server.in('juries').emit(`judgeHost-${hostname}-logs`, log); } + + spyOn(prisma: PrismaClient) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + + return prisma.$extends({ + name: 'db-events', + model: { + submission: this.createOnCreateListener(prisma, 'submission', ['create', 'update'], () => [ + ['all', ['submissions', 'judgingRuns']], + ]), + clarification: this.createOnCreateListener( + prisma, + 'clarification', + 'create', + (clarification: Clarification) => { + const user = this.cls.get('auth') as User; + + return [ + user.roleName === 'team' + ? ['juries', ['clarifications']] + : [ + clarification.teamId ? `team-${clarification.teamId}` : 'all', + ['clarifications'], + ], + ]; + }, + ), + judging: this.createOnCreateListener( + prisma, + 'judging', + ['create', 'update'], + (judging: Prisma.JudgingGetPayload<{ include: { submission: true } }>) => { + const updates: [room: Rooms | 'all', events: WebSocketEvent[]][] = [ + ['juries', ['judgings', 'submissions']], + ]; + + if ('submission' in judging) { + updates.push([`team-${judging.submission.teamId}`, ['submissions']]); + } + + return updates; + }, + ), + judgingRun: this.createOnCreateListener(prisma, 'judgingRun', 'create', () => [ + ['juries', ['judgingRuns']], + ]), + }, + }); + } + + private createOnCreateListener( + prisma: PrismaClient, + entity: string, + method: string | string[], + contextBuilder: (item: unknown) => [room: Rooms | 'all', events: WebSocketEvent[]][], + ) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const socket = this; + const methodArray = Array.isArray(method) ? method : [method]; + + return methodArray.reduce( + (prev, method) => ({ + ...prev, + async [method]( + this: T, + args: Prisma.Exact>, + ): Promise> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await prisma[entity][method](args as any); + + for (const [room, events] of contextBuilder(result)) { + socket.pingForUpdates(room, ...events); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return result as any; + }, + }), + {}, + ); + } } diff --git a/apps/server/src/websocket/websocket.module.ts b/apps/server/src/websocket/websocket.module.ts index de3fc8d..63b1113 100644 --- a/apps/server/src/websocket/websocket.module.ts +++ b/apps/server/src/websocket/websocket.module.ts @@ -1,9 +1,12 @@ -import { Module } from '@nestjs/common'; - import { WebsocketGateway } from './websocket.gateway'; -@Module({ - providers: [WebsocketGateway], - exports: [WebsocketGateway], -}) -export class WebsocketModule {} +export class WebsocketModule { + static forRoot() { + return { + global: true, + module: WebsocketModule, + providers: [WebsocketGateway], + exports: [WebsocketGateway], + }; + } +} diff --git a/libs/shared/.eslintrc.json b/libs/shared/.eslintrc.json new file mode 100644 index 0000000..9d9c0db --- /dev/null +++ b/libs/shared/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/shared/README.md b/libs/shared/README.md new file mode 100644 index 0000000..d6d82fc --- /dev/null +++ b/libs/shared/README.md @@ -0,0 +1,3 @@ +# shared + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/shared/package.json b/libs/shared/package.json new file mode 100644 index 0000000..dfee1a0 --- /dev/null +++ b/libs/shared/package.json @@ -0,0 +1,6 @@ +{ + "name": "@tun-judge/shared", + "version": "0.0.1", + "dependencies": {}, + "private": true +} diff --git a/libs/shared/project.json b/libs/shared/project.json new file mode 100644 index 0000000..7fd3dd8 --- /dev/null +++ b/libs/shared/project.json @@ -0,0 +1,9 @@ +{ + "name": "shared", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project shared --web", + "targets": {} +} diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts new file mode 100644 index 0000000..98111c5 --- /dev/null +++ b/libs/shared/src/index.ts @@ -0,0 +1 @@ +export * from './websocket'; diff --git a/libs/shared/src/websocket.ts b/libs/shared/src/websocket.ts new file mode 100644 index 0000000..7f3f91c --- /dev/null +++ b/libs/shared/src/websocket.ts @@ -0,0 +1,8 @@ +export type WebSocketEvent = + | 'contests' + | 'scoreboard' + | 'submissions' + | 'judgings' + | 'judgingRuns' + | 'clarifications' + | `judgeHost-${string}-logs`; diff --git a/libs/shared/tsconfig.json b/libs/shared/tsconfig.json new file mode 100644 index 0000000..2820e89 --- /dev/null +++ b/libs/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/shared/tsconfig.lib.json b/libs/shared/tsconfig.lib.json new file mode 100644 index 0000000..8675a0b --- /dev/null +++ b/libs/shared/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e29b0ca..6577d47 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,7 +17,9 @@ "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", - "paths": {} + "paths": { + "@tun-judge/shared": ["libs/shared/src/index.ts"] + } }, "exclude": ["node_modules", "tmp"] } diff --git a/zenstack/entities/judge-host.zmodel b/zenstack/entities/judge-host.zmodel index 9adfa08..9354c6d 100644 --- a/zenstack/entities/judge-host.zmodel +++ b/zenstack/entities/judge-host.zmodel @@ -13,6 +13,7 @@ model JudgeHost { judgings Judging[] @@allow('read', auth() != null && (auth().roleName == 'admin' || auth().roleName == 'jury')) - @@allow('read', auth() != null && auth().roleName == 'judge-host' && active && userId == auth().id) + @@allow('read', auth() != null && auth().roleName == 'judge-host' && userId == auth().id) + @@allow('create', auth() != null && auth().roleName == 'judge-host' && userId == auth().id) @@allow('create,update,delete', auth() != null && auth().roleName == 'admin') } diff --git a/zenstack/entities/team.zmodel b/zenstack/entities/team.zmodel index 807a9ab..c293cf3 100644 --- a/zenstack/entities/team.zmodel +++ b/zenstack/entities/team.zmodel @@ -22,6 +22,7 @@ model Team { @@allow('read', auth() != null && (auth().roleName == 'admin' || auth().roleName == 'jury')) @@allow('read', auth() != null && auth().roleName == 'team' && contests?[contest.enabled == true]) + @@allow('read', auth() != null && auth().roleName == 'judge-host' && submissions?[judgeHost == null || (judgeHost.active && judgeHost.userId == auth().id)]) @@allow('read', auth() == null && contests?[contest.enabled == true && contest.public == true]) @@allow('create,update,delete', auth() != null && auth().roleName == 'admin') } diff --git a/zenstack/prisma/schema.prisma b/zenstack/prisma/schema.prisma index 490683a..520ef18 100644 --- a/zenstack/prisma/schema.prisma +++ b/zenstack/prisma/schema.prisma @@ -83,6 +83,7 @@ model Role { /// @@allow('read', auth() != null && (auth().roleName == 'admin' || auth().roleName == 'jury')) /// @@allow('read', auth() != null && auth().roleName == 'team' && contests?[contest.enabled == true]) +/// @@allow('read', auth() != null && auth().roleName == 'judge-host' && submissions?[judgeHost == null || (judgeHost.active && judgeHost.userId == auth().id)]) /// @@allow('read', auth() == null && contests?[contest.enabled == true && contest.public == true]) /// @@allow('create,update,delete', auth() != null && auth().roleName == 'admin') model Team { @@ -323,7 +324,8 @@ model Judging { } /// @@allow('read', auth() != null && (auth().roleName == 'admin' || auth().roleName == 'jury')) -/// @@allow('read', auth() != null && auth().roleName == 'judge-host' && active && userId == auth().id) +/// @@allow('read', auth() != null && auth().roleName == 'judge-host' && userId == auth().id) +/// @@allow('create', auth() != null && auth().roleName == 'judge-host' && userId == auth().id) /// @@allow('create,update,delete', auth() != null && auth().roleName == 'admin') model JudgeHost { id Int @id() @default(autoincrement())