Files
LiquidCode_Frontend/src/redux/slices/contests.ts
Виталий Лавшонок d1a46435c4 add error toasts
2025-12-10 01:33:16 +03:00

1037 lines
31 KiB
TypeScript

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<ContestsResponse>(
'/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<Submission[]>(
`/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<ContestsResponse>('/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<Contest>(`/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<Contest>(
'/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<Contest>(
`/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<Contest[]>('/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<ContestsResponse>(
'/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<Member[]>(
`/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<Attempt>(
`/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<Attempt[]>(
`/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<MembersPage>(
`/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<Contest[]>(
'/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<Attempt[]>(
'/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<Attempt | null>(
`/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<Submission[]>) => {
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<string, string[]>;
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<ContestsResponse>) => {
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<string, string[]>;
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<Contest>) => {
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<string, string[]>;
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<Contest>) => {
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<string, string[]>;
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<Contest>) => {
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<string, string[]>;
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<number>) => {
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<string, string[]>;
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<Contest[]>) => {
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<string, string[]>;
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<ContestsResponse>) => {
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<string, string[]>;
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<string, string[]>;
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<Attempt>) => {
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<string, string[]>;
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<Attempt[]>) => {
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<Attempt[]>) => {
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<string, string[]>;
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<string, string[]>;
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<Contest[]>) => {
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<ContestsResponse>) => {
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;