diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index 9252436..8f2647b 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -41,21 +41,26 @@ public class EndpointAuthorizationTests : IClassFixture var stub = Substitute.For(); var authResult = new AuthResult( new AuthResponse("access_token", DateTime.UtcNow.AddHours(1), - new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])), + new UserAuthDto("test@test.com", "Test User", [UserRole.Student])), "refresh_token"); stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(authResult); @@ -123,7 +123,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory .Returns(authResult); stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); stub.GetCurrentUserAsync(Arg.Any()) - .Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow)); + .Returns(new CurrentUserDto("test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow)); return stub; } @@ -153,10 +153,16 @@ public class ApiWebApplicationFactory : WebApplicationFactory var stub = Substitute.For(); var userDto = new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow); var pagedUsers = PagedResult.Create([userDto], 1, 1, 20); + var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null, + "Title", null, LectureFormat.Offline, + DateTime.UtcNow, DateTime.UtcNow.AddHours(2), + true, 30, 0, null, DateTime.UtcNow, true); + var pagedLectures = PagedResult.Create([lectureDto], 1, 1, 20); stub.GetByIdAsync(Arg.Any()).Returns(userDto); stub.UpdateProfileAsync(Arg.Any(), Arg.Any()).Returns(userDto); stub.GetStatsAsync(Arg.Any()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0, 0, 100)); + stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any()).Returns(pagedLectures); stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index bfe1d9b..af100fe 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -26,73 +26,163 @@ public class UsersController : ControllerBase private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); + private static CurrentUserDto ToCurrentUserDto(UserDto user) => new( + user.Email, + user.DisplayName, + user.AvatarUrl, + user.Roles, + user.Xp, + user.Coins, + user.Level, + user.CreatedAt); + + /// Получить профиль текущего пользователя. + /// Данные текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me")] + [ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMe() => + Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId))); + + /// Обновить профиль текущего пользователя (displayName, avatarUrl). + /// Обновляемые поля профиля. + /// Обновлённые данные текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpPut("me")] + [ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateMe([FromBody] UpdateUserRequest req) => + Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req))); + + /// Получить статистику текущего пользователя. + /// Статистика текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me/stats")] + [ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MyStats() => + Ok(await _users.GetStatsAsync(CurrentUserId)); + + /// Получить список записей текущего пользователя на лекции. + /// Параметры пагинации. + /// Список записей (пагинированный). + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me/enrollments")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task MyEnrollments([FromQuery] PaginationRequest pagination) => + Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination)); + + /// Получить отзывы текущего пользователя. + /// Параметры пагинации. + /// Список отзывов (пагинированный). + /// Требуется аутентификация. + [HttpGet("me/reviews")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyReviews([FromQuery] PaginationRequest pagination) => + Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination)); + + /// Получить достижения текущего пользователя. + /// Список полученных достижений. + /// Требуется аутентификация. + [HttpGet("me/achievements")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyAchievements() => + Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId)); + + /// Получить историю транзакций монет текущего пользователя. + /// Параметры пагинации. + /// История транзакций (пагинированная). + /// Требуется аутентификация. + [HttpGet("me/transactions")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyTransactions([FromQuery] PaginationRequest pagination) => + Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination)); + /// Получить профиль пользователя по ID. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me. /// ID пользователя. /// Данные пользователя. /// Требуется аутентификация. + /// Требуется роль Admin. /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}")] [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _users.GetByIdAsync(id)); /// Обновить профиль пользователя (displayName, avatarUrl). - /// Разрешено только самому пользователю или Admin. + /// Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me. /// ID пользователя. /// Обновляемые поля профиля. /// Обновлённые данные пользователя. /// Требуется аутентификация. - /// Нет прав — только владелец профиля или Admin. + /// Требуется роль Admin. /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Update(int id, [FromBody] UpdateUserRequest req) - { - if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid(); - return Ok(await _users.UpdateProfileAsync(id, req)); - } + public async Task> Update(int id, [FromBody] UpdateUserRequest req) => + Ok(await _users.UpdateProfileAsync(id, req)); /// Получить статистику пользователя (XP, монеты, уровень, посещения). + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats. /// ID пользователя. /// Статистика пользователя. /// Требуется аутентификация. + /// Требуется роль Admin. /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/stats")] [ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Stats(int id) => Ok(await _users.GetStatsAsync(id)); /// Получить список записей пользователя на лекции. - /// Разрешено только самому пользователю или Admin. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments. /// ID пользователя. /// Параметры пагинации. /// Список записей (пагинированный). /// Требуется аутентификация. - /// Нет прав — только владелец или Admin. + /// Требуется роль Admin. + /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/enrollments")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) - { - if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid(); - // Delegate to lecture service would be more proper, but returning reviews for now - return Ok(); - } + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _users.GetEnrollmentsAsync(id, pagination)); /// Получить отзывы пользователя. - /// Только Admin или Teacher. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews. /// ID пользователя. /// Параметры пагинации. /// Список отзывов (пагинированный). /// Требуется аутентификация. - /// Требуется роль Admin или Teacher. - [Authorize(Roles = "Admin,Teacher")] + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/reviews")] [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -101,31 +191,33 @@ public class UsersController : ControllerBase Ok(await _reviews.GetByUserAsync(id, pagination)); /// Получить достижения пользователя. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements. /// ID пользователя. /// Список полученных достижений. /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/achievements")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Achievements(int id) => Ok(await _gamification.GetUserAchievementsAsync(id)); /// Получить историю транзакций монет пользователя. - /// Разрешено только самому пользователю или Admin. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions. /// ID пользователя. /// Параметры пагинации. /// История транзакций (пагинированная). /// Требуется аутентификация. - /// Нет прав — только владелец или Admin. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/transactions")] [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task Transactions(int id, [FromQuery] PaginationRequest pagination) - { - if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid(); - return Ok(await _gamification.GetTransactionsAsync(id, pagination)); - } + public async Task Transactions(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _gamification.GetTransactionsAsync(id, pagination)); /// Получить список всех пользователей с фильтрацией и пагинацией. /// Только Admin. diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index 5d77df6..d4e6fc2 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -3506,32 +3506,20 @@ ] } }, - "/api/v1/users/{id}": { + "/api/v1/users/me": { "get": { "tags": [ "Users" ], - "summary": "Получить профиль пользователя по ID.", + "summary": "Получить профиль текущего пользователя.", "description": "**Required:** any authenticated user", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID пользователя.", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], "responses": { "200": { - "description": "Данные пользователя.", + "description": "Данные текущего пользователя.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserDto" + "$ref": "#/components/schemas/CurrentUserDto" } } } @@ -3563,12 +3551,401 @@ } ] }, + "put": { + "tags": [ + "Users" + ], + "summary": "Обновить профиль текущего пользователя (displayName, avatarUrl).", + "description": "**Required:** any authenticated user", + "requestBody": { + "description": "Обновляемые поля профиля.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/stats": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить статистику текущего пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Статистика текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/enrollments": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить список записей текущего пользователя на лекции.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список записей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/reviews": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить отзывы текущего пользователя.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список отзывов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/achievements": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить достижения текущего пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Список полученных достижений.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAchievementDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/transactions": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить историю транзакций монет текущего пользователя.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "История транзакций (пагинированная).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoinTransactionDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить профиль пользователя по ID.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, "put": { "tags": [ "Users" ], "summary": "Обновить профиль пользователя (displayName, avatarUrl).", - "description": "Разрешено только самому пользователю или Admin.\n\n**Required:** any authenticated user", + "description": "Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3623,7 +4000,7 @@ } }, "403": { - "description": "Нет прав — только владелец профиля или Admin.", + "description": "Требуется роль Admin.", "content": { "application/json": { "schema": { @@ -3656,7 +4033,7 @@ "Users" ], "summary": "Получить статистику пользователя (XP, монеты, уровень, посещения).", - "description": "**Required:** any authenticated user", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3690,6 +4067,16 @@ } } }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "404": { "description": "Пользователь не найден.", "content": { @@ -3714,7 +4101,7 @@ "Users" ], "summary": "Получить список записей пользователя на лекции.", - "description": "Разрешено только самому пользователю или Admin.\n\n**Required:** any authenticated user", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3745,7 +4132,14 @@ ], "responses": { "200": { - "description": "Список записей (пагинированный)." + "description": "Список записей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDtoPagedResult" + } + } + } }, "401": { "description": "Требуется аутентификация.", @@ -3758,7 +4152,17 @@ } }, "403": { - "description": "Нет прав — только владелец или Admin.", + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", "content": { "application/json": { "schema": { @@ -3781,7 +4185,7 @@ "Users" ], "summary": "Получить отзывы пользователя.", - "description": "Только Admin или Teacher.\n\n**Required roles:** Admin, Teacher", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3832,7 +4236,7 @@ } }, "403": { - "description": "Требуется роль Admin или Teacher.", + "description": "Требуется роль Admin.", "content": { "application/json": { "schema": { @@ -3855,7 +4259,7 @@ "Users" ], "summary": "Получить достижения пользователя.", - "description": "**Required:** any authenticated user", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3891,6 +4295,16 @@ } } } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } }, "security": [ @@ -3906,7 +4320,7 @@ "Users" ], "summary": "Получить историю транзакций монет пользователя.", - "description": "Разрешено только самому пользователю или Admin.\n\n**Required:** any authenticated user", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -3957,7 +4371,7 @@ } }, "403": { - "description": "Нет прав — только владелец или Admin.", + "description": "Требуется роль Admin.", "content": { "application/json": { "schema": { @@ -4586,10 +5000,6 @@ "CurrentUserDto": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int32" - }, "email": { "type": "string", "nullable": true @@ -5494,10 +5904,6 @@ "UserAuthDto": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int32" - }, "email": { "type": "string", "nullable": true diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index e2519e3..e2b17e7 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -5,7 +5,7 @@ namespace UniVerse.Application.DTOs.Auth; public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User); public record AuthResult(AuthResponse Response, string RefreshToken); -public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList Roles); +public record UserAuthDto(string Email, string? DisplayName, IReadOnlyList Roles); public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null); diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 2c89f2d..63dedcb 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -16,7 +16,6 @@ public record UserDto( ); public record CurrentUserDto( - int Id, string Email, string? DisplayName, string? AvatarUrl, diff --git a/backend/UniVerse.Application/Interfaces/IUserService.cs b/backend/UniVerse.Application/Interfaces/IUserService.cs index 27985e8..7c6b60f 100644 --- a/backend/UniVerse.Application/Interfaces/IUserService.cs +++ b/backend/UniVerse.Application/Interfaces/IUserService.cs @@ -1,4 +1,5 @@ using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Users; using UniVerse.Domain.Enums; @@ -9,6 +10,7 @@ public interface IUserService Task GetByIdAsync(int id); Task UpdateProfileAsync(int id, UpdateUserRequest request); Task GetStatsAsync(int id); + Task> GetEnrollmentsAsync(int id, PaginationRequest pagination); Task> GetAllAsync(UserFilterRequest filter); Task SetRolesAsync(int id, IReadOnlyCollection roles); Task SetActiveAsync(int id, bool isActive); diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index 01d4f2f..663b006 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -20,12 +20,12 @@ public static class MappingExtensions ); public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new( - user.Id, user.Email, user.DisplayName, user.AvatarUrl, + user.Email, user.DisplayName, user.AvatarUrl, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt ); public static UserAuthDto ToAuthDto(this User user) => new( - user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() + user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() ); // --- Tag --- diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index c563642..1397569 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; @@ -65,6 +66,36 @@ public class UserService : IUserService ); } + public async Task> GetEnrollmentsAsync(int id, PaginationRequest pagination) + { + if (!await _db.Users.AnyAsync(u => u.Id == id)) + throw new NotFoundException("User", id); + + var query = _db.LectureEnrollments + .Where(e => e.UserId == id) + .Include(e => e.Lecture) + .ThenInclude(l => l.Course) + .Include(e => e.Lecture) + .ThenInclude(l => l.Teacher) + .Include(e => e.Lecture) + .ThenInclude(l => l.Location) + .Include(e => e.Lecture) + .ThenInclude(l => l.Enrollments); + + var total = await query.CountAsync(); + var enrollments = await query + .OrderBy(e => e.Lecture.StartsAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(); + + return PagedResult.Create( + enrollments.Select(e => e.Lecture.ToDto(isEnrolled: true)).ToList(), + total, + pagination.Page, + pagination.PageSize); + } + public async Task> GetAllAsync(UserFilterRequest filter) { var query = _db.Users.AsQueryable(); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 51fe591..b605b76 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -59,6 +59,32 @@ export const lecturesApi = { } export const usersApi = { + me: () => apiRequest('/users/me'), + updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) => + apiRequest('/users/me', { + method: 'PUT', + body: JSON.stringify(payload), + }), + myStats: () => apiRequest('/users/me/stats'), + async myEnrollments() { + const payload = await apiRequest | LectureDto[] | undefined>( + '/users/me/enrollments', + ) + return extractItems(payload) + }, + async myAchievements() { + const payload = await apiRequest< + PagedResult | UserAchievementDto[] | AchievementDto[] + >('/users/me/achievements') + if (Array.isArray(payload)) return payload + return payload.items ?? [] + }, + async myTransactions() { + const payload = await apiRequest | CoinTransactionDto[]>( + '/users/me/transactions', + ) + return extractItems(payload) + }, get: (id: string | number) => apiRequest(`/users/${id}`), async list(query: UserQuery = {}) { const payload = await apiRequest | UserDto[]>('/users', { diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 2f633f3..52b9d7f 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -33,7 +33,6 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole { export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User { const roles = mapApiRoles(user.roles) return { - id: String(user.id), name: user.displayName || user.email || 'Пользователь UniVerse', email: user.email || '', roles, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 20b5ec8..b907a8b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -29,13 +29,13 @@ export interface LoginMicrosoftRequest { } export interface UserAuthDto { - id: number email: string displayName?: string | null roles: ApiUserRole[] } export interface UserDto extends UserAuthDto { + id: number avatarUrl?: string | null isActive: boolean xp: number diff --git a/frontend/src/stores/lectures.ts b/frontend/src/stores/lectures.ts index 820bf91..805d21a 100644 --- a/frontend/src/stores/lectures.ts +++ b/frontend/src/stores/lectures.ts @@ -46,17 +46,17 @@ export const useLecturesStore = defineStore('lectures', () => { } } - async function fetchRegisteredForUser(userId: string) { + async function fetchRegisteredForCurrentUser() { try { - const enrollments = await usersApi.enrollments(userId) + const enrollments = await usersApi.myEnrollments() const mapped = enrollments.map(mapApiLecture) + registered.value = mapped.map(lecture => lecture.id) if (mapped.length) { mapped.forEach(lecture => { const index = lectures.value.findIndex(item => item.id === lecture.id) if (index >= 0) lectures.value[index] = { ...lectures.value[index], ...lecture, registered: true } else lectures.value.push({ ...lecture, registered: true }) }) - registered.value = mapped.map(lecture => lecture.id) } } catch { // Some backend builds return an empty 200 for this endpoint; catalog detail still carries isEnrolled. @@ -108,7 +108,7 @@ export const useLecturesStore = defineStore('lectures', () => { registeredLectures, fetchLectures, fetchLecture, - fetchRegisteredForUser, + fetchRegisteredForCurrentUser, fetchReviews, register, unregister, diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index f09b80b..5721ce0 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -12,18 +12,17 @@ export const useUserStore = defineStore('user', () => { const loading = ref(false) const error = ref(null) - async function fetchStudentData(userId?: string) { + async function fetchStudentData() { const auth = useAuthStore() - const id = userId ?? auth.user?.id - if (!id) return + if (!auth.user) return loading.value = true error.value = null try { const [stats, achievementPayload, transactions] = await Promise.all([ - usersApi.stats(id), - usersApi.achievements(id), - usersApi.transactions(id), + usersApi.myStats(), + usersApi.myAchievements(), + usersApi.myTransactions(), ]) const [achievementCatalog, notificationPayload] = await Promise.all([ achievementsApi.list(), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 79b0b9e..4e30f04 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,7 +1,6 @@ export type UserRole = 'student' | 'teacher' | 'admin' export interface User { - id: string name: string email: string roles: UserRole[] diff --git a/frontend/src/views/student/AchievementsView.vue b/frontend/src/views/student/AchievementsView.vue index 9f52d1d..d733108 100644 --- a/frontend/src/views/student/AchievementsView.vue +++ b/frontend/src/views/student/AchievementsView.vue @@ -11,7 +11,7 @@ const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked)) const locked = computed(() => userStore.achievements.filter(a => !a.unlocked)) onMounted(() => { - if (auth.user) void userStore.fetchStudentData(auth.user.id) + if (auth.user) void userStore.fetchStudentData() }) diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue index 996261d..471ba24 100644 --- a/frontend/src/views/student/DashboardView.vue +++ b/frontend/src/views/student/DashboardView.vue @@ -56,9 +56,9 @@ const levelProgressText = computed(() => onMounted(async () => { await Promise.all([ lectures.all.length ? Promise.resolve() : lectures.fetchLectures(), - userStore.fetchStudentData(user.value.id), + userStore.fetchStudentData(), ]) - await lectures.fetchRegisteredForUser(user.value.id) + await lectures.fetchRegisteredForCurrentUser() }) diff --git a/frontend/src/views/student/MyLecturesView.vue b/frontend/src/views/student/MyLecturesView.vue index e95d3f4..708baa6 100644 --- a/frontend/src/views/student/MyLecturesView.vue +++ b/frontend/src/views/student/MyLecturesView.vue @@ -23,7 +23,7 @@ const history = computed(() => lecturesStore.all.filter(l => l.status === 'compl onMounted(async () => { if (!lecturesStore.all.length) await lecturesStore.fetchLectures() - if (auth.user) await lecturesStore.fetchRegisteredForUser(auth.user.id) + if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser() }) function openCancel(id: string) { diff --git a/frontend/src/views/student/ProfileView.vue b/frontend/src/views/student/ProfileView.vue index dbf891c..a580777 100644 --- a/frontend/src/views/student/ProfileView.vue +++ b/frontend/src/views/student/ProfileView.vue @@ -53,7 +53,7 @@ const interestTags = ref([ const notificationSettings = ref({ email: true }) onMounted(() => { - void userStore.fetchStudentData(user.value.id) + void userStore.fetchStudentData() })