From df0e30a1ae9d5420f76c9604774e3f926574ba28 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Tue, 28 Apr 2026 15:52:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B9=20Infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/AppDbContext.cs | 62 ++ .../AchievementConfiguration.cs | 23 + .../CoinTransactionConfiguration.cs | 40 + .../Configurations/CourseConfiguration.cs | 24 + .../Configurations/CourseTagConfiguration.cs | 30 + .../Configurations/LectureConfiguration.cs | 49 + .../LectureEnrollmentConfiguration.cs | 32 + .../Configurations/LocationConfiguration.cs | 24 + .../RefreshTokenConfiguration.cs | 34 + .../Configurations/ReviewConfiguration.cs | 40 + .../StudentProfileConfiguration.cs | 29 + .../Data/Configurations/TagConfiguration.cs | 25 + .../TeacherProfileConfiguration.cs | 28 + .../UserAchievementConfiguration.cs | 31 + .../Data/Configurations/UserConfiguration.cs | 29 + .../20260428124938_Initial.Designer.cs | 955 ++++++++++++++++++ .../Data/Migrations/20260428124938_Initial.cs | 578 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 952 +++++++++++++++++ .../ExternalServices/LlmClient.cs | 59 ++ .../ExternalServices/ModeusApiClient.cs | 43 + .../Services/AchievementService.cs | 52 + .../Services/AuthService.cs | 173 ++++ .../Services/CourseService.cs | 82 ++ .../Services/GamificationService.cs | 93 ++ .../Services/LectureService.cs | 128 +++ .../Services/LlmAnalysisService.cs | 66 ++ .../Services/LocationService.cs | 64 ++ .../Services/ReviewService.cs | 98 ++ .../Services/ScheduleSyncService.cs | 75 ++ .../Services/TagService.cs | 95 ++ .../Services/UserService.cs | 106 ++ .../UniVerse.Infrastructure.csproj | 20 + 32 files changed, 4139 insertions(+) create mode 100644 backend/UniVerse.Infrastructure/Data/AppDbContext.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/AchievementConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/CoinTransactionConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/CourseConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/CourseTagConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/LectureEnrollmentConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/LocationConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/StudentProfileConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/TagConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/UserAchievementConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs create mode 100644 backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs create mode 100644 backend/UniVerse.Infrastructure/Services/AchievementService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/AuthService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/CourseService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/GamificationService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/LectureService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/LocationService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/ReviewService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/TagService.cs create mode 100644 backend/UniVerse.Infrastructure/Services/UserService.cs create mode 100644 backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs new file mode 100644 index 0000000..4280343 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; + +namespace UniVerse.Infrastructure.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users { get; set; } = null!; + public DbSet StudentProfiles { get; set; } = null!; + public DbSet TeacherProfiles { get; set; } = null!; + public DbSet Courses { get; set; } = null!; + public DbSet Lectures { get; set; } = null!; + public DbSet Locations { get; set; } = null!; + public DbSet Tags { get; set; } = null!; + public DbSet CourseTags { get; set; } = null!; + public DbSet LectureEnrollments { get; set; } = null!; + public DbSet Reviews { get; set; } = null!; + public DbSet Achievements { get; set; } = null!; + public DbSet UserAchievements { get; set; } = null!; + public DbSet CoinTransactions { get; set; } = null!; + public DbSet RefreshTokens { get; set; } = null!; + + static AppDbContext() + { + NpgsqlConnection.GlobalTypeMapper.MapEnum("user_role"); + NpgsqlConnection.GlobalTypeMapper.MapEnum("tag_type"); + NpgsqlConnection.GlobalTypeMapper.MapEnum("review_llm_status"); + NpgsqlConnection.GlobalTypeMapper.MapEnum("review_sentiment"); + NpgsqlConnection.GlobalTypeMapper.MapEnum("coin_transaction_type"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasPostgresEnum("user_role"); + modelBuilder.HasPostgresEnum("tag_type"); + modelBuilder.HasPostgresEnum("review_llm_status"); + modelBuilder.HasPostgresEnum("review_sentiment"); + modelBuilder.HasPostgresEnum("coin_transaction_type"); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + + // Basic PK setups if they weren't fully in assembly + modelBuilder.Entity().HasKey(ct => new { ct.CourseId, ct.TagId }); + modelBuilder.Entity().HasKey(le => new { le.LectureId, le.UserId }); + modelBuilder.Entity().HasKey(ua => new { ua.UserId, ua.AchievementId }); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + entity.SetTableName(entity.GetTableName()?.ToLowerInvariant()); + foreach (var property in entity.GetProperties()) + { + property.SetColumnName(string.Concat(property.Name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLowerInvariant()); + } + } + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/AchievementConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/AchievementConfiguration.cs new file mode 100644 index 0000000..47522e8 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/AchievementConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class AchievementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("achievements"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).HasColumnName("id"); + builder.Property(a => a.Name).HasColumnName("name").HasMaxLength(255).IsRequired(); + builder.Property(a => a.Description).HasColumnName("description"); + builder.Property(a => a.IconUrl).HasColumnName("icon_url").HasMaxLength(500); + builder.Property(a => a.XpReward).HasColumnName("xp_reward").HasDefaultValue(0); + builder.Property(a => a.CoinReward).HasColumnName("coin_reward").HasDefaultValue(0); + builder.Property(a => a.Condition).HasColumnName("condition"); + builder.Property(a => a.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/CoinTransactionConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/CoinTransactionConfiguration.cs new file mode 100644 index 0000000..fc6e659 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/CoinTransactionConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class CoinTransactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("coin_transactions"); + + builder.HasKey(ct => ct.Id); + builder.Property(ct => ct.Id).HasColumnName("id"); + builder.Property(ct => ct.UserId).HasColumnName("user_id"); + builder.Property(ct => ct.Amount).HasColumnName("amount"); + builder.Property(ct => ct.Type).HasColumnName("type"); + builder.Property(ct => ct.ReviewId).HasColumnName("review_id"); + builder.Property(ct => ct.AchievementId).HasColumnName("achievement_id"); + builder.Property(ct => ct.Description).HasColumnName("description").HasMaxLength(500); + builder.Property(ct => ct.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(ct => ct.User) + .WithMany(u => u.CoinTransactions) + .HasForeignKey(ct => ct.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(ct => ct.Review) + .WithMany(r => r.CoinTransactions) + .HasForeignKey(ct => ct.ReviewId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(ct => ct.Achievement) + .WithMany() + .HasForeignKey(ct => ct.AchievementId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(ct => ct.UserId); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/CourseConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/CourseConfiguration.cs new file mode 100644 index 0000000..5d5bc3e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/CourseConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class CourseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("courses"); + + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasColumnName("id"); + builder.Property(c => c.Name).HasColumnName("name").HasMaxLength(500).IsRequired(); + builder.Property(c => c.Description).HasColumnName("description"); + builder.Property(c => c.ExternalId).HasColumnName("external_id").HasMaxLength(255); + builder.Property(c => c.IsSynced).HasColumnName("is_synced").HasDefaultValue(false); + builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(c => c.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(c => c.ExternalId).IsUnique().HasFilter("external_id IS NOT NULL"); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/CourseTagConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/CourseTagConfiguration.cs new file mode 100644 index 0000000..25de7b5 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/CourseTagConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class CourseTagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("course_tags"); + + builder.HasKey(ct => ct.Id); + builder.Property(ct => ct.Id).HasColumnName("id"); + builder.Property(ct => ct.CourseId).HasColumnName("course_id"); + builder.Property(ct => ct.TagId).HasColumnName("tag_id"); + + builder.HasOne(ct => ct.Course) + .WithMany(c => c.CourseTags) + .HasForeignKey(ct => ct.CourseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(ct => ct.Tag) + .WithMany(t => t.CourseTags) + .HasForeignKey(ct => ct.TagId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(ct => new { ct.CourseId, ct.TagId }).IsUnique(); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs new file mode 100644 index 0000000..f76d4c6 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class LectureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lectures"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).HasColumnName("id"); + builder.Property(l => l.CourseId).HasColumnName("course_id"); + builder.Property(l => l.TeacherId).HasColumnName("teacher_id"); + builder.Property(l => l.LocationId).HasColumnName("location_id"); + builder.Property(l => l.Title).HasColumnName("title").HasMaxLength(500).IsRequired(); + builder.Property(l => l.Description).HasColumnName("description"); + builder.Property(l => l.Format).HasColumnName("format"); + builder.Property(l => l.StartsAt).HasColumnName("starts_at"); + builder.Property(l => l.EndsAt).HasColumnName("ends_at"); + builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true); + builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0); + builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255); + builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500); + builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(l => l.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(l => l.Course) + .WithMany(c => c.Lectures) + .HasForeignKey(l => l.CourseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Teacher) + .WithMany() + .HasForeignKey(l => l.TeacherId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(l => l.Location) + .WithMany(loc => loc.Lectures) + .HasForeignKey(l => l.LocationId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(l => l.ExternalId).IsUnique().HasFilter("external_id IS NOT NULL"); + builder.HasIndex(l => l.StartsAt); + builder.HasIndex(l => l.CourseId); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LectureEnrollmentConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LectureEnrollmentConfiguration.cs new file mode 100644 index 0000000..a34cd33 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LectureEnrollmentConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class LectureEnrollmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lecture_enrollments"); + + builder.HasKey(le => le.Id); + builder.Property(le => le.Id).HasColumnName("id"); + builder.Property(le => le.LectureId).HasColumnName("lecture_id"); + builder.Property(le => le.UserId).HasColumnName("user_id"); + builder.Property(le => le.Attended).HasColumnName("attended").HasDefaultValue(false); + builder.Property(le => le.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(le => le.Lecture) + .WithMany(l => l.Enrollments) + .HasForeignKey(le => le.LectureId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(le => le.User) + .WithMany(u => u.Enrollments) + .HasForeignKey(le => le.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(le => new { le.LectureId, le.UserId }).IsUnique(); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LocationConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LocationConfiguration.cs new file mode 100644 index 0000000..9f9b14d --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LocationConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class LocationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("locations"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).HasColumnName("id"); + builder.Property(l => l.Name).HasColumnName("name").HasMaxLength(255).IsRequired(); + builder.Property(l => l.Building).HasColumnName("building").HasMaxLength(255); + builder.Property(l => l.Room).HasColumnName("room").HasMaxLength(100); + builder.Property(l => l.Address).HasColumnName("address").HasMaxLength(500); + builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255); + builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(l => l.ExternalId).IsUnique().HasFilter("external_id IS NOT NULL"); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..1b86aac --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("refresh_tokens"); + + builder.HasKey(rt => rt.Id); + builder.Property(rt => rt.Id).HasColumnName("id"); + builder.Property(rt => rt.UserId).HasColumnName("user_id"); + builder.Property(rt => rt.Token).HasColumnName("token").HasMaxLength(500).IsRequired(); + builder.Property(rt => rt.ExpiresAt).HasColumnName("expires_at"); + builder.Property(rt => rt.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(rt => rt.RevokedAt).HasColumnName("revoked_at"); + + builder.HasOne(rt => rt.User) + .WithMany(u => u.RefreshTokens) + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(rt => rt.Token).IsUnique(); + builder.HasIndex(rt => rt.UserId); + + // Computed properties should be ignored by EF + builder.Ignore(rt => rt.IsExpired); + builder.Ignore(rt => rt.IsRevoked); + builder.Ignore(rt => rt.IsActive); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs new file mode 100644 index 0000000..d3d7c8e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class ReviewConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reviews"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id"); + builder.Property(r => r.LectureId).HasColumnName("lecture_id"); + builder.Property(r => r.UserId).HasColumnName("user_id"); + builder.Property(r => r.Rating).HasColumnName("rating"); + builder.Property(r => r.Text).HasColumnName("text"); + builder.Property(r => r.LlmStatus).HasColumnName("llm_status").HasDefaultValue(Domain.Enums.ReviewLlmStatus.Pending); + builder.Property(r => r.Sentiment).HasColumnName("sentiment"); + builder.Property(r => r.QualityScore).HasColumnName("quality_score"); + builder.Property(r => r.IsInformative).HasColumnName("is_informative"); + builder.Property(r => r.LlmTags).HasColumnName("llm_tags"); + builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(r => r.Lecture) + .WithMany(l => l.Reviews) + .HasForeignKey(r => r.LectureId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(r => r.User) + .WithMany(u => u.Reviews) + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(r => new { r.LectureId, r.UserId }).IsUnique(); + builder.HasIndex(r => r.LlmStatus); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/StudentProfileConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/StudentProfileConfiguration.cs new file mode 100644 index 0000000..6cbec64 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/StudentProfileConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class StudentProfileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("student_profiles"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + builder.Property(s => s.UserId).HasColumnName("user_id"); + builder.Property(s => s.StudentId).HasColumnName("student_id").HasMaxLength(50); + builder.Property(s => s.GroupName).HasColumnName("group_name").HasMaxLength(100); + builder.Property(s => s.EnrollmentYear).HasColumnName("enrollment_year"); + builder.Property(s => s.Faculty).HasColumnName("faculty").HasMaxLength(255); + builder.Property(s => s.Specialty).HasColumnName("specialty").HasMaxLength(255); + + builder.HasOne(s => s.User) + .WithOne(u => u.StudentProfile) + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.UserId).IsUnique(); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/TagConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/TagConfiguration.cs new file mode 100644 index 0000000..5ead64e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/TagConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class TagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tags"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + builder.Property(t => t.Name).HasColumnName("name").HasMaxLength(255).IsRequired(); + builder.Property(t => t.Type).HasColumnName("type"); + builder.Property(t => t.ParentId).HasColumnName("parent_id"); + builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(t => t.Parent) + .WithMany(t => t.Children) + .HasForeignKey(t => t.ParentId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs new file mode 100644 index 0000000..c450fce --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class TeacherProfileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("teacher_profiles"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + builder.Property(t => t.UserId).HasColumnName("user_id"); + builder.Property(t => t.Department).HasColumnName("department").HasMaxLength(255); + builder.Property(t => t.Title).HasColumnName("title").HasMaxLength(255); + builder.Property(t => t.Bio).HasColumnName("bio"); + builder.Property(t => t.ModeusId).HasColumnName("modeus_id").HasMaxLength(255); + + builder.HasOne(t => t.User) + .WithOne(u => u.TeacherProfile) + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(t => t.UserId).IsUnique(); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserAchievementConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserAchievementConfiguration.cs new file mode 100644 index 0000000..a0f9b5d --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserAchievementConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class UserAchievementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_achievements"); + + builder.HasKey(ua => ua.Id); + builder.Property(ua => ua.Id).HasColumnName("id"); + builder.Property(ua => ua.UserId).HasColumnName("user_id"); + builder.Property(ua => ua.AchievementId).HasColumnName("achievement_id"); + builder.Property(ua => ua.AwardedAt).HasColumnName("awarded_at").HasDefaultValueSql("NOW()"); + + builder.HasOne(ua => ua.User) + .WithMany(u => u.UserAchievements) + .HasForeignKey(ua => ua.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(ua => ua.Achievement) + .WithMany(a => a.UserAchievements) + .HasForeignKey(ua => ua.AchievementId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(ua => new { ua.UserId, ua.AchievementId }).IsUnique(); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..9297d39 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(u => u.Id); + builder.Property(u => u.Id).HasColumnName("id"); + 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); + builder.Property(u => u.Coins).HasColumnName("coins").HasDefaultValue(0); + builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(u => u.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(u => u.Email).IsUnique(); + builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL"); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs b/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs new file mode 100644 index 0000000..358add9 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs @@ -0,0 +1,955 @@ +// +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.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260428124938_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .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("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + 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.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.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("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs b/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs new file mode 100644 index 0000000..d6c7761 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs @@ -0,0 +1,578 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace UniVerse.Infrastructure.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:coin_transaction_type.coin_transaction_type", "review_reward,achievement_reward,attendance_reward,admin_adjustment") + .Annotation("Npgsql:Enum:review_llm_status.review_llm_status", "pending,analyzed,rejected") + .Annotation("Npgsql:Enum:review_sentiment.review_sentiment", "positive,neutral,negative") + .Annotation("Npgsql:Enum:tag_type.tag_type", "institute,faculty,subject,organization,topic,other") + .Annotation("Npgsql:Enum:user_role.user_role", "student,teacher,admin"); + + migrationBuilder.CreateTable( + name: "achievements", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + description = table.Column(type: "text", nullable: true), + icon_url = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + xp_reward = table.Column(type: "integer", nullable: false, defaultValue: 0), + coin_reward = table.Column(type: "integer", nullable: false, defaultValue: 0), + condition = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_achievements", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "courses", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + description = table.Column(type: "text", nullable: true), + external_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + is_synced = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_courses", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "locations", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + building = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + room = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + address = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + external_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_locations", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "tags", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + type = table.Column(type: "integer", nullable: false), + parent_id = table.Column(type: "integer", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_tags", x => x.id); + table.ForeignKey( + name: "FK_tags_tags_parent_id", + column: x => x.parent_id, + principalTable: "tags", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + display_name = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + avatar_url = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + role = table.Column(type: "integer", nullable: false), + is_active = table.Column(type: "boolean", nullable: false, defaultValue: true), + microsoft_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + xp = table.Column(type: "integer", nullable: false, defaultValue: 0), + coins = table.Column(type: "integer", nullable: false, defaultValue: 0), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "course_tags", + columns: table => new + { + course_id = table.Column(type: "integer", nullable: false), + tag_id = table.Column(type: "integer", nullable: false), + id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_course_tags", x => new { x.course_id, x.tag_id }); + table.ForeignKey( + name: "FK_course_tags_courses_course_id", + column: x => x.course_id, + principalTable: "courses", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_course_tags_tags_tag_id", + column: x => x.tag_id, + principalTable: "tags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lectures", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + course_id = table.Column(type: "integer", nullable: false), + teacher_id = table.Column(type: "integer", nullable: true), + location_id = table.Column(type: "integer", nullable: true), + title = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + description = table.Column(type: "text", nullable: true), + format = table.Column(type: "integer", nullable: false), + starts_at = table.Column(type: "timestamp with time zone", nullable: false), + ends_at = table.Column(type: "timestamp with time zone", nullable: false), + is_open = table.Column(type: "boolean", nullable: false, defaultValue: true), + max_enrollments = table.Column(type: "integer", nullable: false, defaultValue: 0), + external_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + online_url = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_lectures", x => x.id); + table.ForeignKey( + name: "FK_lectures_courses_course_id", + column: x => x.course_id, + principalTable: "courses", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_lectures_locations_location_id", + column: x => x.location_id, + principalTable: "locations", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_lectures_users_teacher_id", + column: x => x.teacher_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_tokens", x => x.id); + table.ForeignKey( + name: "FK_refresh_tokens_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "student_profiles", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + student_id = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + group_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + enrollment_year = table.Column(type: "integer", nullable: true), + faculty = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + specialty = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_student_profiles", x => x.id); + table.ForeignKey( + name: "FK_student_profiles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "teacher_profiles", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + department = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + title = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + bio = table.Column(type: "text", nullable: true), + modeus_id = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_teacher_profiles", x => x.id); + table.ForeignKey( + name: "FK_teacher_profiles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_achievements", + columns: table => new + { + user_id = table.Column(type: "integer", nullable: false), + achievement_id = table.Column(type: "integer", nullable: false), + id = table.Column(type: "integer", nullable: false), + awarded_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_user_achievements", x => new { x.user_id, x.achievement_id }); + table.ForeignKey( + name: "FK_user_achievements_achievements_achievement_id", + column: x => x.achievement_id, + principalTable: "achievements", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_user_achievements_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lecture_enrollments", + columns: table => new + { + lecture_id = table.Column(type: "integer", nullable: false), + user_id = table.Column(type: "integer", nullable: false), + id = table.Column(type: "integer", nullable: false), + attended = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_lecture_enrollments", x => new { x.lecture_id, x.user_id }); + table.ForeignKey( + name: "FK_lecture_enrollments_lectures_lecture_id", + column: x => x.lecture_id, + principalTable: "lectures", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_lecture_enrollments_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "reviews", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + lecture_id = table.Column(type: "integer", nullable: false), + user_id = table.Column(type: "integer", nullable: false), + rating = table.Column(type: "integer", nullable: false), + text = table.Column(type: "text", nullable: true), + llm_status = table.Column(type: "integer", nullable: false, defaultValue: 0), + sentiment = table.Column(type: "integer", nullable: true), + quality_score = table.Column(type: "double precision", nullable: true), + is_informative = table.Column(type: "boolean", nullable: true), + llm_tags = table.Column(type: "text[]", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_reviews", x => x.id); + table.ForeignKey( + name: "FK_reviews_lectures_lecture_id", + column: x => x.lecture_id, + principalTable: "lectures", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_reviews_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "coin_transactions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + amount = table.Column(type: "integer", nullable: false), + type = table.Column(type: "integer", nullable: false), + review_id = table.Column(type: "integer", nullable: true), + achievement_id = table.Column(type: "integer", nullable: true), + description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_coin_transactions", x => x.id); + table.ForeignKey( + name: "FK_coin_transactions_achievements_achievement_id", + column: x => x.achievement_id, + principalTable: "achievements", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_coin_transactions_reviews_review_id", + column: x => x.review_id, + principalTable: "reviews", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_coin_transactions_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_coin_transactions_achievement_id", + table: "coin_transactions", + column: "achievement_id"); + + migrationBuilder.CreateIndex( + name: "IX_coin_transactions_review_id", + table: "coin_transactions", + column: "review_id"); + + migrationBuilder.CreateIndex( + name: "IX_coin_transactions_user_id", + table: "coin_transactions", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_course_tags_course_id_tag_id", + table: "course_tags", + columns: new[] { "course_id", "tag_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_course_tags_tag_id", + table: "course_tags", + column: "tag_id"); + + migrationBuilder.CreateIndex( + name: "IX_courses_external_id", + table: "courses", + column: "external_id", + unique: true, + filter: "external_id IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_lecture_enrollments_lecture_id_user_id", + table: "lecture_enrollments", + columns: new[] { "lecture_id", "user_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_lecture_enrollments_user_id", + table: "lecture_enrollments", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_lectures_course_id", + table: "lectures", + column: "course_id"); + + migrationBuilder.CreateIndex( + name: "IX_lectures_external_id", + table: "lectures", + column: "external_id", + unique: true, + filter: "external_id IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_lectures_location_id", + table: "lectures", + column: "location_id"); + + migrationBuilder.CreateIndex( + name: "IX_lectures_starts_at", + table: "lectures", + column: "starts_at"); + + migrationBuilder.CreateIndex( + name: "IX_lectures_teacher_id", + table: "lectures", + column: "teacher_id"); + + migrationBuilder.CreateIndex( + name: "IX_locations_external_id", + table: "locations", + column: "external_id", + unique: true, + filter: "external_id IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_token", + table: "refresh_tokens", + column: "token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_user_id", + table: "refresh_tokens", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_reviews_lecture_id_user_id", + table: "reviews", + columns: new[] { "lecture_id", "user_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_reviews_llm_status", + table: "reviews", + column: "llm_status"); + + migrationBuilder.CreateIndex( + name: "IX_reviews_user_id", + table: "reviews", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_student_profiles_user_id", + table: "student_profiles", + column: "user_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_tags_parent_id", + table: "tags", + column: "parent_id"); + + migrationBuilder.CreateIndex( + name: "IX_teacher_profiles_user_id", + table: "teacher_profiles", + column: "user_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_achievements_achievement_id", + table: "user_achievements", + column: "achievement_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_achievements_user_id_achievement_id", + table: "user_achievements", + columns: new[] { "user_id", "achievement_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_email", + table: "users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_microsoft_id", + table: "users", + column: "microsoft_id", + unique: true, + filter: "microsoft_id IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "coin_transactions"); + + migrationBuilder.DropTable( + name: "course_tags"); + + migrationBuilder.DropTable( + name: "lecture_enrollments"); + + migrationBuilder.DropTable( + name: "refresh_tokens"); + + migrationBuilder.DropTable( + name: "student_profiles"); + + migrationBuilder.DropTable( + name: "teacher_profiles"); + + migrationBuilder.DropTable( + name: "user_achievements"); + + migrationBuilder.DropTable( + name: "reviews"); + + migrationBuilder.DropTable( + name: "tags"); + + migrationBuilder.DropTable( + name: "achievements"); + + migrationBuilder.DropTable( + name: "lectures"); + + migrationBuilder.DropTable( + name: "courses"); + + migrationBuilder.DropTable( + name: "locations"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..45112aa --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,952 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .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("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + 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.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.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("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs new file mode 100644 index 0000000..903dc80 --- /dev/null +++ b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs @@ -0,0 +1,59 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Infrastructure.ExternalServices; + +public class LlmClient : ILlmClient +{ + private readonly HttpClient _http; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public LlmClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; _config = config; _logger = logger; + } + + public async Task AnalyzeReviewAsync(string reviewText, string lectureContext) + { + var prompt = $""" + Analyze the following student review of a lecture. Return a JSON object with: + - quality_score: float 0-1 indicating review quality + - sentiment: "Positive", "Neutral", or "Negative" + - tags: array of relevant topic tags + - is_informative: boolean indicating if the review is informative + + Lecture context: {lectureContext} + Review text: {reviewText} + """; + + var request = new + { + model = _config["Llm:Model"] ?? "gpt-4o-mini", + messages = new[] { new { role = "user", content = prompt } }, + temperature = 0.3, + response_format = new { type = "json_object" } + }; + + var apiKey = _config["Llm:ApiKey"] ?? ""; + _http.DefaultRequestHeaders.Clear(); + if (!string.IsNullOrEmpty(apiKey)) + _http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); + + var response = await _http.PostAsJsonAsync("chat/completions", request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!; + var analysis = JsonSerializer.Deserialize(content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + return new LlmReviewAnalysis(analysis.QualityScore, analysis.Sentiment, analysis.Tags, analysis.IsInformative); + } + + private record LlmRawResponse(double QualityScore, string Sentiment, string[] Tags, bool IsInformative); +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs new file mode 100644 index 0000000..b1e2e24 --- /dev/null +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -0,0 +1,43 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using UniVerse.Application.DTOs.Sync; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Infrastructure.ExternalServices; + +public class ModeusApiClient : IModeusApiClient +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + + public ModeusApiClient(HttpClient http, IConfiguration config, ILogger logger) + { + _http = http; _logger = logger; + var apiKey = config["ModeusApi:ApiKey"]; + if (!string.IsNullOrEmpty(apiKey)) + _http.DefaultRequestHeaders.Add("X-API-Key", apiKey); + } + + public async Task SearchEventsAsync(SyncScheduleRequest request) + { + var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId }; + var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync() ?? new(new()); + } + + public async Task SearchRoomsAsync() + { + var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { }); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync() ?? new(new()); + } + + public async Task> SearchEmployeeAsync(string fullname) + { + var response = await _http.GetFromJsonAsync>( + $"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}"); + return response ?? new(); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/AchievementService.cs b/backend/UniVerse.Infrastructure/Services/AchievementService.cs new file mode 100644 index 0000000..12505d0 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/AchievementService.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Achievements; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class AchievementService : IAchievementService +{ + private readonly AppDbContext _db; + public AchievementService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync() => + await _db.Achievements.OrderBy(a => a.Name).Select(a => a.ToDto()).ToListAsync(); + + public async Task GetByIdAsync(int id) + { + var a = await _db.Achievements.FindAsync(id) ?? throw new NotFoundException("Achievement", id); + return a.ToDto(); + } + + public async Task CreateAsync(CreateAchievementRequest req) + { + var a = new Achievement + { + Name = req.Name, Description = req.Description, IconUrl = req.IconUrl, + XpReward = req.XpReward, CoinReward = req.CoinReward, Condition = req.Condition + }; + _db.Achievements.Add(a); + await _db.SaveChangesAsync(); + return a.ToDto(); + } + + public async Task UpdateAsync(int id, UpdateAchievementRequest req) + { + var a = await _db.Achievements.FindAsync(id) ?? throw new NotFoundException("Achievement", id); + a.Name = req.Name; a.Description = req.Description; a.IconUrl = req.IconUrl; + a.XpReward = req.XpReward; a.CoinReward = req.CoinReward; a.Condition = req.Condition; + await _db.SaveChangesAsync(); + return a.ToDto(); + } + + public async Task DeleteAsync(int id) + { + var a = await _db.Achievements.FindAsync(id) ?? throw new NotFoundException("Achievement", id); + _db.Achievements.Remove(a); + await _db.SaveChangesAsync(); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs new file mode 100644 index 0000000..30ec28c --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -0,0 +1,173 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using UniVerse.Application.DTOs.Auth; +using UniVerse.Application.DTOs.Users; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class AuthService : IAuthService +{ + private readonly AppDbContext _db; + private readonly IConfiguration _config; + private readonly IGamificationService _gamification; + + public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification) + { + _db = db; + _config = config; + _gamification = gamification; + } + + public async Task LoginWithMicrosoftAsync(string authorizationCode) + { + // Stub: in production, exchange authorizationCode with Microsoft Entra ID + // For now, create/find a demo user + throw new NotImplementedException( + "Microsoft Entra ID integration not yet configured. Use /api/v1/auth/login/dev in Development mode."); + } + + public async Task DevLoginAsync(string email, string? displayName, UserRole role) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + + if (user == null) + { + user = new User + { + 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(); + } + } + + if (!user.IsActive) + throw new ForbiddenException("Account is deactivated."); + + var accessToken = GenerateAccessToken(user); + var refreshToken = await GenerateRefreshTokenAsync(user.Id); + + return new AuthResponse( + accessToken, + DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + user.ToAuthDto() + ); + } + + public async Task RefreshTokenAsync(string refreshToken) + { + var token = await _db.RefreshTokens + .Include(rt => rt.User) + .FirstOrDefaultAsync(rt => rt.Token == refreshToken); + + if (token == null || !token.IsActive) + throw new ForbiddenException("Invalid or expired refresh token."); + + // Revoke old token + token.RevokedAt = DateTime.UtcNow; + + // Generate new tokens + var accessToken = GenerateAccessToken(token.User); + var newRefreshToken = await GenerateRefreshTokenAsync(token.UserId); + + await _db.SaveChangesAsync(); + + return new AuthResponse( + accessToken, + DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + token.User.ToAuthDto() + ); + } + + public async Task RevokeRefreshTokenAsync(string refreshToken) + { + var token = await _db.RefreshTokens.FirstOrDefaultAsync(rt => rt.Token == refreshToken); + if (token != null && token.IsActive) + { + token.RevokedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + } + + public async Task GetCurrentUserAsync(int userId) + { + var user = await _db.Users.FindAsync(userId) + ?? throw new NotFoundException("User", userId); + return user.ToDto(_gamification.CalculateLevel(user.Xp)); + } + + private string GenerateAccessToken(User user) + { + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + 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 ?? "") + }; + + var token = new JwtSecurityToken( + issuer: _config["Jwt:Issuer"], + audience: _config["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private async Task GenerateRefreshTokenAsync(int userId) + { + var randomBytes = RandomNumberGenerator.GetBytes(64); + var tokenString = Convert.ToBase64String(randomBytes); + + var refreshToken = new RefreshToken + { + UserId = userId, + Token = tokenString, + ExpiresAt = DateTime.UtcNow.AddDays(GetRefreshTokenExpiration()), + CreatedAt = DateTime.UtcNow + }; + + _db.RefreshTokens.Add(refreshToken); + await _db.SaveChangesAsync(); + + return tokenString; + } + + private int GetAccessTokenExpiration() => + int.Parse(_config["Jwt:AccessTokenExpirationMinutes"] ?? "30"); + + private int GetRefreshTokenExpiration() => + int.Parse(_config["Jwt:RefreshTokenExpirationDays"] ?? "30"); +} diff --git a/backend/UniVerse.Infrastructure/Services/CourseService.cs b/backend/UniVerse.Infrastructure/Services/CourseService.cs new file mode 100644 index 0000000..2d9625a --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/CourseService.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Courses; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class CourseService : ICourseService +{ + private readonly AppDbContext _db; + public CourseService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync(CourseFilterRequest filter) + { + var query = _db.Courses.Include(c => c.CourseTags).ThenInclude(ct => ct.Tag).AsQueryable(); + if (!string.IsNullOrEmpty(filter.Search)) + query = query.Where(c => c.Name.ToLower().Contains(filter.Search.ToLower())); + if (filter.IsSynced.HasValue) + query = query.Where(c => c.IsSynced == filter.IsSynced.Value); + if (filter.TagId.HasValue) + query = query.Where(c => c.CourseTags.Any(ct => ct.TagId == filter.TagId.Value)); + + var total = await query.CountAsync(); + var courses = await query.OrderByDescending(c => c.CreatedAt) + .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); + return PagedResult.Create(courses.Select(c => c.ToDto()).ToList(), total, filter.Page, filter.PageSize); + } + + public async Task GetByIdAsync(int id) + { + var course = await _db.Courses.Include(c => c.CourseTags).ThenInclude(ct => ct.Tag) + .FirstOrDefaultAsync(c => c.Id == id) ?? throw new NotFoundException("Course", id); + return course.ToDto(); + } + + public async Task CreateAsync(CreateCourseRequest request) + { + var course = new Course { Name = request.Name, Description = request.Description }; + _db.Courses.Add(course); + await _db.SaveChangesAsync(); + return await GetByIdAsync(course.Id); + } + + public async Task UpdateAsync(int id, UpdateCourseRequest request) + { + var course = await _db.Courses.FindAsync(id) ?? throw new NotFoundException("Course", id); + course.Name = request.Name; + course.Description = request.Description; + course.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return await GetByIdAsync(id); + } + + public async Task DeleteAsync(int id) + { + var course = await _db.Courses.FindAsync(id) ?? throw new NotFoundException("Course", id); + _db.Courses.Remove(course); + await _db.SaveChangesAsync(); + } + + public async Task AddTagAsync(int courseId, int tagId) + { + if (await _db.CourseTags.AnyAsync(ct => ct.CourseId == courseId && ct.TagId == tagId)) + throw new ConflictException("Tag already linked to course."); + _ = await _db.Courses.FindAsync(courseId) ?? throw new NotFoundException("Course", courseId); + _ = await _db.Tags.FindAsync(tagId) ?? throw new NotFoundException("Tag", tagId); + _db.CourseTags.Add(new CourseTag { CourseId = courseId, TagId = tagId }); + await _db.SaveChangesAsync(); + } + + public async Task RemoveTagAsync(int courseId, int tagId) + { + var ct = await _db.CourseTags.FirstOrDefaultAsync(x => x.CourseId == courseId && x.TagId == tagId) + ?? throw new NotFoundException("CourseTag not found."); + _db.CourseTags.Remove(ct); + await _db.SaveChangesAsync(); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/GamificationService.cs b/backend/UniVerse.Infrastructure/Services/GamificationService.cs new file mode 100644 index 0000000..077598c --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/GamificationService.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using UniVerse.Application.DTOs.Achievements; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Gamification; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class GamificationService : IGamificationService +{ + private readonly AppDbContext _db; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public GamificationService(AppDbContext db, IConfiguration config, ILogger logger) + { + _db = db; _config = config; _logger = logger; + } + + public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, + int? reviewId = null, int? achievementId = null, string? description = null) + { + var user = await _db.Users.FindAsync(userId); + if (user == null) return; + user.Coins += amount; + user.Xp += amount; + _db.CoinTransactions.Add(new CoinTransaction + { + UserId = userId, Amount = amount, Type = type, + ReviewId = reviewId, AchievementId = achievementId, Description = description + }); + await _db.SaveChangesAsync(); + _logger.LogInformation("Awarded {Amount} coins to user {UserId} ({Type})", amount, userId, type); + } + + public async Task CheckAndAwardAchievementsAsync(int userId) + { + var achievements = await _db.Achievements.ToListAsync(); + var existing = await _db.UserAchievements.Where(ua => ua.UserId == userId) + .Select(ua => ua.AchievementId).ToListAsync(); + var reviews = await _db.Reviews.CountAsync(r => r.UserId == userId); + var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId && e.Attended); + + foreach (var achievement in achievements.Where(a => !existing.Contains(a.Id))) + { + var earned = achievement.Condition switch + { + "reviews_1" => reviews >= 1, + "reviews_5" => reviews >= 5, + "reviews_10" => reviews >= 10, + "attended_5" => attended >= 5, + "attended_10" => attended >= 10, + _ => false + }; + if (!earned) continue; + _db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id }); + if (achievement.CoinReward > 0) + await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward, + achievementId: achievement.Id, description: $"Achievement: {achievement.Name}"); + } + await _db.SaveChangesAsync(); + } + + public int CalculateLevel(int xp) + { + var thresholds = _config.GetSection("Gamification:XpThresholds").Get() + ?? [0, 100, 300, 600, 1000, 1500, 2500, 4000]; + for (int i = thresholds.Length - 1; i >= 0; i--) + if (xp >= thresholds[i]) return i + 1; + return 1; + } + + public async Task> GetUserAchievementsAsync(int userId) => + await _db.UserAchievements.Include(ua => ua.Achievement) + .Where(ua => ua.UserId == userId).OrderByDescending(ua => ua.AwardedAt) + .Select(ua => ua.ToDto()).ToListAsync(); + + public async Task> GetTransactionsAsync(int userId, PaginationRequest pagination) + { + var query = _db.CoinTransactions.Where(ct => ct.UserId == userId); + var total = await query.CountAsync(); + var items = await query.OrderByDescending(ct => ct.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize) + .Select(ct => ct.ToDto()).ToListAsync(); + return PagedResult.Create(items, total, pagination.Page, pagination.PageSize); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs new file mode 100644 index 0000000..b28ffdc --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Lectures; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class LectureService : ILectureService +{ + private readonly AppDbContext _db; + public LectureService(AppDbContext db) => _db = db; + + private IQueryable BaseQuery() => _db.Lectures + .Include(l => l.Course).Include(l => l.Teacher) + .Include(l => l.Location).Include(l => l.Enrollments); + + public async Task> GetAllAsync(LectureFilterRequest filter) + { + var query = BaseQuery(); + if (filter.CourseId.HasValue) query = query.Where(l => l.CourseId == filter.CourseId); + if (filter.TeacherId.HasValue) query = query.Where(l => l.TeacherId == filter.TeacherId); + if (filter.Format.HasValue) query = query.Where(l => l.Format == filter.Format); + if (filter.IsOpen.HasValue) query = query.Where(l => l.IsOpen == filter.IsOpen); + if (filter.DateFrom.HasValue) + query = query.Where(l => DateOnly.FromDateTime(l.StartsAt) >= filter.DateFrom); + if (filter.DateTo.HasValue) + query = query.Where(l => DateOnly.FromDateTime(l.StartsAt) <= filter.DateTo); + if (!string.IsNullOrEmpty(filter.Search)) + query = query.Where(l => l.Title.ToLower().Contains(filter.Search.ToLower())); + if (filter.TagId.HasValue) + query = query.Where(l => l.Course.CourseTags.Any(ct => ct.TagId == filter.TagId)); + + var total = await query.CountAsync(); + var items = await query.OrderBy(l => l.StartsAt) + .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(l => l.ToDto()).ToList(), total, filter.Page, filter.PageSize); + } + + public async Task GetByIdAsync(int id, int? currentUserId = null) + { + var lecture = await BaseQuery().FirstOrDefaultAsync(l => l.Id == id) + ?? throw new NotFoundException("Lecture", id); + var isEnrolled = currentUserId.HasValue && + lecture.Enrollments.Any(e => e.UserId == currentUserId.Value); + return lecture.ToDetailDto(isEnrolled); + } + + public async Task CreateAsync(CreateLectureRequest req) + { + _ = await _db.Courses.FindAsync(req.CourseId) ?? throw new NotFoundException("Course", req.CourseId); + var lecture = new Lecture + { + CourseId = req.CourseId, TeacherId = req.TeacherId, LocationId = req.LocationId, + Title = req.Title, Description = req.Description, Format = req.Format, + StartsAt = req.StartsAt, EndsAt = req.EndsAt, IsOpen = req.IsOpen, + MaxEnrollments = req.MaxEnrollments, OnlineUrl = req.OnlineUrl + }; + _db.Lectures.Add(lecture); + await _db.SaveChangesAsync(); + var full = await BaseQuery().FirstAsync(l => l.Id == lecture.Id); + return full.ToDto(); + } + + public async Task UpdateAsync(int id, UpdateLectureRequest req) + { + var lecture = await _db.Lectures.FindAsync(id) ?? throw new NotFoundException("Lecture", id); + lecture.TeacherId = req.TeacherId; lecture.LocationId = req.LocationId; + lecture.Title = req.Title; lecture.Description = req.Description; + lecture.Format = req.Format; lecture.StartsAt = req.StartsAt; lecture.EndsAt = req.EndsAt; + lecture.IsOpen = req.IsOpen; lecture.MaxEnrollments = req.MaxEnrollments; + lecture.OnlineUrl = req.OnlineUrl; lecture.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + var full = await BaseQuery().FirstAsync(l => l.Id == id); + return full.ToDto(); + } + + public async Task DeleteAsync(int id) + { + var lecture = await _db.Lectures.FindAsync(id) ?? throw new NotFoundException("Lecture", id); + _db.Lectures.Remove(lecture); + await _db.SaveChangesAsync(); + } + + public async Task EnrollAsync(int lectureId, int userId) + { + var lecture = await _db.Lectures.Include(l => l.Enrollments) + .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); + if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); + if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments) + throw new ConflictException("Lecture is full."); + if (lecture.Enrollments.Any(e => e.UserId == userId)) + throw new ConflictException("Already enrolled."); + _db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId }); + await _db.SaveChangesAsync(); + } + + public async Task UnenrollAsync(int lectureId, int userId) + { + var enrollment = await _db.LectureEnrollments + .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) + ?? throw new NotFoundException("Enrollment not found."); + _db.LectureEnrollments.Remove(enrollment); + await _db.SaveChangesAsync(); + } + + public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended) + { + var enrollment = await _db.LectureEnrollments + .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) + ?? throw new NotFoundException("Enrollment not found."); + enrollment.Attended = attended; + await _db.SaveChangesAsync(); + } + + public async Task> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination) + { + var query = _db.LectureEnrollments.Include(e => e.User) + .Where(e => e.LectureId == lectureId); + var total = await query.CountAsync(); + var items = await query.OrderBy(e => e.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(e => e.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs new file mode 100644 index 0000000..898aeae --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class LlmAnalysisService : ILlmAnalysisService +{ + private readonly AppDbContext _db; + private readonly ILlmClient _llm; + private readonly IGamificationService _gamification; + private readonly ILogger _logger; + + public LlmAnalysisService(AppDbContext db, ILlmClient llm, + IGamificationService gamification, ILogger logger) + { + _db = db; _llm = llm; _gamification = gamification; _logger = logger; + } + + public async Task AnalyzeReviewAsync(int reviewId) + { + var review = await _db.Reviews.Include(r => r.Lecture) + .FirstOrDefaultAsync(r => r.Id == reviewId); + if (review == null || review.LlmStatus != ReviewLlmStatus.Pending) return; + + try + { + var context = $"Lecture: {review.Lecture?.Title}"; + var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context); + + review.QualityScore = result.QualityScore; + review.Sentiment = Enum.TryParse(result.Sentiment, true, out var s) + ? s : ReviewSentiment.Neutral; + review.LlmTags = result.Tags; + review.IsInformative = result.IsInformative; + review.LlmStatus = ReviewLlmStatus.Analyzed; + review.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + + if (result.IsInformative) + await _gamification.AwardCoinsAsync(review.UserId, 10, + CoinTransactionType.ReviewReward, reviewId: review.Id, + description: "Informative review reward"); + + await _gamification.CheckAndAwardAchievementsAsync(review.UserId); + _logger.LogInformation("Review {ReviewId} analyzed successfully", reviewId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to analyze review {ReviewId}, will retry later", reviewId); + } + } + + public async Task ProcessPendingReviewsAsync() + { + var pending = await _db.Reviews + .Where(r => r.LlmStatus == ReviewLlmStatus.Pending) + .OrderBy(r => r.CreatedAt).Take(10) + .Select(r => r.Id).ToListAsync(); + + foreach (var id in pending) + await AnalyzeReviewAsync(id); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/LocationService.cs b/backend/UniVerse.Infrastructure/Services/LocationService.cs new file mode 100644 index 0000000..45502c6 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/LocationService.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Locations; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class LocationService : ILocationService +{ + private readonly AppDbContext _db; + + public LocationService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync() => + await _db.Locations.OrderBy(l => l.Name) + .Select(l => new LocationDto(l.Id, l.Name, l.Building, l.Room, l.Address, l.CreatedAt)) + .ToListAsync(); + + public async Task GetByIdAsync(int id) + { + var loc = await _db.Locations.FindAsync(id) + ?? throw new NotFoundException("Location", id); + return loc.ToDto(); + } + + public async Task CreateAsync(CreateLocationRequest request) + { + var loc = new Location + { + Name = request.Name, + Building = request.Building, + Room = request.Room, + Address = request.Address + }; + _db.Locations.Add(loc); + await _db.SaveChangesAsync(); + return loc.ToDto(); + } + + public async Task UpdateAsync(int id, UpdateLocationRequest request) + { + var loc = await _db.Locations.FindAsync(id) + ?? throw new NotFoundException("Location", id); + + loc.Name = request.Name; + loc.Building = request.Building; + loc.Room = request.Room; + loc.Address = request.Address; + + await _db.SaveChangesAsync(); + return loc.ToDto(); + } + + public async Task DeleteAsync(int id) + { + var loc = await _db.Locations.FindAsync(id) + ?? throw new NotFoundException("Location", id); + _db.Locations.Remove(loc); + await _db.SaveChangesAsync(); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/ReviewService.cs b/backend/UniVerse.Infrastructure/Services/ReviewService.cs new file mode 100644 index 0000000..b70aaba --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/ReviewService.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class ReviewService : IReviewService +{ + private readonly AppDbContext _db; + public ReviewService(AppDbContext db) => _db = db; + + private IQueryable BaseQuery() => _db.Reviews + .Include(r => r.Lecture).Include(r => r.User); + + public async Task CreateAsync(int userId, CreateReviewRequest req) + { + _ = await _db.Lectures.FindAsync(req.LectureId) ?? throw new NotFoundException("Lecture", req.LectureId); + if (await _db.Reviews.AnyAsync(r => r.LectureId == req.LectureId && r.UserId == userId)) + throw new ConflictException("You already reviewed this lecture."); + var review = new Review + { + LectureId = req.LectureId, UserId = userId, + Rating = req.Rating, Text = req.Text, + LlmStatus = ReviewLlmStatus.Pending + }; + _db.Reviews.Add(review); + await _db.SaveChangesAsync(); + var full = await BaseQuery().FirstAsync(r => r.Id == review.Id); + return full.ToDto(); + } + + public async Task GetByIdAsync(int id) + { + var review = await BaseQuery().FirstOrDefaultAsync(r => r.Id == id) + ?? throw new NotFoundException("Review", id); + return review.ToDto(); + } + + public async Task UpdateAsync(int id, int userId, UpdateReviewRequest req) + { + var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); + if (review.UserId != userId) throw new ForbiddenException(); + review.Rating = req.Rating; review.Text = req.Text; + review.LlmStatus = ReviewLlmStatus.Pending; + review.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return await GetByIdAsync(id); + } + + public async Task DeleteAsync(int id, int userId, bool isAdmin = false) + { + var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); + if (review.UserId != userId && !isAdmin) throw new ForbiddenException(); + _db.Reviews.Remove(review); + await _db.SaveChangesAsync(); + } + + public async Task> GetByLectureAsync(int lectureId, PaginationRequest pagination) + { + var query = BaseQuery().Where(r => r.LectureId == lectureId); + var total = await query.CountAsync(); + var items = await query.OrderByDescending(r => r.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + } + + public async Task> GetByUserAsync(int userId, PaginationRequest pagination) + { + var query = BaseQuery().Where(r => r.UserId == userId); + var total = await query.CountAsync(); + var items = await query.OrderByDescending(r => r.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + } + + public async Task> GetPendingAsync(PaginationRequest pagination) + { + var query = BaseQuery().Where(r => r.LlmStatus == ReviewLlmStatus.Pending); + var total = await query.CountAsync(); + var items = await query.OrderBy(r => r.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + } + + public async Task ReanalyzeAsync(int id) + { + var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); + review.LlmStatus = ReviewLlmStatus.Pending; + review.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs new file mode 100644 index 0000000..7b3556e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using UniVerse.Application.DTOs.Sync; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class ScheduleSyncService : IScheduleSyncService +{ + private readonly AppDbContext _db; + private readonly IModeusApiClient _modeus; + private readonly ILogger _logger; + private static SyncStatusDto _lastStatus = new(null, "idle", null); + + public ScheduleSyncService(AppDbContext db, IModeusApiClient modeus, ILogger logger) + { + _db = db; _modeus = modeus; _logger = logger; + } + + public async Task SyncScheduleAsync(SyncScheduleRequest request) + { + int created = 0, updated = 0, skipped = 0; + try + { + var events = await _modeus.SearchEventsAsync(request); + foreach (var ev in events.Events) + { + var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id); + if (existing != null) { updated++; existing.StartsAt = ev.StartsAt; existing.EndsAt = ev.EndsAt; existing.UpdatedAt = DateTime.UtcNow; } + else + { + var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == ev.TypeId); + if (course == null) { course = new Course { Name = ev.Name, ExternalId = ev.TypeId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } + _db.Lectures.Add(new Lecture { CourseId = course.Id, Title = ev.Name, ExternalId = ev.Id, StartsAt = ev.StartsAt, EndsAt = ev.EndsAt }); + created++; + } + } + await _db.SaveChangesAsync(); + var result = new SyncResultDto(created, updated, skipped, null); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Schedule sync failed"); + var result = new SyncResultDto(created, updated, skipped, ex.Message); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); + return result; + } + } + + public async Task SyncRoomsAsync() + { + int created = 0, updated = 0; + var rooms = await _modeus.SearchRoomsAsync(); + foreach (var room in rooms.Rooms) + { + var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); + if (existing != null) { existing.Name = room.Name; existing.Building = room.Building; updated++; } + else { _db.Locations.Add(new Location { Name = room.Name, Building = room.Building, ExternalId = room.Id }); created++; } + } + await _db.SaveChangesAsync(); + return new SyncResultDto(created, updated, 0, null); + } + + public async Task> SearchEmployeesAsync(string fullname) + { + var employees = await _modeus.SearchEmployeeAsync(fullname); + return employees.Select(e => new EmployeeDto(e.Id, e.FullName, e.Department)).ToList(); + } + + public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); +} diff --git a/backend/UniVerse.Infrastructure/Services/TagService.cs b/backend/UniVerse.Infrastructure/Services/TagService.cs new file mode 100644 index 0000000..34926ea --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/TagService.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Tags; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class TagService : ITagService +{ + private readonly AppDbContext _db; + + public TagService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync(TagType? type = null, int? parentId = null) + { + var query = _db.Tags.AsQueryable(); + if (type.HasValue) query = query.Where(t => t.Type == type.Value); + if (parentId.HasValue) query = query.Where(t => t.ParentId == parentId.Value); + + return await query.OrderBy(t => t.Name) + .Select(t => new TagDto(t.Id, t.Name, t.Type, t.ParentId, t.CreatedAt)) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + var tag = await _db.Tags.FindAsync(id) + ?? throw new NotFoundException("Tag", id); + return tag.ToDto(); + } + + public async Task CreateAsync(CreateTagRequest request) + { + if (request.ParentId.HasValue) + { + var parent = await _db.Tags.FindAsync(request.ParentId.Value) + ?? throw new NotFoundException("Parent Tag", request.ParentId.Value); + } + + var tag = new Tag + { + Name = request.Name, + Type = request.Type, + ParentId = request.ParentId + }; + + _db.Tags.Add(tag); + await _db.SaveChangesAsync(); + return tag.ToDto(); + } + + public async Task UpdateAsync(int id, UpdateTagRequest request) + { + var tag = await _db.Tags.FindAsync(id) + ?? throw new NotFoundException("Tag", id); + + tag.Name = request.Name; + tag.Type = request.Type; + tag.ParentId = request.ParentId; + + await _db.SaveChangesAsync(); + return tag.ToDto(); + } + + public async Task DeleteAsync(int id) + { + var tag = await _db.Tags.FindAsync(id) + ?? throw new NotFoundException("Tag", id); + _db.Tags.Remove(tag); + await _db.SaveChangesAsync(); + } + + public async Task> GetTreeAsync() + { + var allTags = await _db.Tags + .Include(t => t.Children) + .ToListAsync(); + + var roots = allTags.Where(t => t.ParentId == null).ToList(); + return roots.Select(t => BuildTree(t, allTags)).ToList(); + } + + private TagTreeDto BuildTree(Tag tag, List allTags) + { + var children = allTags.Where(t => t.ParentId == tag.Id).ToList(); + return new TagTreeDto( + tag.Id, tag.Name, tag.Type, + children.Select(c => BuildTree(c, allTags)).ToList() + ); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs new file mode 100644 index 0000000..5e691f3 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Users; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Mappings; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class UserService : IUserService +{ + private readonly AppDbContext _db; + private readonly IGamificationService _gamification; + + public UserService(AppDbContext db, IGamificationService gamification) + { + _db = db; + _gamification = gamification; + } + + public async Task GetByIdAsync(int id) + { + var user = await _db.Users.FindAsync(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) + ?? throw new NotFoundException("User", id); + + if (request.DisplayName != null) user.DisplayName = request.DisplayName; + if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl; + user.UpdatedAt = DateTime.UtcNow; + + await _db.SaveChangesAsync(); + return user.ToDto(_gamification.CalculateLevel(user.Xp)); + } + + public async Task GetStatsAsync(int id) + { + var user = await _db.Users.FindAsync(id) + ?? throw new NotFoundException("User", id); + + var totalLectures = await _db.LectureEnrollments.CountAsync(e => e.UserId == id); + var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended); + var reviews = await _db.Reviews.CountAsync(r => r.UserId == id); + var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id); + + return new UserStatsDto( + totalLectures, attended, reviews, + user.Xp, user.Coins, _gamification.CalculateLevel(user.Xp), achievements + ); + } + + public async Task> GetAllAsync(UserFilterRequest filter) + { + var query = _db.Users.AsQueryable(); + + if (!string.IsNullOrEmpty(filter.Search)) + { + var search = filter.Search.ToLower(); + query = query.Where(u => + u.Email.ToLower().Contains(search) || + (u.DisplayName != null && u.DisplayName.ToLower().Contains(search))); + } + + if (filter.Role.HasValue) + query = query.Where(u => u.Role == filter.Role.Value); + + if (filter.IsActive.HasValue) + query = query.Where(u => u.IsActive == filter.IsActive.Value); + + var total = await query.CountAsync(); + + var users = await query + .OrderByDescending(u => u.CreatedAt) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(); + + var items = users.Select(u => u.ToDto(_gamification.CalculateLevel(u.Xp))).ToList(); + return PagedResult.Create(items, total, filter.Page, filter.PageSize); + } + + public async Task SetRoleAsync(int id, UserRole role) + { + var user = await _db.Users.FindAsync(id) + ?? throw new NotFoundException("User", id); + user.Role = role; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task SetActiveAsync(int id, bool isActive) + { + var user = await _db.Users.FindAsync(id) + ?? throw new NotFoundException("User", id); + user.IsActive = isActive; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } +} diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj new file mode 100644 index 0000000..bec92ca --- /dev/null +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + UniVerse.Infrastructure + + + + + + + + + + + + +