auth + groups invite

This commit is contained in:
Виталий Лавшонок
2025-11-15 17:37:47 +03:00
parent ded41ba7f0
commit dfc2985209
16 changed files with 673 additions and 225 deletions

View File

@@ -1,7 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// ─── Типы ────────────────────────────────────────────
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -19,39 +21,106 @@ export interface Group {
contests: any[];
}
// =====================
// Состояние
// =====================
interface GroupsState {
groups: Group[];
currentGroup: Group | null;
statuses: {
create: Status;
update: Status;
delete: Status;
fetchMy: Status;
fetchById: Status;
addMember: Status;
removeMember: Status;
fetchMyGroups: {
groups: Group[];
status: Status;
error?: string;
};
fetchGroupById: {
group?: Group;
status: Status;
error?: string;
};
createGroup: {
group?: Group;
status: Status;
error?: string;
};
updateGroup: {
group?: Group;
status: Status;
error?: string;
};
deleteGroup: {
deletedId?: number;
status: Status;
error?: string;
};
addGroupMember: {
status: Status;
error?: string;
};
removeGroupMember: {
status: Status;
error?: string;
};
fetchGroupJoinLink: {
joinLink?: { token: string; expiresAt: string };
status: Status;
error?: string;
};
joinGroupByToken: {
group?: Group;
status: Status;
error?: string;
};
error: string | null;
}
const initialState: GroupsState = {
groups: [],
currentGroup: null,
statuses: {
create: 'idle',
update: 'idle',
delete: 'idle',
fetchMy: 'idle',
fetchById: 'idle',
addMember: 'idle',
removeMember: 'idle',
fetchMyGroups: {
groups: [],
status: 'idle',
error: undefined,
},
fetchGroupById: {
group: undefined,
status: 'idle',
error: undefined,
},
createGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
updateGroup: {
group: undefined,
status: 'idle',
error: undefined,
},
deleteGroup: {
deletedId: undefined,
status: 'idle',
error: undefined,
},
addGroupMember: {
status: 'idle',
error: undefined,
},
removeGroupMember: {
status: 'idle',
error: undefined,
},
fetchGroupJoinLink: {
joinLink: undefined,
status: 'idle',
error: undefined,
},
joinGroupByToken: {
group: undefined,
status: 'idle',
error: undefined,
},
error: null,
};
// ─── Async Thunks ─────────────────────────────────────
// =====================
// Async Thunks
// =====================
// POST /groups
export const createGroup = createAsyncThunk(
'groups/createGroup',
async (
@@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk(
},
);
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk(
'groups/updateGroup',
async (
@@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk(
},
);
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk(
'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => {
@@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk(
},
);
// GET /groups/my
export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups',
async (_, { rejectWithValue }) => {
@@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk(
},
);
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => {
@@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk(
},
);
// POST /groups/members
export const addGroupMember = createAsyncThunk(
'groups/addGroupMember',
async (
{ userId, role }: { userId: number; role: string },
{
groupId,
userId,
role,
}: { groupId: number; userId: number; role: string },
{ rejectWithValue },
) => {
try {
await axios.post('/groups/members', { userId, role });
return { userId, role };
const response = await axios.post(`/groups/${groupId}/members`, {
userId,
role,
});
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
@@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk(
},
);
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember',
async (
@@ -176,147 +246,169 @@ export const removeGroupMember = createAsyncThunk(
},
);
// ─── Slice ────────────────────────────────────────────
// =====================
// Новые Async Thunks
// =====================
// Получение актуальной ссылки для присоединения к группе
export const fetchGroupJoinLink = createAsyncThunk(
'groups/fetchGroupJoinLink',
async (groupId: number, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${groupId}/join-link`);
return response.data as { token: string; expiresAt: string };
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении ссылки для присоединения',
);
}
},
);
// Присоединение к группе по токену приглашения
export const joinGroupByToken = createAsyncThunk(
'groups/joinGroupByToken',
async (token: string, { rejectWithValue }) => {
try {
const response = await axios.post(`/groups/join/${token}`);
return response.data as Group;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при присоединении к группе по ссылке',
);
}
},
);
// =====================
// Slice
// =====================
const groupsSlice = createSlice({
name: 'groups',
initialState,
reducers: {
clearCurrentGroup: (state) => {
state.currentGroup = null;
setGroupsStatus: (
state,
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
},
},
extraReducers: (builder) => {
// ─── CREATE GROUP ───
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.create = 'successful';
state.groups.push(action.payload);
},
);
builder.addCase(
createGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.create = 'failed';
state.error = action.payload;
},
);
// ─── UPDATE GROUP ───
builder.addCase(updateGroup.pending, (state) => {
state.statuses.update = 'loading';
state.error = null;
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.update = 'successful';
const index = state.groups.findIndex(
(g) => g.id === action.payload.id,
);
if (index !== -1) state.groups[index] = action.payload;
if (state.currentGroup?.id === action.payload.id) {
state.currentGroup = action.payload;
}
},
);
builder.addCase(
updateGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.update = 'failed';
state.error = action.payload;
},
);
// ─── DELETE GROUP ───
builder.addCase(deleteGroup.pending, (state) => {
state.statuses.delete = 'loading';
state.error = null;
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.statuses.delete = 'successful';
state.groups = state.groups.filter(
(g) => g.id !== action.payload,
);
if (state.currentGroup?.id === action.payload)
state.currentGroup = null;
},
);
builder.addCase(
deleteGroup.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH MY GROUPS ───
// fetchMyGroups
builder.addCase(fetchMyGroups.pending, (state) => {
state.statuses.fetchMy = 'loading';
state.error = null;
state.fetchMyGroups.status = 'loading';
});
builder.addCase(
fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => {
state.statuses.fetchMy = 'successful';
state.groups = action.payload;
},
);
builder.addCase(
fetchMyGroups.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = action.payload;
state.fetchMyGroups.status = 'successful';
state.fetchMyGroups.groups = action.payload;
},
);
builder.addCase(fetchMyGroups.rejected, (state, action: any) => {
state.fetchMyGroups.status = 'failed';
state.fetchMyGroups.error = action.payload;
});
// ─── FETCH GROUP BY ID ───
// fetchGroupById
builder.addCase(fetchGroupById.pending, (state) => {
state.statuses.fetchById = 'loading';
state.error = null;
state.fetchGroupById.status = 'loading';
});
builder.addCase(
fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.fetchById = 'successful';
state.currentGroup = action.payload;
},
);
builder.addCase(
fetchGroupById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
state.fetchGroupById.status = 'successful';
state.fetchGroupById.group = action.payload;
},
);
builder.addCase(fetchGroupById.rejected, (state, action: any) => {
state.fetchGroupById.status = 'failed';
state.fetchGroupById.error = action.payload;
});
// ─── ADD MEMBER ───
// createGroup
builder.addCase(createGroup.pending, (state) => {
state.createGroup.status = 'loading';
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.createGroup.status = 'successful';
state.createGroup.group = action.payload;
state.fetchMyGroups.groups.push(action.payload);
},
);
builder.addCase(createGroup.rejected, (state, action: any) => {
state.createGroup.status = 'failed';
state.createGroup.error = action.payload;
});
// updateGroup
builder.addCase(updateGroup.pending, (state) => {
state.updateGroup.status = 'loading';
});
builder.addCase(
updateGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.updateGroup.status = 'successful';
state.updateGroup.group = action.payload;
const index = state.fetchMyGroups.groups.findIndex(
(g) => g.id === action.payload.id,
);
if (index !== -1)
state.fetchMyGroups.groups[index] = action.payload;
if (state.fetchGroupById.group?.id === action.payload.id)
state.fetchGroupById.group = action.payload;
},
);
builder.addCase(updateGroup.rejected, (state, action: any) => {
state.updateGroup.status = 'failed';
state.updateGroup.error = action.payload;
});
// deleteGroup
builder.addCase(deleteGroup.pending, (state) => {
state.deleteGroup.status = 'loading';
});
builder.addCase(
deleteGroup.fulfilled,
(state, action: PayloadAction<number>) => {
state.deleteGroup.status = 'successful';
state.deleteGroup.deletedId = action.payload;
state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter(
(g) => g.id !== action.payload,
);
if (state.fetchGroupById.group?.id === action.payload)
state.fetchGroupById.group = undefined;
},
);
builder.addCase(deleteGroup.rejected, (state, action: any) => {
state.deleteGroup.status = 'failed';
state.deleteGroup.error = action.payload;
});
// addGroupMember
builder.addCase(addGroupMember.pending, (state) => {
state.statuses.addMember = 'loading';
state.error = null;
state.addGroupMember.status = 'loading';
});
builder.addCase(addGroupMember.fulfilled, (state) => {
state.statuses.addMember = 'successful';
state.addGroupMember.status = 'successful';
});
builder.addCase(addGroupMember.rejected, (state, action: any) => {
state.addGroupMember.status = 'failed';
state.addGroupMember.error = action.payload;
});
builder.addCase(
addGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.addMember = 'failed';
state.error = action.payload;
},
);
// ─── REMOVE MEMBER ───
// removeGroupMember
builder.addCase(removeGroupMember.pending, (state) => {
state.statuses.removeMember = 'loading';
state.error = null;
state.removeGroupMember.status = 'loading';
});
builder.addCase(
removeGroupMember.fulfilled,
@@ -324,27 +416,60 @@ const groupsSlice = createSlice({
state,
action: PayloadAction<{ groupId: number; memberId: number }>,
) => {
state.statuses.removeMember = 'successful';
state.removeGroupMember.status = 'successful';
if (
state.currentGroup &&
state.currentGroup.id === action.payload.groupId
state.fetchGroupById.group &&
state.fetchGroupById.group.id === action.payload.groupId
) {
state.currentGroup.members =
state.currentGroup.members.filter(
state.fetchGroupById.group.members =
state.fetchGroupById.group.members.filter(
(m) => m.userId !== action.payload.memberId,
);
}
},
);
builder.addCase(removeGroupMember.rejected, (state, action: any) => {
state.removeGroupMember.status = 'failed';
state.removeGroupMember.error = action.payload;
});
// fetchGroupJoinLink
builder.addCase(fetchGroupJoinLink.pending, (state) => {
state.fetchGroupJoinLink.status = 'loading';
});
builder.addCase(
removeGroupMember.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.removeMember = 'failed';
state.error = action.payload;
fetchGroupJoinLink.fulfilled,
(
state,
action: PayloadAction<{ token: string; expiresAt: string }>,
) => {
state.fetchGroupJoinLink.status = 'successful';
state.fetchGroupJoinLink.joinLink = action.payload;
},
);
builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => {
state.fetchGroupJoinLink.status = 'failed';
state.fetchGroupJoinLink.error = action.payload;
});
// joinGroupByToken
builder.addCase(joinGroupByToken.pending, (state) => {
state.joinGroupByToken.status = 'loading';
});
builder.addCase(
joinGroupByToken.fulfilled,
(state, action: PayloadAction<Group>) => {
state.joinGroupByToken.status = 'successful';
state.joinGroupByToken.group = action.payload;
state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список
},
);
builder.addCase(joinGroupByToken.rejected, (state, action: any) => {
state.joinGroupByToken.status = 'failed';
state.joinGroupByToken.error = action.payload;
});
},
});
export const { clearCurrentGroup } = groupsSlice.actions;
export const { setGroupsStatus } = groupsSlice.actions;
export const groupsReducer = groupsSlice.reducer;