import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; import { toastError } from '../../lib/toastNotification'; // ===================== // Типы // ===================== export interface Solution { id: number; missionId: number; language: string; languageVersion: string; sourceCode: string; status: string; time: string; testerState: string; testerErrorCode: string; testerMessage: string; currentTest: number; amountOfTests: number; } export interface Submission { id: number; userId: number; solution: Solution; contestId: number; contestName: string; sourceType: string; } export interface Mission { id: number; authorId: number; name: string; difficulty: number; tags: string[]; timeLimitMilliseconds: number; memoryLimitBytes: number; statements: string; } export interface Member { userId: number; username: string; role: string; } export interface Group { groupId: number; groupName: string; } export interface Contest { id: number; name: string; description?: string; scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; visibility: 'Public' | 'GroupPrivate'; startsAt?: string; endsAt?: string; attemptDurationMinutes?: number; maxAttempts?: number; allowEarlyFinish?: boolean; groupId?: number; groupName?: string; missions?: Mission[]; articles?: any[]; members?: Member[]; } export interface Attempt { attemptId: number; contestId: number; startedAt: string; expiresAt: string; finished: boolean; submissions?: Submission[]; results?: any[]; } interface ContestsResponse { hasNextPage: boolean; contests: Contest[]; } interface MembersPage { members: Member[]; hasNextPage: boolean; } export interface CreateContestBody { name: string; description?: string; scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; visibility: 'Public' | 'GroupPrivate'; startsAt?: string; endsAt?: string; attemptDurationMinutes?: number; maxAttempts?: number; allowEarlyFinish?: boolean; groupId?: number; groupName?: string; missionIds?: number[]; articleIds?: number[]; } // ===================== // Состояние // ===================== type Status = 'idle' | 'loading' | 'successful' | 'failed'; interface ContestsState { fetchContests: { contests: Contest[]; hasNextPage: boolean; status: Status; error?: string; }; fetchContestById: { contest: Contest; status: Status; error?: string; }; createContest: { contest: Contest; status: Status; error?: string; }; fetchMySubmissions: { submissions: Submission[]; status: Status; error?: string; }; updateContest: { contest: Contest; status: Status; error?: string; }; deleteContest: { status: Status; error?: string; }; fetchMyContests: { contests: Contest[]; status: Status; error?: string; }; fetchRegisteredContests: { contests: Contest[]; hasNextPage: boolean; status: Status; error?: string; }; // NEW: fetchContestMembers: { members: Member[]; hasNextPage: boolean; status: Status; error?: string; }; addOrUpdateMember: { status: Status; error?: string; }; deleteContestMember: { status: Status; error?: string; }; startAttempt: { attempt?: Attempt; status: Status; error?: string; }; fetchMyAttemptsInContest: { attempts: Attempt[]; status: Status; error?: string; }; fetchMyAllAttempts: { attempts: Attempt[]; status: Status; error?: string; }; fetchMyActiveAttempt: { attempt?: Attempt | null; status: Status; error?: string; }; checkRegistration: { registered: boolean; status: Status; error?: string; }; fetchUpcomingEligible: { contests: Contest[]; status: Status; error?: string; }; fetchParticipating: { contests: Contest[]; hasNextPage: boolean; status: Status; error?: string; }; } const emptyContest: Contest = { id: 0, name: '', description: '', scheduleType: 'AlwaysOpen', visibility: 'Public', startsAt: '', endsAt: '', attemptDurationMinutes: 0, maxAttempts: 0, allowEarlyFinish: false, missions: [], articles: [], members: [], }; const initialState: ContestsState = { fetchContests: { contests: [], hasNextPage: false, status: 'idle' }, fetchContestById: { contest: emptyContest, status: 'idle' }, createContest: { contest: emptyContest, status: 'idle' }, fetchMySubmissions: { submissions: [], status: 'idle' }, updateContest: { contest: emptyContest, status: 'idle' }, deleteContest: { status: 'idle' }, fetchMyContests: { contests: [], status: 'idle' }, fetchRegisteredContests: { contests: [], hasNextPage: false, status: 'idle', }, fetchContestMembers: { members: [], hasNextPage: false, status: 'idle' }, addOrUpdateMember: { status: 'idle' }, deleteContestMember: { status: 'idle' }, startAttempt: { status: 'idle' }, fetchMyAttemptsInContest: { attempts: [], status: 'idle' }, fetchMyAllAttempts: { attempts: [], status: 'idle' }, fetchMyActiveAttempt: { attempt: null, status: 'idle' }, checkRegistration: { registered: false, status: 'idle' }, fetchUpcomingEligible: { contests: [], status: 'idle' }, fetchParticipating: { contests: [], hasNextPage: false, status: 'idle', error: undefined, }, }; // ===================== // Async Thunks // ===================== // Existing ---------------------------- export const fetchParticipatingContests = createAsyncThunk( 'contests/fetchParticipating', async ( params: { page?: number; pageSize?: number } = {}, { rejectWithValue }, ) => { try { const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/participating', { params: { page, pageSize } }, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const fetchMySubmissions = createAsyncThunk( 'contests/fetchMySubmissions', async (contestId: number, { rejectWithValue }) => { try { const response = await axios.get( `/contests/${contestId}/submissions/my`, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const fetchContests = createAsyncThunk( 'contests/fetchAll', async ( params: { page?: number; pageSize?: number; groupId?: number | null; } = {}, { rejectWithValue }, ) => { try { const { page = 0, pageSize = 100, groupId } = params; const response = await axios.get('/contests', { params: { page, pageSize, groupId }, }); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const fetchContestById = createAsyncThunk( 'contests/fetchById', async (id: number, { rejectWithValue }) => { try { const response = await axios.get(`/contests/${id}`); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { try { const response = await axios.post( '/contests', contestData, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const updateContest = createAsyncThunk( 'contests/update', async ( { contestId, ...contestData }: { contestId: number } & CreateContestBody, { rejectWithValue }, ) => { try { const response = await axios.put( `/contests/${contestId}`, contestData, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const deleteContest = createAsyncThunk( 'contests/delete', async (contestId: number, { rejectWithValue }) => { try { await axios.delete(`/contests/${contestId}`); return contestId; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const fetchMyContests = createAsyncThunk( 'contests/fetchMyContests', async (_, { rejectWithValue }) => { try { const response = await axios.get('/contests/my'); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); export const fetchRegisteredContests = createAsyncThunk( 'contests/fetchRegisteredContests', async ( params: { page?: number; pageSize?: number } = {}, { rejectWithValue }, ) => { try { const { page = 0, pageSize = 100 } = params; const response = await axios.get( '/contests/registered', { params: { page, pageSize } }, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // NEW ----------------------------------- // Add or update member export const addOrUpdateContestMember = createAsyncThunk( 'contests/addOrUpdateMember', async ( { contestId, member, }: { contestId: number; member: { userId: number; role: string } }, { rejectWithValue }, ) => { try { const response = await axios.post( `/contests/${contestId}/members`, member, ); return { contestId, members: response.data }; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Delete member export const deleteContestMember = createAsyncThunk( 'contests/deleteContestMember', async ( { contestId, memberId }: { contestId: number; memberId: number }, { rejectWithValue }, ) => { try { await axios.delete(`/contests/${contestId}/members/${memberId}`); return { contestId, memberId }; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Start attempt export const startContestAttempt = createAsyncThunk( 'contests/startContestAttempt', async (contestId: number, { rejectWithValue }) => { try { const response = await axios.post( `/contests/${contestId}/attempts`, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // My attempts in contest export const fetchMyAttemptsInContest = createAsyncThunk( 'contests/fetchMyAttemptsInContest', async (contestId: number, { rejectWithValue }) => { try { const response = await axios.get( `/contests/${contestId}/attempts/my`, ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Members with pagination export const fetchContestMembers = createAsyncThunk( 'contests/fetchContestMembers', async ( { contestId, page = 0, pageSize = 25, }: { contestId: number; page?: number; pageSize?: number }, { rejectWithValue }, ) => { try { const response = await axios.get( `/contests/${contestId}/members`, { params: { page, pageSize } }, ); return { contestId, ...response.data }; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Check registration export const checkContestRegistration = createAsyncThunk( 'contests/checkRegistration', async (contestId: number, { rejectWithValue }) => { try { const response = await axios.get<{ registered: boolean }>( `/contests/${contestId}/registered`, ); return { contestId, registered: response.data.registered }; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Upcoming eligible contests export const fetchUpcomingEligibleContests = createAsyncThunk( 'contests/fetchUpcomingEligible', async (_, { rejectWithValue }) => { try { const response = await axios.get( '/contests/upcoming/eligible', ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // All my attempts export const fetchMyAllAttempts = createAsyncThunk( 'contests/fetchMyAllAttempts', async (_, { rejectWithValue }) => { try { const response = await axios.get( '/contests/attempts/my', ); return response.data; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // Active attempt export const fetchMyActiveAttempt = createAsyncThunk( 'contests/fetchMyActiveAttempt', async (contestId: number, { rejectWithValue }) => { try { const response = await axios.get( `/contests/${contestId}/attempts/my/active`, ); return { contestId, attempt: response.data }; } catch (err: any) { return rejectWithValue(err.response?.data); } }, ); // ===================== // Slice // ===================== const contestsSlice = createSlice({ name: 'contests', initialState, reducers: { setContestStatus: ( state, action: PayloadAction<{ key: keyof ContestsState; status: Status }>, ) => { const { key, status } = action.payload; if (state[key]) { (state[key] as any).status = status; } }, }, extraReducers: (builder) => { // ——— YOUR EXISTING HANDLERS (unchanged) ——— builder.addCase(fetchMySubmissions.pending, (state) => { state.fetchMySubmissions.status = 'loading'; state.fetchMySubmissions.error = undefined; }); builder.addCase( fetchMySubmissions.fulfilled, (state, action: PayloadAction) => { state.fetchMySubmissions.status = 'successful'; state.fetchMySubmissions.submissions = action.payload; }, ); builder.addCase(fetchMySubmissions.rejected, (state, action: any) => { state.fetchMySubmissions.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchContests.pending, (state) => { state.fetchContests.status = 'loading'; }); builder.addCase( fetchContests.fulfilled, (state, action: PayloadAction) => { state.fetchContests.status = 'successful'; state.fetchContests.contests = action.payload.contests; state.fetchContests.hasNextPage = action.payload.hasNextPage; }, ); builder.addCase(fetchContests.rejected, (state, action: any) => { state.fetchContests.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchContestById.pending, (state) => { state.fetchContestById.status = 'loading'; }); builder.addCase( fetchContestById.fulfilled, (state, action: PayloadAction) => { state.fetchContestById.status = 'successful'; state.fetchContestById.contest = action.payload; }, ); builder.addCase(fetchContestById.rejected, (state, action: any) => { state.fetchContestById.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(createContest.pending, (state) => { state.createContest.status = 'loading'; }); builder.addCase( createContest.fulfilled, (state, action: PayloadAction) => { state.createContest.status = 'successful'; state.createContest.contest = action.payload; }, ); builder.addCase(createContest.rejected, (state, action: any) => { state.createContest.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(updateContest.pending, (state) => { state.updateContest.status = 'loading'; }); builder.addCase( updateContest.fulfilled, (state, action: PayloadAction) => { state.updateContest.status = 'successful'; state.updateContest.contest = action.payload; }, ); builder.addCase(updateContest.rejected, (state, action: any) => { state.updateContest.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(deleteContest.pending, (state) => { state.deleteContest.status = 'loading'; }); builder.addCase( deleteContest.fulfilled, (state, action: PayloadAction) => { state.deleteContest.status = 'successful'; state.fetchContests.contests = state.fetchContests.contests.filter( (c) => c.id !== action.payload, ); state.fetchMyContests.contests = state.fetchMyContests.contests.filter( (c) => c.id !== action.payload, ); }, ); builder.addCase(deleteContest.rejected, (state, action: any) => { state.deleteContest.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchMyContests.pending, (state) => { state.fetchMyContests.status = 'loading'; }); builder.addCase( fetchMyContests.fulfilled, (state, action: PayloadAction) => { state.fetchMyContests.status = 'successful'; state.fetchMyContests.contests = action.payload; }, ); builder.addCase(fetchMyContests.rejected, (state, action: any) => { state.fetchMyContests.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchRegisteredContests.pending, (state) => { state.fetchRegisteredContests.status = 'loading'; }); builder.addCase( fetchRegisteredContests.fulfilled, (state, action: PayloadAction) => { state.fetchRegisteredContests.status = 'successful'; state.fetchRegisteredContests.contests = action.payload.contests; state.fetchRegisteredContests.hasNextPage = action.payload.hasNextPage; }, ); builder.addCase( fetchRegisteredContests.rejected, (state, action: any) => { state.fetchRegisteredContests.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); // NEW HANDLERS builder.addCase(fetchContestMembers.pending, (state) => { state.fetchContestMembers.status = 'loading'; }); builder.addCase( fetchContestMembers.fulfilled, ( state, action: PayloadAction<{ contestId: number; members: Member[]; hasNextPage: boolean; }>, ) => { state.fetchContestMembers.status = 'successful'; state.fetchContestMembers.members = action.payload.members; state.fetchContestMembers.hasNextPage = action.payload.hasNextPage; }, ); builder.addCase(fetchContestMembers.rejected, (state, action: any) => { state.fetchContestMembers.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(addOrUpdateContestMember.pending, (state) => { state.addOrUpdateMember.status = 'loading'; }); builder.addCase(addOrUpdateContestMember.fulfilled, (state) => { state.addOrUpdateMember.status = 'successful'; }); builder.addCase( addOrUpdateContestMember.rejected, (state, action: any) => { state.addOrUpdateMember.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); builder.addCase(deleteContestMember.pending, (state) => { state.deleteContestMember.status = 'loading'; }); builder.addCase(deleteContestMember.fulfilled, (state) => { state.deleteContestMember.status = 'successful'; }); builder.addCase(deleteContestMember.rejected, (state, action: any) => { state.deleteContestMember.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(startContestAttempt.pending, (state) => { state.startAttempt.status = 'loading'; }); builder.addCase( startContestAttempt.fulfilled, (state, action: PayloadAction) => { state.startAttempt.status = 'successful'; state.startAttempt.attempt = action.payload; }, ); builder.addCase(startContestAttempt.rejected, (state, action: any) => { state.startAttempt.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchMyAttemptsInContest.pending, (state) => { state.fetchMyAttemptsInContest.status = 'loading'; }); builder.addCase( fetchMyAttemptsInContest.fulfilled, (state, action: PayloadAction) => { state.fetchMyAttemptsInContest.status = 'successful'; state.fetchMyAttemptsInContest.attempts = action.payload; }, ); builder.addCase( fetchMyAttemptsInContest.rejected, (state, action: any) => { state.fetchMyAttemptsInContest.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); builder.addCase(fetchMyAllAttempts.pending, (state) => { state.fetchMyAllAttempts.status = 'loading'; }); builder.addCase( fetchMyAllAttempts.fulfilled, (state, action: PayloadAction) => { state.fetchMyAllAttempts.status = 'successful'; state.fetchMyAllAttempts.attempts = action.payload; }, ); builder.addCase(fetchMyAllAttempts.rejected, (state, action: any) => { state.fetchMyAllAttempts.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(fetchMyActiveAttempt.pending, (state) => { state.fetchMyActiveAttempt.status = 'loading'; }); builder.addCase( fetchMyActiveAttempt.fulfilled, ( state, action: PayloadAction<{ contestId: number; attempt: Attempt | null; }>, ) => { state.fetchMyActiveAttempt.status = 'successful'; state.fetchMyActiveAttempt.attempt = action.payload.attempt; }, ); builder.addCase(fetchMyActiveAttempt.rejected, (state, action: any) => { state.fetchMyActiveAttempt.status = 'failed'; const errors = action.payload.errors as Record; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }); builder.addCase(checkContestRegistration.pending, (state) => { state.checkRegistration.status = 'loading'; }); builder.addCase( checkContestRegistration.fulfilled, ( state, action: PayloadAction<{ contestId: number; registered: boolean; }>, ) => { state.checkRegistration.status = 'successful'; state.checkRegistration.registered = action.payload.registered; }, ); builder.addCase( checkContestRegistration.rejected, (state, action: any) => { state.checkRegistration.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); builder.addCase(fetchUpcomingEligibleContests.pending, (state) => { state.fetchUpcomingEligible.status = 'loading'; }); builder.addCase( fetchUpcomingEligibleContests.fulfilled, (state, action: PayloadAction) => { state.fetchUpcomingEligible.status = 'successful'; state.fetchUpcomingEligible.contests = action.payload; }, ); builder.addCase( fetchUpcomingEligibleContests.rejected, (state, action: any) => { state.fetchUpcomingEligible.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); builder.addCase(fetchParticipatingContests.pending, (state) => { state.fetchParticipating.status = 'loading'; }); builder.addCase( fetchParticipatingContests.fulfilled, (state, action: PayloadAction) => { state.fetchParticipating.status = 'successful'; state.fetchParticipating.contests = action.payload.contests; state.fetchParticipating.hasNextPage = action.payload.hasNextPage; }, ); builder.addCase( fetchParticipatingContests.rejected, (state, action: any) => { state.fetchParticipating.status = 'failed'; const errors = action.payload.errors as Record< string, string[] >; Object.values(errors).forEach((messages) => { messages.forEach((msg) => { toastError(msg); }); }); }, ); }, }); // ===================== // Экспорты // ===================== export const { setContestStatus } = contestsSlice.actions; export const contestsReducer = contestsSlice.reducer;