From 6824d7ce7dd517af70d0e917be5f17554821f50d Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Mon, 11 May 2026 21:29:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Helpers/ApiWebApplicationFactory.cs | 10 +- .../Controllers/AuthController.cs | 4 +- .../Controllers/UsersController.cs | 13 +- .../DTOs/Auth/AuthDtos.cs | 4 +- .../DTOs/Users/UserDtos.cs | 2 +- .../Interfaces/IAuthService.cs | 2 +- .../Interfaces/IUserService.cs | 2 +- .../Mappings/MappingExtensions.cs | 4 +- backend/UniVerse.Domain/Entities/User.cs | 2 +- .../Entities/UserRoleAssignment.cs | 11 + .../Data/AppDbContext.cs | 1 + .../Data/Configurations/UserConfiguration.cs | 6 +- .../UserRoleAssignmentConfiguration.cs | 17 + ...60511011508_UserRolesJoinTable.Designer.cs | 979 ++++++++++++++++++ .../20260511011508_UserRolesJoinTable.cs | 65 ++ .../Migrations/AppDbContextModelSnapshot.cs | 34 +- .../Services/AuthService.cs | 79 +- .../Services/UserService.cs | 56 +- frontend/src/api/index.ts | 4 +- frontend/src/api/mappers.ts | 16 +- frontend/src/api/types.ts | 2 +- .../src/components/layout/AppBottomNav.vue | 2 +- frontend/src/components/layout/AppSidebar.vue | 52 +- frontend/src/components/layout/AppTopbar.vue | 22 +- frontend/src/router/index.ts | 11 +- frontend/src/stores/auth.ts | 10 +- frontend/src/types/index.ts | 3 +- frontend/src/views/admin/AdminUsersView.vue | 30 +- frontend/src/views/auth/AuthCallbackView.vue | 2 +- 29 files changed, 1350 insertions(+), 95 deletions(-) create mode 100644 backend/UniVerse.Domain/Entities/UserRoleAssignment.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index 410c41a..b00666a 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -115,15 +115,15 @@ public class ApiWebApplicationFactory : WebApplicationFactory 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(1, "test@test.com", "Test User", [UserRole.Student])), "refresh_token"); stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(authResult); - stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(authResult); stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); stub.GetCurrentUserAsync(Arg.Any()) - .Returns(new UserDto(1, "test@test.com", "Test", null, UserRole.Student, true, 0, 0, 1, DateTime.UtcNow)); + .Returns(new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow)); return stub; } @@ -140,14 +140,14 @@ public class ApiWebApplicationFactory : WebApplicationFactory private static IUserService CreateUserServiceStub() { var stub = Substitute.For(); - var userDto = new UserDto(1, "test@test.com", "Test", null, UserRole.Student, true, 0, 0, 1, DateTime.UtcNow); + 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); 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)); stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); - stub.SetRoleAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); return stub; } diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index 0bea199..db9d462 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using UniVerse.Application.DTOs.Auth; using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; using System.Security.Cryptography; using System.Security.Claims; @@ -184,7 +185,8 @@ public class AuthController : ControllerBase { if (!HttpContext.RequestServices.GetRequiredService().IsDevelopment()) return NotFound(); - var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role, GetClientIpAddress()); + var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student]; + var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress()); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index a635e9e..0b08da8 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -137,23 +137,26 @@ public class UsersController : ControllerBase public async Task GetAll([FromQuery] UserFilterRequest filter) => Ok(await _users.GetAllAsync(filter)); - /// Изменить роль пользователя. + /// Изменить набор ролей пользователя. /// Только Admin. Доступные роли: Student, Teacher, Admin. /// ID пользователя. - /// Новая роль. - /// Роль успешно изменена. + /// Новый набор ролей пользователя. + /// Роли успешно изменены. /// Требуется аутентификация. /// Требуется роль Admin. /// Пользователь не найден. [Authorize(Roles = "Admin")] [HttpPatch("{id:int}/role")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task SetRole(int id, [FromBody] UserRole role) + public async Task SetRole(int id, [FromBody] IReadOnlyCollection roles) { - await _users.SetRoleAsync(id, role); + if (roles.Count == 0) + return BadRequest("At least one role is required."); + await _users.SetRolesAsync(id, roles); return NoContent(); } diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index efd97ed..e2519e3 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -5,8 +5,8 @@ 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, UserRole Role); +public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList Roles); public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null); -public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student); +public record DevLoginRequest(string Email, string? DisplayName = null, IReadOnlyList? Roles = null); diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 2a79aa9..f58bbb6 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -7,7 +7,7 @@ public record UserDto( string Email, string? DisplayName, string? AvatarUrl, - UserRole Role, + IReadOnlyList Roles, bool IsActive, int Xp, int Coins, diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index ee0d1d4..75b49cc 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -6,7 +6,7 @@ namespace UniVerse.Application.Interfaces; public interface IAuthService { Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null); - Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role, string? ipAddress = null); + Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); Task GetCurrentUserAsync(int userId); diff --git a/backend/UniVerse.Application/Interfaces/IUserService.cs b/backend/UniVerse.Application/Interfaces/IUserService.cs index 4625450..27985e8 100644 --- a/backend/UniVerse.Application/Interfaces/IUserService.cs +++ b/backend/UniVerse.Application/Interfaces/IUserService.cs @@ -10,6 +10,6 @@ public interface IUserService Task UpdateProfileAsync(int id, UpdateUserRequest request); Task GetStatsAsync(int id); Task> GetAllAsync(UserFilterRequest filter); - Task SetRoleAsync(int id, UserRole role); + 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 a332e41..a8b581b 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -16,11 +16,11 @@ public static class MappingExtensions // --- User --- public static UserDto ToDto(this User user, int level) => new( user.Id, user.Email, user.DisplayName, user.AvatarUrl, - user.Role, user.IsActive, user.Xp, user.Coins, level, user.CreatedAt + user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt ); public static UserAuthDto ToAuthDto(this User user) => new( - user.Id, user.Email, user.DisplayName, user.Role + user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() ); // --- Tag --- diff --git a/backend/UniVerse.Domain/Entities/User.cs b/backend/UniVerse.Domain/Entities/User.cs index d7893de..8bd1d3e 100644 --- a/backend/UniVerse.Domain/Entities/User.cs +++ b/backend/UniVerse.Domain/Entities/User.cs @@ -8,7 +8,6 @@ public class User public string Email { get; set; } = string.Empty; public string? DisplayName { get; set; } public string? AvatarUrl { get; set; } - public UserRole Role { get; set; } = UserRole.Student; public bool IsActive { get; set; } = true; public string? MicrosoftId { get; set; } public int Xp { get; set; } @@ -19,6 +18,7 @@ public class User // Navigation properties public StudentProfile? StudentProfile { get; set; } public TeacherProfile? TeacherProfile { get; set; } + public ICollection Roles { get; set; } = new List(); public ICollection Enrollments { get; set; } = new List(); public ICollection Reviews { get; set; } = new List(); public ICollection UserAchievements { get; set; } = new List(); diff --git a/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs b/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs new file mode 100644 index 0000000..57d361a --- /dev/null +++ b/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs @@ -0,0 +1,11 @@ +using UniVerse.Domain.Enums; + +namespace UniVerse.Domain.Entities; + +public class UserRoleAssignment +{ + public int UserId { get; set; } + public UserRole Role { get; set; } + + public User User { get; set; } = null!; +} diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs index 4280343..c5e9bba 100644 --- a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -10,6 +10,7 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } = null!; + public DbSet UserRoles { get; set; } = null!; public DbSet StudentProfiles { get; set; } = null!; public DbSet TeacherProfiles { get; set; } = null!; public DbSet Courses { get; set; } = null!; diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs index 9297d39..91e7b03 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs @@ -15,7 +15,6 @@ public class UserConfiguration : IEntityTypeConfiguration builder.Property(u => u.Email).HasColumnName("email").HasMaxLength(255).IsRequired(); builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(255); builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500); - builder.Property(u => u.Role).HasColumnName("role"); builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true); builder.Property(u => u.MicrosoftId).HasColumnName("microsoft_id").HasMaxLength(255); builder.Property(u => u.Xp).HasColumnName("xp").HasDefaultValue(0); @@ -25,5 +24,10 @@ public class UserConfiguration : IEntityTypeConfiguration builder.HasIndex(u => u.Email).IsUnique(); builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL"); + + builder.HasMany(u => u.Roles) + .WithOne(ur => ur.User) + .HasForeignKey(ur => ur.UserId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs new file mode 100644 index 0000000..f0872f7 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class UserRoleAssignmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_roles"); + + builder.HasKey(ur => new { ur.UserId, ur.Role }); + builder.Property(ur => ur.UserId).HasColumnName("user_id"); + builder.Property(ur => ur.Role).HasColumnName("role"); + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs new file mode 100644 index 0000000..5f6da46 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs @@ -0,0 +1,979 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260511011508_UserRolesJoinTable")] + partial class UserRolesJoinTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_llm_status", "review_llm_status", new[] { "pending", "analyzed", "rejected" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_sentiment", "review_sentiment", new[] { "positive", "neutral", "negative" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tag_type", "tag_type", new[] { "institute", "faculty", "subject", "organization", "topic", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", "user_role", new[] { "student", "teacher", "admin" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("XpReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp_reward"); + + b.HasKey("Id"); + + b.ToTable("achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementId"); + + b.HasIndex("ReviewId"); + + b.HasIndex("UserId"); + + b.ToTable("coin_transactions", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("courses", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("CourseId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("CourseId", "TagId") + .IsUnique(); + + b.ToTable("course_tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.HasIndex("LocationId"); + + b.HasIndex("StartsAt"); + + b.HasIndex("TeacherId"); + + b.ToTable("lectures", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("LectureId", "UserId"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("lecture_enrollments", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("Room") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("room"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("locations", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("LlmStatus"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("reviews", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("student_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("teacher_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Xp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("MicrosoftId") + .IsUnique() + .HasFilter("microsoft_id IS NOT NULL"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("UserId", "AchievementId"); + + b.HasIndex("AchievementId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("UserId", "Role"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany() + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.Review", "Review") + .WithMany("CoinTransactions") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("CoinTransactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("Review"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("CourseTags") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Tag", "Tag") + .WithMany("CourseTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("Lectures") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Location", "Location") + .WithMany("Lectures") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Course"); + + b.Navigation("Location"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Enrollments") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Reviews") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("StudentProfile") + .HasForeignKey("UniVerse.Domain.Entities.StudentProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.HasOne("UniVerse.Domain.Entities.Tag", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("TeacherProfile") + .HasForeignKey("UniVerse.Domain.Entities.TeacherProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany("UserAchievements") + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("UserAchievements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Navigation("UserAchievements"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Navigation("CourseTags"); + + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Navigation("CoinTransactions"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Navigation("Children"); + + b.Navigation("CourseTags"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Navigation("CoinTransactions"); + + b.Navigation("Enrollments"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs new file mode 100644 index 0000000..2016a04 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class UserRolesJoinTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new + { + user_id = table.Column(type: "integer", nullable: false), + role = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_roles", x => new { x.user_id, x.role }); + table.ForeignKey( + name: "FK_user_roles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql(""" + INSERT INTO user_roles (user_id, role) + SELECT id, role FROM users; + """); + + migrationBuilder.DropColumn( + name: "role", + table: "users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "role", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE users + SET role = COALESCE(( + SELECT MIN(ur.role) + FROM user_roles ur + WHERE ur.user_id = users.id + ), 0); + """); + + migrationBuilder.DropTable( + name: "user_roles"); + + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 91dae44..8b0a278 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); @@ -667,10 +667,6 @@ namespace UniVerse.Infrastructure.Migrations .HasColumnType("character varying(255)") .HasColumnName("microsoft_id"); - b.Property("Role") - .HasColumnType("integer") - .HasColumnName("role"); - b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -725,6 +721,21 @@ namespace UniVerse.Infrastructure.Migrations b.ToTable("user_achievements", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("UserId", "Role"); + + b.ToTable("user_roles", (string)null); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => { b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") @@ -894,6 +905,17 @@ namespace UniVerse.Infrastructure.Migrations b.Navigation("User"); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => { b.Navigation("UserAchievements"); @@ -940,6 +962,8 @@ namespace UniVerse.Infrastructure.Migrations b.Navigation("Reviews"); + b.Navigation("Roles"); + b.Navigation("StudentProfile"); b.Navigation("TeacherProfile"); diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 40bd25e..4b0aa58 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -85,20 +85,21 @@ public class AuthService : IAuthService throw new UnauthorizedException("Email не найден в токене Microsoft."); // Automatically provision user - var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { user = new User { Email = email, DisplayName = name ?? email.Split('@')[0], - Role = UserRole.Student, // Default role IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); - // Create corresponding profile + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); await _db.SaveChangesAsync(); } @@ -107,6 +108,13 @@ public class AuthService : IAuthService throw new ForbiddenException("Аккаунт деактивирован."); } + if (user.Roles.Count == 0) + { + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); + await EnsureProfilesForRolesAsync(user.Id, [UserRole.Student]); + await _db.SaveChangesAsync(); + } + var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); await TrySendLoginNotificationAsync(user, ipAddress); @@ -121,9 +129,12 @@ public class AuthService : IAuthService ); } - public async Task DevLoginAsync(string email, string? displayName, UserRole role, string? ipAddress = null) + public async Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null) { - var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + var normalizedRoles = (roles.Count > 0 ? roles : [UserRole.Student]).Distinct().ToList(); + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { @@ -131,25 +142,23 @@ public class AuthService : IAuthService { Email = email, DisplayName = displayName ?? email.Split('@')[0], - Role = role, IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); - - // Create profile based on role - if (role == UserRole.Student) - { - _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); - await _db.SaveChangesAsync(); - } - else if (role == UserRole.Teacher) - { - _db.TeacherProfiles.Add(new TeacherProfile { UserId = user.Id }); - await _db.SaveChangesAsync(); - } } + var existing = user.Roles.Select(r => r.Role).ToHashSet(); + var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); + foreach (var item in toRemove) + user.Roles.Remove(item); + + foreach (var role in normalizedRoles.Where(r => !existing.Contains(r))) + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = role }); + + await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); + await _db.SaveChangesAsync(); + if (!user.IsActive) throw new ForbiddenException("Аккаунт деактивирован."); @@ -171,6 +180,7 @@ public class AuthService : IAuthService { var token = await _db.RefreshTokens .Include(rt => rt.User) + .ThenInclude(u => u.Roles) .FirstOrDefaultAsync(rt => rt.Token == refreshToken); if (token == null || !token.IsActive) @@ -207,7 +217,9 @@ public class AuthService : IAuthService public async Task GetCurrentUserAsync(int userId) { - var user = await _db.Users.FindAsync(userId) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == userId) ?? throw new NotFoundException("User", userId); return user.ToDto(_gamification.CalculateLevel(user.Xp)); } @@ -245,13 +257,15 @@ public class AuthService : IAuthService Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var claims = new[] + var claims = new List { - new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new Claim(JwtRegisteredClaimNames.Email, user.Email), - new Claim(ClaimTypes.Role, user.Role.ToString()), - new Claim("display_name", user.DisplayName ?? "") + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email), + new("display_name", user.DisplayName ?? "") }; + var roles = user.Roles.Select(r => r.Role).Distinct().ToList(); + if (roles.Count == 0) roles.Add(UserRole.Student); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], @@ -264,6 +278,23 @@ public class AuthService : IAuthService return new JwtSecurityTokenHandler().WriteToken(token); } + private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) + { + if (roles.Contains(UserRole.Student)) + { + var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); + if (!hasStudentProfile) + _db.StudentProfiles.Add(new StudentProfile { UserId = userId }); + } + + if (roles.Contains(UserRole.Teacher)) + { + var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); + if (!hasTeacherProfile) + _db.TeacherProfiles.Add(new TeacherProfile { UserId = userId }); + } + } + private async Task GenerateRefreshTokenAsync(int userId) { var randomBytes = RandomNumberGenerator.GetBytes(64); diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 5e691f3..e80847a 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -22,14 +22,18 @@ public class UserService : IUserService public async Task GetByIdAsync(int id) { - var user = await _db.Users.FindAsync(id) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); return user.ToDto(_gamification.CalculateLevel(user.Xp)); } public async Task UpdateProfileAsync(int id, UpdateUserRequest request) { - var user = await _db.Users.FindAsync(id) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); if (request.DisplayName != null) user.DisplayName = request.DisplayName; @@ -68,8 +72,15 @@ public class UserService : IUserService (u.DisplayName != null && u.DisplayName.ToLower().Contains(search))); } + query = query.Include(u => u.Roles); + if (filter.Role.HasValue) - query = query.Where(u => u.Role == filter.Role.Value); + { + var role = filter.Role.Value; + query = query.Where(u => + u.Roles.Count == 1 && + u.Roles.Any(ur => ur.Role == role)); + } if (filter.IsActive.HasValue) query = query.Where(u => u.IsActive == filter.IsActive.Value); @@ -86,11 +97,27 @@ public class UserService : IUserService return PagedResult.Create(items, total, filter.Page, filter.PageSize); } - public async Task SetRoleAsync(int id, UserRole role) + public async Task SetRolesAsync(int id, IReadOnlyCollection roles) { - var user = await _db.Users.FindAsync(id) + var normalizedRoles = roles.Distinct().ToList(); + if (normalizedRoles.Count == 0) + throw new ForbiddenException("At least one role is required."); + + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); - user.Role = role; + + var existing = user.Roles.Select(r => r.Role).ToHashSet(); + var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); + foreach (var item in toRemove) + user.Roles.Remove(item); + + var toAdd = normalizedRoles.Where(r => !existing.Contains(r)).ToList(); + foreach (var role in toAdd) + user.Roles.Add(new Domain.Entities.UserRoleAssignment { UserId = user.Id, Role = role }); + + await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } @@ -103,4 +130,21 @@ public class UserService : IUserService user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } + + private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) + { + if (roles.Contains(UserRole.Student)) + { + var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); + if (!hasStudentProfile) + _db.StudentProfiles.Add(new Domain.Entities.StudentProfile { UserId = userId }); + } + + if (roles.Contains(UserRole.Teacher)) + { + var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); + if (!hasTeacherProfile) + _db.TeacherProfiles.Add(new Domain.Entities.TeacherProfile { UserId = userId }); + } + } } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d7230f8..19fdc3c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -70,8 +70,8 @@ export const usersApi = { ) return extractItems(payload) }, - setRole: (id: string | number, role: 'Student' | 'Teacher' | 'Admin') => - apiRequest(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(role) }), + setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) => + apiRequest(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(roles) }), setActive: (id: string | number, isActive: boolean) => apiRequest(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }), } diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 119ef3c..9716500 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -16,12 +16,26 @@ export function mapApiRole(role: string | undefined): UserRole { return 'student' } +function mapApiRoles(roles: string[] | undefined): UserRole[] { + if (!roles?.length) return ['student'] + return Array.from(new Set(roles.map(mapApiRole))) +} + +function getDefaultActiveRole(roles: UserRole[]): UserRole { + if (roles.includes('student')) return 'student' + if (roles.includes('teacher')) return 'teacher' + if (roles.includes('admin')) return 'admin' + return 'student' +} + export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User { + const roles = mapApiRoles(user.roles) return { id: String(user.id), name: user.displayName || user.email || 'Пользователь UniVerse', email: user.email || '', - role: mapApiRole(user.role), + roles, + activeRole: getDefaultActiveRole(roles), avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined, institute: 'ЮФУ', department: '', diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ea6f23c..40083d8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -32,7 +32,7 @@ export interface UserAuthDto { id: number email: string displayName?: string | null - role: ApiUserRole + roles: ApiUserRole[] } export interface UserDto extends UserAuthDto { diff --git a/frontend/src/components/layout/AppBottomNav.vue b/frontend/src/components/layout/AppBottomNav.vue index f459b90..2dd3153 100644 --- a/frontend/src/components/layout/AppBottomNav.vue +++ b/frontend/src/components/layout/AppBottomNav.vue @@ -8,7 +8,7 @@ const auth = useAuthStore() const route = useRoute() const navItems = computed(() => { - const role = auth.user?.role ?? 'student' + const role = auth.user?.activeRole ?? 'student' if (role === 'teacher') return [ { label: 'Дашборд', icon: 'chart-bar', to: '/teacher' }, { label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' }, diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 1a46111..7a3feb9 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -9,6 +9,7 @@ const router = useRouter() const route = useRoute() interface NavItem { label: string; icon: string; to: string; roles: string[] } +type AppRole = 'student' | 'teacher' | 'admin' const navItems: NavItem[] = [ { label: 'Главная', icon: 'home', to: '/', roles: ['student'] }, @@ -30,9 +31,32 @@ const navItems: NavItem[] = [ ] const visible = computed(() => - navItems.filter(n => auth.user && n.roles.includes(auth.user.role)) + navItems.filter(n => auth.user && n.roles.includes(auth.user.activeRole)) ) +const roleButtons = computed(() => { + if (!auth.user) return [] + const labels: Record = { + student: 'Студент', + teacher: 'Преподаватель', + admin: 'Администратор', + } + const targets: Record = { + student: '/', + teacher: '/teacher', + admin: '/admin', + } + return auth.user.roles + .filter(role => role !== auth.user?.activeRole) + .map(role => ({ role, label: labels[role], to: targets[role] })) +}) + +function switchToRole(role: AppRole, to: string) { + if (auth.setActiveRole(role)) { + router.push(to) + } +} + function isActive(to: string) { if (to === '/') return route.path === '/' return route.path.startsWith(to) && to !== '/' @@ -55,6 +79,16 @@ function isActive(to: string) {