From 3ff483d74aed54b9237a65b573e7f1b052623db9 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Wed, 8 Apr 2026 19:48:48 +0300 Subject: [PATCH 01/67] Base project: Contains examples of requests and data controllers. --- .gitignore | 6 ++ README.md | 26 ++++++++- Versum.slnx | 3 + Versum/Controllers/ApplicationDbContext.cs | 14 +++++ Versum/Controllers/PostsController.cs | 40 +++++++++++++ Versum/Hubs/NotificationHub.cs | 19 ++++++ .../20260405145838_InitialCreate.Designer.cs | 52 +++++++++++++++++ .../20260405145838_InitialCreate.cs | 38 ++++++++++++ .../20260408161839_AddPostsTable.Designer.cs | 58 +++++++++++++++++++ .../20260408161839_AddPostsTable.cs | 57 ++++++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 55 ++++++++++++++++++ Versum/Post.cs | 11 ++++ Versum/Program.cs | 46 +++++++++++++++ Versum/Properties/launchSettings.json | 23 ++++++++ Versum/Versum.csproj | 23 ++++++++ Versum/Versum.http | 15 +++++ Versum/appsettings.json | 9 +++ 17 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 Versum.slnx create mode 100644 Versum/Controllers/ApplicationDbContext.cs create mode 100644 Versum/Controllers/PostsController.cs create mode 100644 Versum/Hubs/NotificationHub.cs create mode 100644 Versum/Migrations/20260405145838_InitialCreate.Designer.cs create mode 100644 Versum/Migrations/20260405145838_InitialCreate.cs create mode 100644 Versum/Migrations/20260408161839_AddPostsTable.Designer.cs create mode 100644 Versum/Migrations/20260408161839_AddPostsTable.cs create mode 100644 Versum/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 Versum/Post.cs create mode 100644 Versum/Program.cs create mode 100644 Versum/Properties/launchSettings.json create mode 100644 Versum/Versum.csproj create mode 100644 Versum/Versum.http create mode 100644 Versum/appsettings.json diff --git a/.gitignore b/.gitignore index ce89292..46a79e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +appsettings.Development.json +appsettings.Local.json +UserSecrets/ + + # User-specific files *.rsuser *.suo diff --git a/README.md b/README.md index f64f7aa..639b1f8 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -# my-project-backend \ No newline at end of file +# VersomLog-backend + +# ВСТАНОВЛЕННЯ +1. Встанови Visual Studio 2026 та .NET 10 SDK. + +2. У Visual Studio Installer обери вкладку "ASP.NET and web development" (перша галочка). + +3. Створи в папці проекту файл appsettings.Development.json (якщо його немає). + +4. Скопіюй туди Connection String від актуальної бази даних (запитай у саші або візьми з закріплених повідомлень). + +5. Відкрий проект (.sln) у Visual Studio. Вона автоматично почне скачувати пакети (Restore NuGet Packages). + +Якщо ти щось написав в коді, оновити базу даних можна так: + Відкрий Package Manager Console (Tools -> NuGet Package Manager) та введи: + Add-Migration "Назва Зміни" + Update-Database + +Для запуску: +Натисни F5 (зелена стрілочка зверху) у Visual Studio. +При першому запуску будуть просити створити локальний Ssl ключ, просто зі всім погоджуйся + +Результат зазвичай за посиланнями: +https://localhost:7014/swagger/ +http://localhost:5056/swagger/ \ No newline at end of file diff --git a/Versum.slnx b/Versum.slnx new file mode 100644 index 0000000..f3411f9 --- /dev/null +++ b/Versum.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Versum/Controllers/ApplicationDbContext.cs b/Versum/Controllers/ApplicationDbContext.cs new file mode 100644 index 0000000..93e4b12 --- /dev/null +++ b/Versum/Controllers/ApplicationDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace Versum +{ + public class ApplicationDbContext : DbContext + { + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Posts { get; set; } + } +} \ No newline at end of file diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs new file mode 100644 index 0000000..a99d589 --- /dev/null +++ b/Versum/Controllers/PostsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Versum.Hubs; + +namespace Versum.Controllers +{ + //"Route" turns class name into a link - api/[className - "Controller"] + [ApiController] + [Route("api/[controller]")] + public class PostsController : ControllerBase + { + private readonly ApplicationDbContext _context; + private readonly IHubContext _hubContext; + + public PostsController(ApplicationDbContext context, IHubContext hubContext) + { + _context = context; + _hubContext = hubContext; + } + + //when you call GET on /api/posts + [HttpGet] + public async Task>> GetPosts() + { + return await _context.Posts.OrderByDescending(p => p.CreatedAt).ToListAsync(); + } + + //try to guess + [HttpPost] + public async Task> CreatePost(Post post) + { + _context.Posts.Add(post); + await _context.SaveChangesAsync(); + + await _hubContext.Clients.All.SendAsync("NewPostPublished"); + return CreatedAtAction(nameof(GetPosts), new { id = post.Id }, post); + } + } +} diff --git a/Versum/Hubs/NotificationHub.cs b/Versum/Hubs/NotificationHub.cs new file mode 100644 index 0000000..3f0664a --- /dev/null +++ b/Versum/Hubs/NotificationHub.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Versum.Hubs +{ + public class NotificationHub : Hub + { + public async Task SendNotification(string message) + { + await Clients.All.SendAsync("NewPostPublished", message); + } + + // track users + public override async Task OnConnectedAsync() + { + Console.WriteLine($"User Connected: {Context.ConnectionId}"); + await base.OnConnectedAsync(); + } + } +} \ No newline at end of file diff --git a/Versum/Migrations/20260405145838_InitialCreate.Designer.cs b/Versum/Migrations/20260405145838_InitialCreate.Designer.cs new file mode 100644 index 0000000..2185125 --- /dev/null +++ b/Versum/Migrations/20260405145838_InitialCreate.Designer.cs @@ -0,0 +1,52 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260405145838_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.WeatherForecast", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("TemperatureC") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Forecasts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260405145838_InitialCreate.cs b/Versum/Migrations/20260405145838_InitialCreate.cs new file mode 100644 index 0000000..8165c15 --- /dev/null +++ b/Versum/Migrations/20260405145838_InitialCreate.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Forecasts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Date = table.Column(type: "date", nullable: false), + TemperatureC = table.Column(type: "integer", nullable: false), + Summary = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Forecasts", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Forecasts"); + } + } +} diff --git a/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs b/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs new file mode 100644 index 0000000..4d81561 --- /dev/null +++ b/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs @@ -0,0 +1,58 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260408161839_AddPostsTable")] + partial class AddPostsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260408161839_AddPostsTable.cs b/Versum/Migrations/20260408161839_AddPostsTable.cs new file mode 100644 index 0000000..52b913c --- /dev/null +++ b/Versum/Migrations/20260408161839_AddPostsTable.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddPostsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Forecasts"); + + migrationBuilder.CreateTable( + name: "Posts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: false), + Content = table.Column(type: "text", nullable: false), + Author = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Posts", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Posts"); + + migrationBuilder.CreateTable( + name: "Forecasts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Date = table.Column(type: "date", nullable: false), + Summary = table.Column(type: "text", nullable: true), + TemperatureC = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Forecasts", x => x.Id); + }); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..0230cc1 --- /dev/null +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,55 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Post.cs b/Versum/Post.cs new file mode 100644 index 0000000..38613f8 --- /dev/null +++ b/Versum/Post.cs @@ -0,0 +1,11 @@ +namespace Versum +{ + public class Post + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public string Author { get; set; } = "NotAsasha"; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Versum/Program.cs b/Versum/Program.cs new file mode 100644 index 0000000..92c2a0a --- /dev/null +++ b/Versum/Program.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Versum; +using Versum.Hubs; + +var builder = WebApplication.CreateBuilder(args); + + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSignalR(); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddCors(options => { + options.AddPolicy("NuxtPolicy", policy => { + policy.WithOrigins("http://localhost:3000") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + +var app = builder.Build(); + + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.UseCors("NuxtPolicy"); + +app.MapHub("/notificationHub"); + + +app.MapControllers(); +app.Run(); diff --git a/Versum/Properties/launchSettings.json b/Versum/Properties/launchSettings.json new file mode 100644 index 0000000..3592ac0 --- /dev/null +++ b/Versum/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7014;http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj new file mode 100644 index 0000000..0ecbb99 --- /dev/null +++ b/Versum/Versum.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Versum/Versum.http b/Versum/Versum.http new file mode 100644 index 0000000..8d6f1bb --- /dev/null +++ b/Versum/Versum.http @@ -0,0 +1,15 @@ +@Versum_HostAddress = https://localhost:7014 + +### Get all the posts +GET {{Versum_HostAddress}}/api/posts +Accept: application/json + +### Create first post +POST {{Versum_HostAddress}}/api/posts +Content-Type: application/json + +{ + "title": "Test Post", + "author": "Ivan Franko", + "content": "First post, навіть українською." +} diff --git a/Versum/appsettings.json b/Versum/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Versum/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 387e13875570ede999d0fdc743943bc2ef08b404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D1=96=D0=BA=D1=82=D0=BE=D1=80=D1=96=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=B8=D0=B4?= Date: Sun, 12 Apr 2026 11:43:38 +0300 Subject: [PATCH 02/67] Implemented Backend Logic for User Registration --- Versum/Controllers/ApplicationDbContext.cs | 14 --- Versum/Controllers/AuthController.cs | 49 +++++++++ Versum/DdContext/ApplicationDbContext.cs | 23 ++++ Versum/Dtos/RegisterDto.cs | 35 ++++++ .../20260411150626_AddUsersTable.Designer.cs | 100 +++++++++++++++++ .../20260411150626_AddUsersTable.cs | 53 +++++++++ .../20260411185632_FixHashLenght.Designer.cs | 101 ++++++++++++++++++ .../20260411185632_FixHashLenght.cs | 36 +++++++ .../ApplicationDbContextModelSnapshot.cs | 43 ++++++++ Versum/{ => Models}/Post.cs | 0 Versum/Models/User.cs | 25 +++++ Versum/Program.cs | 2 + Versum/Services/AuthService.cs | 67 ++++++++++++ Versum/Versum.csproj | 8 +- 14 files changed, 541 insertions(+), 15 deletions(-) delete mode 100644 Versum/Controllers/ApplicationDbContext.cs create mode 100644 Versum/Controllers/AuthController.cs create mode 100644 Versum/DdContext/ApplicationDbContext.cs create mode 100644 Versum/Dtos/RegisterDto.cs create mode 100644 Versum/Migrations/20260411150626_AddUsersTable.Designer.cs create mode 100644 Versum/Migrations/20260411150626_AddUsersTable.cs create mode 100644 Versum/Migrations/20260411185632_FixHashLenght.Designer.cs create mode 100644 Versum/Migrations/20260411185632_FixHashLenght.cs rename Versum/{ => Models}/Post.cs (100%) create mode 100644 Versum/Models/User.cs create mode 100644 Versum/Services/AuthService.cs diff --git a/Versum/Controllers/ApplicationDbContext.cs b/Versum/Controllers/ApplicationDbContext.cs deleted file mode 100644 index 93e4b12..0000000 --- a/Versum/Controllers/ApplicationDbContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Versum -{ - public class ApplicationDbContext : DbContext - { - public ApplicationDbContext(DbContextOptions options) - : base(options) - { - } - - public DbSet Posts { get; set; } - } -} \ No newline at end of file diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs new file mode 100644 index 0000000..b3b2ddd --- /dev/null +++ b/Versum/Controllers/AuthController.cs @@ -0,0 +1,49 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Versum.Services; + +namespace Versum.Controllers +{ + + + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + + [HttpPost("register")] + + public async Task Register([FromBody] RegisterDto dto)// JSON converts to RegisterDtos object + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks all [Required], [RegularExpression] from RegisterDto -> Smth wrong -> returns error 400 + + var (success, error, field) = await _authService.RegisterAsync(dto);// calls Service's RegisterAsync method and passes dto + + if (!success) + return Conflict(new { field, message = error }); //checks if data for transfer does not cause conflicts(error 409) + + return Ok(new { message = "Реєстрація успішна" }); + + } + + // Allow to see added users in the table(only for dev to try it out): shall be deleted or changed. + [HttpGet("users")] + public async Task>> GetUsers( + [FromServices] ApplicationDbContext db) + { + return await db.Users.OrderByDescending(u => u.CreatedAt).ToListAsync(); + } + + + + } +} diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs new file mode 100644 index 0000000..66aa017 --- /dev/null +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; + +namespace Versum +{ + public class ApplicationDbContext : DbContext + { + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Posts { get; set; } + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + + { + modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); + modelBuilder.Entity().HasIndex(u => u.Gmail).IsUnique(); + + } + } +} diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs new file mode 100644 index 0000000..8c48325 --- /dev/null +++ b/Versum/Dtos/RegisterDto.cs @@ -0,0 +1,35 @@ + +using System.ComponentModel.DataAnnotations; + + +namespace Versum.Dtos{ + public class RegisterDto + { + + [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can'n be null + [RegularExpression(@"^[a-z0-9_]+$", + ErrorMessage = "Нікнейм може містити лише цифри, малі літери та підкреслення")] // Allowed symbols: only lowercase (a-z), numbers (0-9), underscores (_) + [MaxLength( 50, ErrorMessage = "поле нікнейму неможе містити більше 50-ти символів")] + public string Username { get; set; } = ""; + + [Required(ErrorMessage = "Введіть свій пароль")] + [StringLength(8, MinimumLength = 8, + ErrorMessage = "Пароль має містити рівно 8 символів")] + [RegularExpression(@"^\S{8}$", + ErrorMessage = "Пароль не може містити пробіли")] + + public string Password { get; set; } = ""; + + + [Required(ErrorMessage = "Введіть свій пароль ще раз")] + [Compare("Password", ErrorMessage = "Ваш пароль не збігається")] + public string ConfirmPassword { get; set; } = ""; // field for re-entry password + + + [Required(ErrorMessage = "Введіть gmail")] + [RegularExpression(@"^[^@\s]+@gmail\.com$", + ErrorMessage = "Неправильний gmail")] // checks if gmail is in right form + [MaxLength(50, ErrorMessage = "поле gmail неможе містити більше 50-ти символів")] + public string Gmail { get; set; } = string.Empty; + } +} diff --git a/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs b/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs new file mode 100644 index 0000000..f109db0 --- /dev/null +++ b/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs @@ -0,0 +1,100 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260411150626_AddUsersTable")] + partial class AddUsersTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailConfirmationToken") + .HasColumnType("text"); + + b.Property("Gmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Gmail") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260411150626_AddUsersTable.cs b/Versum/Migrations/20260411150626_AddUsersTable.cs new file mode 100644 index 0000000..7c9854f --- /dev/null +++ b/Versum/Migrations/20260411150626_AddUsersTable.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddUsersTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + Gmail = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + IsEmailConfirmed = table.Column(type: "boolean", nullable: false), + EmailConfirmationToken = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Gmail", + table: "Users", + column: "Gmail", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs b/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs new file mode 100644 index 0000000..f8b9406 --- /dev/null +++ b/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs @@ -0,0 +1,101 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260411185632_FixHashLenght")] + partial class FixHashLenght + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailConfirmationToken") + .HasColumnType("text"); + + b.Property("Gmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Gmail") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260411185632_FixHashLenght.cs b/Versum/Migrations/20260411185632_FixHashLenght.cs new file mode 100644 index 0000000..e5cf057 --- /dev/null +++ b/Versum/Migrations/20260411185632_FixHashLenght.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class FixHashLenght : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "character varying(60)", + maxLength: 60, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PasswordHash", + table: "Users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(60)", + oldMaxLength: 60); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 0230cc1..b9d4d05 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -49,6 +49,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Posts"); }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailConfirmationToken") + .HasColumnType("text"); + + b.Property("Gmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Gmail") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); #pragma warning restore 612, 618 } } diff --git a/Versum/Post.cs b/Versum/Models/Post.cs similarity index 100% rename from Versum/Post.cs rename to Versum/Models/Post.cs diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs new file mode 100644 index 0000000..666d653 --- /dev/null +++ b/Versum/Models/User.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +namespace Versum +{ + public class User + { + + public int Id { get; set; } + + [MaxLength(50)] public string Username { get; set; } = ""; + + [MaxLength(60)] public string PasswordHash { get; set; } = ""; + + + [MaxLength(50)] public string Gmail { get; set; } = ""; + + + public bool IsEmailConfirmed { get; set; } = false; // Did user confirm email + + public string? EmailConfirmationToken { get; set; } //Unique token which sends on post for confirmation + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up + + + } +} diff --git a/Versum/Program.cs b/Versum/Program.cs index 92c2a0a..c9f790c 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Versum; using Versum.Hubs; +using Versum.Services; var builder = WebApplication.CreateBuilder(args); @@ -23,6 +24,7 @@ }); }); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs new file mode 100644 index 0000000..7bc2590 --- /dev/null +++ b/Versum/Services/AuthService.cs @@ -0,0 +1,67 @@ +using Versum.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace Versum.Services +{ + public interface IAuthService + { + Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto); + // Metjod returns: + // bool Success = successful registration + // string? Error = text of error (or null if everuthing is ok) + // string? Field = what field has error (or null if everything is ok) + + } + + public class AuthService : IAuthService { + + private readonly ApplicationDbContext _db; + public AuthService(ApplicationDbContext db) + + { + _db = db; + + } + public async Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto) + { + bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); + + if (usernameExists) + return (false, "Цей нікнейм вже існує", "username"); + + + string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password); + // Hashing password before saving by algorythm + // BCrypt from NuGet packet BCrypt.Net-Next; + + string token = Guid.NewGuid().ToString("N"); + // Generateі unique token for email confirmation + + + var user = new User + { + Username = dto.Username, + + PasswordHash = passwordHash, + + Gmail = dto.Gmail, + + EmailConfirmationToken = token + + + }; + + _db.Users.Add(user); + + await _db.SaveChangesAsync(); + + + return (true, null, null); + + } + + + + + } +} diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index 0ecbb99..e5c493f 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,6 +7,7 @@ + all @@ -20,4 +21,9 @@ + + + + + From ac12504e1922acfcaa3e109d8f205f21eb19d9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D1=96=D0=BA=D1=82=D0=BE=D1=80=D1=96=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=B8=D0=B4?= Date: Wed, 15 Apr 2026 19:55:01 +0300 Subject: [PATCH 03/67] Changed restrictions and conf email details --- Versum/Dtos/RegisterDto.cs | 14 +-- ...222_AddEmailConfirmationFields.Designer.cs | 104 ++++++++++++++++++ ...260415164222_AddEmailConfirmationFields.cs | 39 +++++++ .../ApplicationDbContextModelSnapshot.cs | 5 +- Versum/Models/User.cs | 4 +- Versum/Services/AuthService.cs | 31 ++++-- 6 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs create mode 100644 Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs index 8c48325..a809492 100644 --- a/Versum/Dtos/RegisterDto.cs +++ b/Versum/Dtos/RegisterDto.cs @@ -6,15 +6,15 @@ namespace Versum.Dtos{ public class RegisterDto { - [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can'n be null + [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can't be null [RegularExpression(@"^[a-z0-9_]+$", ErrorMessage = "Нікнейм може містити лише цифри, малі літери та підкреслення")] // Allowed symbols: only lowercase (a-z), numbers (0-9), underscores (_) [MaxLength( 50, ErrorMessage = "поле нікнейму неможе містити більше 50-ти символів")] public string Username { get; set; } = ""; [Required(ErrorMessage = "Введіть свій пароль")] - [StringLength(8, MinimumLength = 8, - ErrorMessage = "Пароль має містити рівно 8 символів")] + [StringLength(20, MinimumLength = 8, + ErrorMessage = "Пароль має містити від 8 до 20 символів")] [RegularExpression(@"^\S{8}$", ErrorMessage = "Пароль не може містити пробіли")] @@ -26,10 +26,10 @@ public class RegisterDto public string ConfirmPassword { get; set; } = ""; // field for re-entry password - [Required(ErrorMessage = "Введіть gmail")] - [RegularExpression(@"^[^@\s]+@gmail\.com$", - ErrorMessage = "Неправильний gmail")] // checks if gmail is in right form - [MaxLength(50, ErrorMessage = "поле gmail неможе містити більше 50-ти символів")] + [Required(ErrorMessage = "Введіть email")] + [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", + ErrorMessage = "Неправильний email")] // checks if gmail is in right form + [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] public string Gmail { get; set; } = string.Empty; } } diff --git a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs new file mode 100644 index 0000000..a7ec7cc --- /dev/null +++ b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260415164222_AddEmailConfirmationFields")] + partial class AddEmailConfirmationFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gmail") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Gmail") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs new file mode 100644 index 0000000..5a17331 --- /dev/null +++ b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddEmailConfirmationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailConfirmationToken", + table: "Users", + newName: "EmailConfirmationTokenHash"); + + migrationBuilder.AddColumn( + name: "EmailTokenExpiryDate", + table: "Users", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EmailTokenExpiryDate", + table: "Users"); + + migrationBuilder.RenameColumn( + name: "EmailConfirmationTokenHash", + table: "Users", + newName: "EmailConfirmationToken"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index b9d4d05..c15d6d4 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -61,9 +61,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("EmailConfirmationToken") + b.Property("EmailConfirmationTokenHash") .HasColumnType("text"); + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + b.Property("Gmail") .IsRequired() .HasMaxLength(50) diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index 666d653..a6d4f12 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -16,8 +16,8 @@ public class User public bool IsEmailConfirmed { get; set; } = false; // Did user confirm email - public string? EmailConfirmationToken { get; set; } //Unique token which sends on post for confirmation - + public string? EmailConfirmationTokenHash { get; set; } //Unique token Hash which sends on post for confirmation + public DateTime? EmailTokenExpiryDate { get; set; } // Limit in Time to conf email public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 7bc2590..4b77df2 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -1,12 +1,14 @@ -using Versum.Dtos; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; +using Versum.Dtos; namespace Versum.Services { public interface IAuthService { Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto); - // Metjod returns: + // Method returns: // bool Success = successful registration // string? Error = text of error (or null if everuthing is ok) // string? Field = what field has error (or null if everything is ok) @@ -35,20 +37,27 @@ public AuthService(ApplicationDbContext db) // BCrypt from NuGet packet BCrypt.Net-Next; string token = Guid.NewGuid().ToString("N"); - // Generateі unique token for email confirmation - + // Generates unique token for email confirmation + var confLimit = DateTime.UtcNow.AddHours(24); // email confirmation could be valid only during 24 h + + string tokenHash; + using (var sha256 = SHA256.Create()) + { + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); + tokenHash = Convert.ToBase64String(bytes); + } var user = new User { Username = dto.Username, - + PasswordHash = passwordHash, - + Gmail = dto.Gmail, - - EmailConfirmationToken = token - - + + EmailConfirmationTokenHash = tokenHash, + EmailTokenExpiryDate = confLimit + }; _db.Users.Add(user); From e80e65aa9afe56de6a15704c856e8bbe0cca9a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D1=96=D0=BA=D1=82=D0=BE=D1=80=D1=96=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=B2=D0=B8=D1=80=D0=B8=D0=B4?= Date: Thu, 16 Apr 2026 12:07:56 +0300 Subject: [PATCH 04/67] Deleted ConfirmPassword --- Versum/DdContext/ApplicationDbContext.cs | 2 +- Versum/Dtos/RegisterDto.cs | 7 +- ...0416090608_ChangedGmailOnEmail.Designer.cs | 104 ++++++++++++++++++ .../20260416090608_ChangedGmailOnEmail.cs | 38 +++++++ .../ApplicationDbContextModelSnapshot.cs | 12 +- Versum/Models/User.cs | 2 +- Versum/Services/AuthService.cs | 2 +- 7 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs create mode 100644 Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index 66aa017..7b4af6e 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -16,7 +16,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); - modelBuilder.Entity().HasIndex(u => u.Gmail).IsUnique(); + modelBuilder.Entity().HasIndex(u => u.Email).IsUnique(); } } diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs index a809492..efa1d70 100644 --- a/Versum/Dtos/RegisterDto.cs +++ b/Versum/Dtos/RegisterDto.cs @@ -21,15 +21,10 @@ public class RegisterDto public string Password { get; set; } = ""; - [Required(ErrorMessage = "Введіть свій пароль ще раз")] - [Compare("Password", ErrorMessage = "Ваш пароль не збігається")] - public string ConfirmPassword { get; set; } = ""; // field for re-entry password - - [Required(ErrorMessage = "Введіть email")] [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", ErrorMessage = "Неправильний email")] // checks if gmail is in right form [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] - public string Gmail { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; } } diff --git a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs new file mode 100644 index 0000000..7ef6c09 --- /dev/null +++ b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260416090608_ChangedGmailOnEmail")] + partial class ChangedGmailOnEmail + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs new file mode 100644 index 0000000..123751a --- /dev/null +++ b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class ChangedGmailOnEmail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Gmail", + table: "Users", + newName: "Email"); + + migrationBuilder.RenameIndex( + name: "IX_Users_Gmail", + table: "Users", + newName: "IX_Users_Email"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Email", + table: "Users", + newName: "Gmail"); + + migrationBuilder.RenameIndex( + name: "IX_Users_Email", + table: "Users", + newName: "IX_Users_Gmail"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index c15d6d4..66db741 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -61,17 +61,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + b.Property("EmailConfirmationTokenHash") .HasColumnType("text"); b.Property("EmailTokenExpiryDate") .HasColumnType("timestamp with time zone"); - b.Property("Gmail") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - b.Property("IsEmailConfirmed") .HasColumnType("boolean"); @@ -87,7 +87,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("Gmail") + b.HasIndex("Email") .IsUnique(); b.HasIndex("Username") diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index a6d4f12..e26809e 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -11,7 +11,7 @@ public class User [MaxLength(60)] public string PasswordHash { get; set; } = ""; - [MaxLength(50)] public string Gmail { get; set; } = ""; + [MaxLength(50)] public string Email { get; set; } = ""; public bool IsEmailConfirmed { get; set; } = false; // Did user confirm email diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 4b77df2..825199b 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -53,7 +53,7 @@ public AuthService(ApplicationDbContext db) PasswordHash = passwordHash, - Gmail = dto.Gmail, + Email = dto.Email, EmailConfirmationTokenHash = tokenHash, EmailTokenExpiryDate = confLimit From e315544d0682070bacfbfc83d3bb17ed6a810a00 Mon Sep 17 00:00:00 2001 From: Denis Date: Sun, 19 Apr 2026 21:17:51 +0300 Subject: [PATCH 05/67] Feature/user login backend Added login endpoint to AuthController using LoginDto for validation. Integrated IGmailService and GmailService to send login notification emails via Gmail SMTP. Updated AuthService with LoginAsync for credential verification and JWT token generation (dummy token for now). Registered GmailService in DI container. --- Versum/Controllers/AuthController.cs | 29 +++++++++++++++++++- Versum/Dtos/LoginDto.cs | 16 +++++++++++ Versum/Program.cs | 2 ++ Versum/Services/AuthService.cs | 30 +++++++++++++++++++++ Versum/Services/GmailService.cs | 40 ++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 Versum/Dtos/LoginDto.cs create mode 100644 Versum/Services/GmailService.cs diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index b3b2ddd..f1dedf3 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -13,9 +13,12 @@ public class AuthController : ControllerBase { private readonly IAuthService _authService; - public AuthController(IAuthService authService) + private readonly IGmailService _gmailService; + + public AuthController(IAuthService authService, IGmailService gmailService) { _authService = authService; + _gmailService = gmailService; } @@ -35,6 +38,30 @@ public async Task Register([FromBody] RegisterDto dto)// JSON con } + [HttpPost("login")] + public async Task Login([FromBody] LoginDto dto)// JSON converts to LoginDto object + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 + + var (success, resultMessage, userGmail, username) = await _authService.LoginAsync(dto);// calls Service's LoginAsync method and passes dto + + if (!success) + return Unauthorized(new { message = resultMessage }); // checks if login fails (wrong password or user) -> returns error 401 + + try + { + await _gmailService.SendLoginNotificationAsync(userGmail, username);// awaits email sending to avoid crashes + } + catch (Exception ex) + { + Console.WriteLine($"Gmail sending error: {ex.Message}");// logs error but doesn't stop the login process + } + + return Ok(new { token = resultMessage, message = "Вхід успішний" }); + } + + // Allow to see added users in the table(only for dev to try it out): shall be deleted or changed. [HttpGet("users")] public async Task>> GetUsers( diff --git a/Versum/Dtos/LoginDto.cs b/Versum/Dtos/LoginDto.cs new file mode 100644 index 0000000..022f05b --- /dev/null +++ b/Versum/Dtos/LoginDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class LoginDto + { + [Required(ErrorMessage = "Введіть логін або email")] + [MaxLength(50, ErrorMessage = "Поле не може містити більше 50-ти символів")] + public string UsernameOrGmail { get; set; } = string.Empty; + + + [Required(ErrorMessage = "Введіть пароль")] + [MaxLength(20, ErrorMessage = "Пароль не може містити більше 20 символів")] + public string Password { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Versum/Program.cs b/Versum/Program.cs index c9f790c..b14f35d 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -25,6 +25,8 @@ }); builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 825199b..5c6cc5a 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -13,6 +13,7 @@ public interface IAuthService // string? Error = text of error (or null if everuthing is ok) // string? Field = what field has error (or null if everything is ok) + Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto); } public class AuthService : IAuthService { @@ -67,7 +68,36 @@ public AuthService(ApplicationDbContext db) return (true, null, null); + } + public async Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto) + { + + var user = await _db.Users.FirstOrDefaultAsync(u => + u.Gmail == dto.UsernameOrGmail || u.Username == dto.UsernameOrGmail); + + if (user == null) + { + + return (false, "Невірний логін або пароль", string.Empty, string.Empty); + } + + + bool isPasswordValid = BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash); + + if (!isPasswordValid) + { + + return (false, "Невірний логін або пароль", string.Empty, string.Empty); + } + + + string jwtToken = "dummy_jwt_token_here"; + + + return (true, jwtToken, user.Gmail, user.Username); + } + diff --git a/Versum/Services/GmailService.cs b/Versum/Services/GmailService.cs new file mode 100644 index 0000000..c006ad4 --- /dev/null +++ b/Versum/Services/GmailService.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Mail; + +namespace Versum.Services +{ + + public interface IGmailService + { + Task SendLoginNotificationAsync(string toGmail, string username); + } + + public class GmailService : IGmailService + { + // Note: for Gmail, use an "App Password" here, not a regular Google password + private const string SmtpServer = "smtp.gmail.com"; + private const int SmtpPort = 587; + private const string SenderGmail = "your_email@gmail.com"; + private const string SenderPassword = "your_google_app_password"; + + public async Task SendLoginNotificationAsync(string toGmail, string username) + { + using var client = new SmtpClient(SmtpServer, SmtpPort) + { + Credentials = new NetworkCredential(SenderGmail, SenderPassword), + EnableSsl = true // required for Gmail + }; + + var mailMessage = new MailMessage + { + From = new MailAddress(SenderGmail, "Versum App"), + Subject = "Успішний вхід у систему", + Body = $"Привіт, {username}! \n\nВ твій акаунт щойно був здійснений успішний вхід. Якщо це був не ти, негайно зміни пароль.", + IsBodyHtml = false + }; + + mailMessage.To.Add(toGmail); + await client.SendMailAsync(mailMessage); // sends email + } + } +} \ No newline at end of file From 6813ac586c2df6fc4ea8f88749cc95481ff1ff1c Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:56:41 +0300 Subject: [PATCH 06/67] Feature/user forgot password backend * Implemented Password Recovery for users * Implemented Password reset token sending via email * Moved IAuthService into a separate file * Fixed password length being fixed at 8 and improved comments --- Versum/Controllers/AuthController.cs | 37 +++++- Versum/Dtos/ForgotPasswordDto.cs | 11 ++ Versum/Dtos/RegisterDto.cs | 2 +- Versum/Dtos/ResetPasswordDto.cs | 17 +++ ...260419184915_UserTokenDBUpdate.Designer.cs | 110 ++++++++++++++++++ .../20260419184915_UserTokenDBUpdate.cs | 39 +++++++ .../ApplicationDbContextModelSnapshot.cs | 6 + Versum/Models/User.cs | 3 +- Versum/Program.cs | 2 +- Versum/Services/AuthService.cs | 83 ++++++++++--- Versum/Services/EmailService.cs | 50 ++++++++ Versum/Services/GmailService.cs | 40 ------- Versum/Services/IAuthService.cs | 15 +++ Versum/Services/IEmailService.cs | 4 + Versum/Versum.csproj | 1 + 15 files changed, 357 insertions(+), 63 deletions(-) create mode 100644 Versum/Dtos/ForgotPasswordDto.cs create mode 100644 Versum/Dtos/ResetPasswordDto.cs create mode 100644 Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs create mode 100644 Versum/Migrations/20260419184915_UserTokenDBUpdate.cs create mode 100644 Versum/Services/EmailService.cs delete mode 100644 Versum/Services/GmailService.cs create mode 100644 Versum/Services/IAuthService.cs create mode 100644 Versum/Services/IEmailService.cs diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index f1dedf3..c33a564 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -12,13 +12,12 @@ namespace Versum.Controllers public class AuthController : ControllerBase { private readonly IAuthService _authService; + private readonly IEmailService _emailService; - private readonly IGmailService _gmailService; - - public AuthController(IAuthService authService, IGmailService gmailService) + public AuthController(IAuthService authService, IEmailService emailService) { _authService = authService; - _gmailService = gmailService; + _emailService = emailService; } @@ -51,7 +50,7 @@ public async Task Login([FromBody] LoginDto dto)// JSON converts try { - await _gmailService.SendLoginNotificationAsync(userGmail, username);// awaits email sending to avoid crashes + //await _gmailService.SendLoginNotificationAsync(userGmail, username);// awaits email sending to avoid crashes } catch (Exception ex) { @@ -61,6 +60,34 @@ public async Task Login([FromBody] LoginDto dto)// JSON converts return Ok(new { token = resultMessage, message = "Вхід успішний" }); } + [HttpPost("forgot-password")] + public async Task ForgotReset([FromBody] ForgotPasswordDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 + var (success, error) = await _authService.ForgotPasswordAsync(dto); + if (!success) + { + return BadRequest(new { message = error }); + } + + + return Ok(new { message = "Лист надіслано" }); + + } + + [HttpPost("reset-password")] + public async Task ResetReset([FromBody] ResetPasswordDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 + var (success, error) = await _authService.ResetPasswordAsync(dto); + if (!success) + { + return BadRequest(new { message = error }); + } + return Ok(new { message = "Пароль успішно змінено" }); + } // Allow to see added users in the table(only for dev to try it out): shall be deleted or changed. [HttpGet("users")] diff --git a/Versum/Dtos/ForgotPasswordDto.cs b/Versum/Dtos/ForgotPasswordDto.cs new file mode 100644 index 0000000..ac0b264 --- /dev/null +++ b/Versum/Dtos/ForgotPasswordDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class ForgotPasswordDto + { + [Required(ErrorMessage = "Введіть email")] + [MaxLength(50, ErrorMessage = "Поле не може містити більше 50-ти символів")] + public string Email { get; set; } = string.Empty; + } +} diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs index efa1d70..abb9249 100644 --- a/Versum/Dtos/RegisterDto.cs +++ b/Versum/Dtos/RegisterDto.cs @@ -15,7 +15,7 @@ public class RegisterDto [Required(ErrorMessage = "Введіть свій пароль")] [StringLength(20, MinimumLength = 8, ErrorMessage = "Пароль має містити від 8 до 20 символів")] - [RegularExpression(@"^\S{8}$", + [RegularExpression(@"^\S+$", ErrorMessage = "Пароль не може містити пробіли")] public string Password { get; set; } = ""; diff --git a/Versum/Dtos/ResetPasswordDto.cs b/Versum/Dtos/ResetPasswordDto.cs new file mode 100644 index 0000000..a9a09ea --- /dev/null +++ b/Versum/Dtos/ResetPasswordDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class ResetPasswordDto + { + [Required(ErrorMessage = "Введіть код підтвердження")] + [StringLength(6, ErrorMessage = "Код повинен складатися з 6 символів")] + public string Token { get; set; } = string.Empty; + [Required(ErrorMessage = "Введіть новий пароль")] + [StringLength(20, MinimumLength = 8, + ErrorMessage = "Пароль має містити від 8 до 20 символів")] + [RegularExpression(@"^\S{8}$", + ErrorMessage = "Пароль не може містити пробіли")] + public string NewPassword { get; set; } = string.Empty; + } +} diff --git a/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs b/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs new file mode 100644 index 0000000..989afaa --- /dev/null +++ b/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260419184915_UserTokenDBUpdate")] + partial class UserTokenDBUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs b/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs new file mode 100644 index 0000000..9fc0e3b --- /dev/null +++ b/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class UserTokenDBUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordResetToken", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ResetTokenExpires", + table: "Users", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordResetToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "ResetTokenExpires", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 66db741..0b171c7 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -80,6 +80,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(60) .HasColumnType("character varying(60)"); + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + b.Property("Username") .IsRequired() .HasMaxLength(50) diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index e26809e..fe1d5d5 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -19,7 +19,8 @@ public class User public string? EmailConfirmationTokenHash { get; set; } //Unique token Hash which sends on post for confirmation public DateTime? EmailTokenExpiryDate { get; set; } // Limit in Time to conf email public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up - + public string? PasswordResetToken { get; set; } + public DateTime? ResetTokenExpires { get; set; } } } diff --git a/Versum/Program.cs b/Versum/Program.cs index b14f35d..09cef38 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -25,7 +25,7 @@ }); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 5c6cc5a..5e07960 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -5,25 +5,15 @@ namespace Versum.Services { - public interface IAuthService - { - Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto); - // Method returns: - // bool Success = successful registration - // string? Error = text of error (or null if everuthing is ok) - // string? Field = what field has error (or null if everything is ok) - - Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto); - } - public class AuthService : IAuthService { private readonly ApplicationDbContext _db; - public AuthService(ApplicationDbContext db) + private readonly IEmailService _emailService; + public AuthService(ApplicationDbContext db, IEmailService emailService) { _db = db; - + _emailService = emailService; } public async Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto) { @@ -74,7 +64,7 @@ public AuthService(ApplicationDbContext db) { var user = await _db.Users.FirstOrDefaultAsync(u => - u.Gmail == dto.UsernameOrGmail || u.Username == dto.UsernameOrGmail); + u.Email == dto.UsernameOrGmail || u.Username == dto.UsernameOrGmail); if (user == null) { @@ -95,10 +85,73 @@ public AuthService(ApplicationDbContext db) string jwtToken = "dummy_jwt_token_here"; - return (true, jwtToken, user.Gmail, user.Username); + return (true, jwtToken, user.Email, user.Username); } + public async Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.Email); + if (user == null) + { + return (true, null); //Returning true even if email doesn't exit for account safety + } + string ResetToken = RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); + user.PasswordResetToken = ResetToken; + user.ResetTokenExpires = DateTime.UtcNow.AddHours(1); // Token is valid for one hour after creation + + try + { + await _db.SaveChangesAsync(); + + //Email message + string htmlMessage = $@" +
+

Відновлення пароля

+

Ваш код підтвердження:

+

{ResetToken}

+

Цей код дійсний протягом 1 години.

+
"; + + + await _emailService.SendEmailAsync(user.Username, "Код відновлення пароля", htmlMessage); + return (true, null); + } + catch (Exception ex) + { + return (false, "Помилка на сервері при обробці запиту"); + } + } + + public async Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.PasswordResetToken == dto.Token); + if (user == null) + { + return (false, "Недійсний токен."); + } + if (user.ResetTokenExpires < DateTime.UtcNow) + { + return (false, "Термін дії токена вичерпано. Запитуйте відновлення знову."); + } + try + { + user.PasswordResetToken = null; + user.ResetTokenExpires = null; + string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.NewPassword); + + await _db.SaveChangesAsync(); + return (true, null); + } + catch(DbUpdateException ex) + { + return (false, "Сталася помилка при зверненні до бази даних."); + } + catch(Exception ex) + { + return (false, "Сталася непередбачувана помилка на сервері."); + } + } diff --git a/Versum/Services/EmailService.cs b/Versum/Services/EmailService.cs new file mode 100644 index 0000000..69051ca --- /dev/null +++ b/Versum/Services/EmailService.cs @@ -0,0 +1,50 @@ +using MailKit.Net.Smtp; +using MimeKit; +using Microsoft.Extensions.Options; + +public class EmailService : IEmailService +{ + private readonly IConfiguration _config; + + public EmailService(IConfiguration config) + { + _config = config; + } + + public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage) + { + var emailSettings = _config.GetSection("EmailSettings"); + var emailMessage = new MimeKit.MimeMessage(); + + // Email Message Creation + emailMessage.From.Add(new MailboxAddress("Versum", emailSettings["SenderEmail"])); + emailMessage.To.Add(new MailboxAddress("", toEmail)); + emailMessage.Subject = subject; + + // 2. Email body + emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Html) + { + Text = htmlMessage + }; + + // Mailtrap + using (var client = new SmtpClient()) + { + // Connecting to Mailtrap server + await client.ConnectAsync( + emailSettings["SmtpServer"], + int.Parse(emailSettings["Port"]), + MailKit.Security.SecureSocketOptions.StartTls + ); + + // Client Auth + await client.AuthenticateAsync(emailSettings["Username"], emailSettings["Password"]); + + // Sending an email + await client.SendAsync(emailMessage); + + // disconnecting + await client.DisconnectAsync(true); + } + } +} \ No newline at end of file diff --git a/Versum/Services/GmailService.cs b/Versum/Services/GmailService.cs deleted file mode 100644 index c006ad4..0000000 --- a/Versum/Services/GmailService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Net; -using System.Net.Mail; - -namespace Versum.Services -{ - - public interface IGmailService - { - Task SendLoginNotificationAsync(string toGmail, string username); - } - - public class GmailService : IGmailService - { - // Note: for Gmail, use an "App Password" here, not a regular Google password - private const string SmtpServer = "smtp.gmail.com"; - private const int SmtpPort = 587; - private const string SenderGmail = "your_email@gmail.com"; - private const string SenderPassword = "your_google_app_password"; - - public async Task SendLoginNotificationAsync(string toGmail, string username) - { - using var client = new SmtpClient(SmtpServer, SmtpPort) - { - Credentials = new NetworkCredential(SenderGmail, SenderPassword), - EnableSsl = true // required for Gmail - }; - - var mailMessage = new MailMessage - { - From = new MailAddress(SenderGmail, "Versum App"), - Subject = "Успішний вхід у систему", - Body = $"Привіт, {username}! \n\nВ твій акаунт щойно був здійснений успішний вхід. Якщо це був не ти, негайно зміни пароль.", - IsBodyHtml = false - }; - - mailMessage.To.Add(toGmail); - await client.SendMailAsync(mailMessage); // sends email - } - } -} \ No newline at end of file diff --git a/Versum/Services/IAuthService.cs b/Versum/Services/IAuthService.cs new file mode 100644 index 0000000..2738d7e --- /dev/null +++ b/Versum/Services/IAuthService.cs @@ -0,0 +1,15 @@ +using Versum.Dtos; + +public interface IAuthService +{ + Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto); + // Method returns: + // bool Success = successful registration + // string? Error = text of error (or null if everuthing is ok) + // string? Field = what field has error (or null if everything is ok) + + Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto); + + Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto); + Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto); +} \ No newline at end of file diff --git a/Versum/Services/IEmailService.cs b/Versum/Services/IEmailService.cs new file mode 100644 index 0000000..71c9f0e --- /dev/null +++ b/Versum/Services/IEmailService.cs @@ -0,0 +1,4 @@ +public interface IEmailService +{ + Task SendEmailAsync(string toEmail, string subject, string htmlMessage); +} \ No newline at end of file diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index e5c493f..42bf7ff 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -8,6 +8,7 @@ + all From 5b8d488c502d7fda179495e60cbe0ad2a6d6bb12 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:29:09 +0300 Subject: [PATCH 07/67] Hotfixed Emails being sent to an incorrect address --- Versum/Services/AuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 5e07960..c219845 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -113,7 +113,7 @@ public AuthService(ApplicationDbContext db, IEmailService emailService) "; - await _emailService.SendEmailAsync(user.Username, "Код відновлення пароля", htmlMessage); + await _emailService.SendEmailAsync(user.Email, "Код відновлення пароля", htmlMessage); return (true, null); } From 9cd6a14335c51158d25047e5b55b024d75113236 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:33:17 +0300 Subject: [PATCH 08/67] Feature/password reset email template * [REFACTOR] Improved password reset email method * Email template now shows Username * Fixed passwords requiring exactly 8 characters when resetting a password instead of intended 8-20 * Removed a redundant commented method from AuthService and email template copyright line --- Versum/Dtos/ResetPasswordDto.cs | 2 +- .../20260420173851_User-Revert.Designer.cs | 110 ++++++++++++++++++ .../Migrations/20260420173851_User-Revert.cs | 22 ++++ .../20260420174100_User-Hash-Back.Designer.cs | 110 ++++++++++++++++++ .../20260420174100_User-Hash-Back.cs | 22 ++++ Versum/Services/AuthService.cs | 11 +- Versum/Services/EmailService.cs | 22 +++- Versum/Services/IEmailService.cs | 1 + Versum/Templates/ForgotPasswordTemplate.html | 72 ++++++++++++ Versum/Versum.csproj | 6 + 10 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 Versum/Migrations/20260420173851_User-Revert.Designer.cs create mode 100644 Versum/Migrations/20260420173851_User-Revert.cs create mode 100644 Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs create mode 100644 Versum/Migrations/20260420174100_User-Hash-Back.cs create mode 100644 Versum/Templates/ForgotPasswordTemplate.html diff --git a/Versum/Dtos/ResetPasswordDto.cs b/Versum/Dtos/ResetPasswordDto.cs index a9a09ea..3c9476a 100644 --- a/Versum/Dtos/ResetPasswordDto.cs +++ b/Versum/Dtos/ResetPasswordDto.cs @@ -10,7 +10,7 @@ public class ResetPasswordDto [Required(ErrorMessage = "Введіть новий пароль")] [StringLength(20, MinimumLength = 8, ErrorMessage = "Пароль має містити від 8 до 20 символів")] - [RegularExpression(@"^\S{8}$", + [RegularExpression(@"^\S+$", ErrorMessage = "Пароль не може містити пробіли")] public string NewPassword { get; set; } = string.Empty; } diff --git a/Versum/Migrations/20260420173851_User-Revert.Designer.cs b/Versum/Migrations/20260420173851_User-Revert.Designer.cs new file mode 100644 index 0000000..245ca4b --- /dev/null +++ b/Versum/Migrations/20260420173851_User-Revert.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420173851_User-Revert")] + partial class UserRevert + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260420173851_User-Revert.cs b/Versum/Migrations/20260420173851_User-Revert.cs new file mode 100644 index 0000000..06d36de --- /dev/null +++ b/Versum/Migrations/20260420173851_User-Revert.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class UserRevert : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs b/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs new file mode 100644 index 0000000..ebd5d0b --- /dev/null +++ b/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420174100_User-Hash-Back")] + partial class UserHashBack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260420174100_User-Hash-Back.cs b/Versum/Migrations/20260420174100_User-Hash-Back.cs new file mode 100644 index 0000000..f4e586c --- /dev/null +++ b/Versum/Migrations/20260420174100_User-Hash-Back.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class UserHashBack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index c219845..b9e99ad 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -104,16 +104,7 @@ public AuthService(ApplicationDbContext db, IEmailService emailService) await _db.SaveChangesAsync(); //Email message - string htmlMessage = $@" -
-

Відновлення пароля

-

Ваш код підтвердження:

-

{ResetToken}

-

Цей код дійсний протягом 1 години.

-
"; - - - await _emailService.SendEmailAsync(user.Email, "Код відновлення пароля", htmlMessage); + await _emailService.SendResetCodeEmailAsync(user.Email, user.Username, ResetToken); return (true, null); } diff --git a/Versum/Services/EmailService.cs b/Versum/Services/EmailService.cs index 69051ca..8efcca3 100644 --- a/Versum/Services/EmailService.cs +++ b/Versum/Services/EmailService.cs @@ -1,14 +1,30 @@ -using MailKit.Net.Smtp; +using Microsoft.AspNetCore.Hosting; +using MailKit.Net.Smtp; using MimeKit; using Microsoft.Extensions.Options; public class EmailService : IEmailService { private readonly IConfiguration _config; + private readonly IWebHostEnvironment _env; - public EmailService(IConfiguration config) + public EmailService(IConfiguration config, IWebHostEnvironment env) { _config = config; + _env = env; + } + + public async Task SendResetCodeEmailAsync(string toEmail, string userName, string code) + { + string templatePath = Path.Combine(_env.ContentRootPath, "Templates", "ForgotPasswordTemplate.html"); + + string htmlContent = await File.ReadAllTextAsync(templatePath); + + htmlContent = htmlContent + .Replace("{UserName}", userName) + .Replace("{RESET_TOKEN}", code); + + await SendEmailAsync(toEmail, "Ваш код для зміни пароля", htmlContent); } public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage) @@ -21,7 +37,7 @@ public async Task SendEmailAsync(string toEmail, string subject, string htmlMess emailMessage.To.Add(new MailboxAddress("", toEmail)); emailMessage.Subject = subject; - // 2. Email body + // Email body emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Html) { Text = htmlMessage diff --git a/Versum/Services/IEmailService.cs b/Versum/Services/IEmailService.cs index 71c9f0e..2258b1b 100644 --- a/Versum/Services/IEmailService.cs +++ b/Versum/Services/IEmailService.cs @@ -1,4 +1,5 @@ public interface IEmailService { Task SendEmailAsync(string toEmail, string subject, string htmlMessage); + Task SendResetCodeEmailAsync(string toEmail, string userName, string code); } \ No newline at end of file diff --git a/Versum/Templates/ForgotPasswordTemplate.html b/Versum/Templates/ForgotPasswordTemplate.html new file mode 100644 index 0000000..ef92b3c --- /dev/null +++ b/Versum/Templates/ForgotPasswordTemplate.html @@ -0,0 +1,72 @@ + + + + + Відновлення пароля + + + + + + + +
+ + + + + + + + + + + +
+ +

Versum

+

Твоя поетична платформа

+ +

+ Вітаємо, {UserName}! +

+ +

Відновлення пароля

+ +
+

Ми отримали запит на відновлення пароля.

+

Використайте цей код підтвердження:

+
+ +
+
+ {RESET_TOKEN} +
+
+ +
+

Код дійсний протягом 1 години.

+

Якщо Ви не запитували зміну пароля — просто проігноруйте цей лист.

+
+ +
+ +
+ + + \ No newline at end of file diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index 42bf7ff..3f1d97e 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -27,4 +27,10 @@
+ + + PreserveNewest + + +
From 356bc365cb367f89d29e32456def0927f2b202ce Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Sat, 25 Apr 2026 19:53:16 +0300 Subject: [PATCH 09/67] Feature/registration email confirmation (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEATURE] Implemented registration confirmation via email-link * Feature/password reset email template * [REFACTOR] Improved password reset email method * Email template now shows Username * Fixed passwords requiring exactly 8 characters when resetting a password instead of intended 8-20 * Removed a redundant commented method from AuthService and email template copyright line * [Feature] email confirmation template * [FIX] Error upon registering with an existing email * [FIX] Add unique index for EmailConfirmationTokenHash * [FIX] EmailTokenExpiryDate changed to 24h * [FIX] Creating confirmlink by using BaseUrl from appsettings --------- Co-authored-by: Вікторія Свирид Co-authored-by: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> --- Versum/Controllers/AuthController.cs | 25 +++- Versum/DdContext/ApplicationDbContext.cs | 3 +- ...0140306_EmailConfirmationToken.Designer.cs | 110 +++++++++++++++++ .../20260420140306_EmailConfirmationToken.cs | 28 +++++ .../20260420174229_Hash.Designer.cs | 110 +++++++++++++++++ Versum/Migrations/20260420174229_Hash.cs | 28 +++++ ...60425103338_AddEmailTokenIndex.Designer.cs | 113 ++++++++++++++++++ .../20260425103338_AddEmailTokenIndex.cs | 28 +++++ .../ApplicationDbContextModelSnapshot.cs | 3 + Versum/Models/User.cs | 2 +- Versum/Services/AuthService.cs | 76 +++++++++--- Versum/Services/IAuthService.cs | 1 + .../Templates/ConfRegistrationTemplate.html | 73 +++++++++++ Versum/Versum.csproj | 10 ++ Versum/appsettings.json | 3 + 15 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs create mode 100644 Versum/Migrations/20260420140306_EmailConfirmationToken.cs create mode 100644 Versum/Migrations/20260420174229_Hash.Designer.cs create mode 100644 Versum/Migrations/20260420174229_Hash.cs create mode 100644 Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs create mode 100644 Versum/Migrations/20260425103338_AddEmailTokenIndex.cs create mode 100644 Versum/Templates/ConfRegistrationTemplate.html diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index c33a564..a0cd7c7 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -21,6 +21,7 @@ public AuthController(IAuthService authService, IEmailService emailService) } + [HttpPost("register")] public async Task Register([FromBody] RegisterDto dto)// JSON converts to RegisterDtos object @@ -33,10 +34,32 @@ public async Task Register([FromBody] RegisterDto dto)// JSON con if (!success) return Conflict(new { field, message = error }); //checks if data for transfer does not cause conflicts(error 409) - return Ok(new { message = "Реєстрація успішна" }); + return Ok(new { message = "Реєстрація успішна! Перевірте пошту для підтвердження." }); } + + + [HttpGet("confirm-email")] + public async Task ConfirmEmail([FromQuery] string token) + { + if (string.IsNullOrWhiteSpace(token)) + return BadRequest("Токен відсутній"); + + var (success, error) = await _authService.ConfirmEmailAsync(token); + + if (!success) + // redirect on page with error + return BadRequest(new { message = error }); + /* return Redirect($"https://localhost:7014.com/email-confirmed?success=false&error={Uri.EscapeDataString(error!)}"); */ + + // successfull: redirect on main page + return Ok(new { message = "Email успішно підтверджено!" }); + /* return Redirect("https://localhost:7014.com/email-confirmed?success=true");*/ + } + + + [HttpPost("login")] public async Task Login([FromBody] LoginDto dto)// JSON converts to LoginDto object { diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index 7b4af6e..ceb56b5 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -17,7 +17,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity().HasIndex(u => u.Email).IsUnique(); - + modelBuilder.Entity().HasIndex(u => u.EmailConfirmationTokenHash).IsUnique(); + } } } diff --git a/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs b/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs new file mode 100644 index 0000000..ce63f06 --- /dev/null +++ b/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420140306_EmailConfirmationToken")] + partial class EmailConfirmationToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationToken") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260420140306_EmailConfirmationToken.cs b/Versum/Migrations/20260420140306_EmailConfirmationToken.cs new file mode 100644 index 0000000..c0e327b --- /dev/null +++ b/Versum/Migrations/20260420140306_EmailConfirmationToken.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class EmailConfirmationToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailConfirmationTokenHash", + table: "Users", + newName: "EmailConfirmationToken"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailConfirmationToken", + table: "Users", + newName: "EmailConfirmationTokenHash"); + } + } +} diff --git a/Versum/Migrations/20260420174229_Hash.Designer.cs b/Versum/Migrations/20260420174229_Hash.Designer.cs new file mode 100644 index 0000000..ed9394e --- /dev/null +++ b/Versum/Migrations/20260420174229_Hash.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420174229_Hash")] + partial class Hash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260420174229_Hash.cs b/Versum/Migrations/20260420174229_Hash.cs new file mode 100644 index 0000000..c79822e --- /dev/null +++ b/Versum/Migrations/20260420174229_Hash.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Hash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailConfirmationToken", + table: "Users", + newName: "EmailConfirmationTokenHash"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailConfirmationTokenHash", + table: "Users", + newName: "EmailConfirmationToken"); + } + } +} diff --git a/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs b/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs new file mode 100644 index 0000000..e7723ee --- /dev/null +++ b/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs @@ -0,0 +1,113 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260425103338_AddEmailTokenIndex")] + partial class AddEmailTokenIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs b/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs new file mode 100644 index 0000000..7ccf556 --- /dev/null +++ b/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddEmailTokenIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users", + column: "EmailConfirmationTokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 0b171c7..b33d3f4 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -96,6 +96,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Email") .IsUnique(); + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + b.HasIndex("Username") .IsUnique(); diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index fe1d5d5..eb1d2ff 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -16,7 +16,7 @@ public class User public bool IsEmailConfirmed { get; set; } = false; // Did user confirm email - public string? EmailConfirmationTokenHash { get; set; } //Unique token Hash which sends on post for confirmation + public string? EmailConfirmationTokenHash { get; set; } //Unique token which sends on post for confirmation public DateTime? EmailTokenExpiryDate { get; set; } // Limit in Time to conf email public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up public string? PasswordResetToken { get; set; } diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index b9e99ad..82617f2 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using MailKit.Security; +using Microsoft.EntityFrameworkCore; +using MimeKit; +using System.Buffers.Text; using System.Security.Cryptography; using System.Text; using Versum.Dtos; @@ -9,34 +12,42 @@ public class AuthService : IAuthService { private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; - public AuthService(ApplicationDbContext db, IEmailService emailService) - + private readonly IConfiguration _configuration; + public AuthService(ApplicationDbContext db,IEmailService emailService,IConfiguration configuration) { _db = db; - _emailService = emailService; + _emailService = emailService; + _configuration = configuration; } public async Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto) { bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); + bool emailExists = await _db.Users.AnyAsync(e => e.Email == dto.Email); if (usernameExists) return (false, "Цей нікнейм вже існує", "username"); - + if (emailExists) + return (false, "Цей імейл вже існує", "email"); + string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password); // Hashing password before saving by algorythm // BCrypt from NuGet packet BCrypt.Net-Next; - string token = Guid.NewGuid().ToString("N"); - // Generates unique token for email confirmation - var confLimit = DateTime.UtcNow.AddHours(24); // email confirmation could be valid only during 24 h - string tokenHash; + string registerToken = Guid.NewGuid().ToString(); + + // Generates unique token for email confirmation + string RegisterTokenHash; using (var sha256 = SHA256.Create()) { - var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); - tokenHash = Convert.ToBase64String(bytes); + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(registerToken)); + RegisterTokenHash = Convert.ToBase64String(bytes); } + + var confLimit = DateTime.UtcNow.AddHours(24); // email confirmation could be valid only during 24 h + + var user = new User { @@ -46,20 +57,57 @@ public AuthService(ApplicationDbContext db, IEmailService emailService) Email = dto.Email, - EmailConfirmationTokenHash = tokenHash, + EmailConfirmationTokenHash = RegisterTokenHash, EmailTokenExpiryDate = confLimit }; _db.Users.Add(user); - await _db.SaveChangesAsync(); - + + + var confirmLink = $"{_configuration["AppSettings:BaseUrl"]}/api/Auth/confirm-email?token={Uri.EscapeDataString(registerToken)}&email={dto.Email}"; + var filePath = Path.Combine(AppContext.BaseDirectory, "Templates", "ConfRegistrationTemplate.html"); + + + string htmlBody = await File.ReadAllTextAsync(filePath); + htmlBody = htmlBody.Replace("{Username}", dto.Username) + .Replace("{confirmLink}", confirmLink); + + await _emailService.SendEmailAsync(dto.Email, "Підтвердження реєстрації — Versum", htmlBody); + return (true, null, null); } + + public async Task<(bool success, string? error)> ConfirmEmailAsync(string token) + { + string incomingHash; + using (var sha256 = SHA256.Create()) + { + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); + incomingHash = Convert.ToBase64String(bytes); + } + + + var user = await _db.Users.FirstOrDefaultAsync(u => + u.EmailConfirmationTokenHash == incomingHash && + u.EmailTokenExpiryDate > DateTime.UtcNow + ); + + if (user is null) + return (false, "Посилання недійсне або термін дії вичерпано"); + + user.IsEmailConfirmed = true; + user.EmailConfirmationTokenHash = null; + user.EmailTokenExpiryDate = null; + + await _db.SaveChangesAsync(); + + return (true, null); + } public async Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto) { diff --git a/Versum/Services/IAuthService.cs b/Versum/Services/IAuthService.cs index 2738d7e..c94f2d5 100644 --- a/Versum/Services/IAuthService.cs +++ b/Versum/Services/IAuthService.cs @@ -8,6 +8,7 @@ public interface IAuthService // string? Error = text of error (or null if everuthing is ok) // string? Field = what field has error (or null if everything is ok) + Task<(bool success, string? error)> ConfirmEmailAsync(string token); Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto); Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto); diff --git a/Versum/Templates/ConfRegistrationTemplate.html b/Versum/Templates/ConfRegistrationTemplate.html new file mode 100644 index 0000000..e04d168 --- /dev/null +++ b/Versum/Templates/ConfRegistrationTemplate.html @@ -0,0 +1,73 @@ + + + + + Підтвердження email + + + + + + + +
+ + + + + + + + + + + +
+ +

Versum

+

Твоя поетична платформа

+ +

+ Вітаємо, {Username}! +

+ +

Підтвердження пошти

+ +
+

Дякуємо за реєстрацію у Versum!

+

Натисніть кнопку нижче, щоб підтвердити реєстрацію та почати ділитись творчістю:

+
+ + + +
+

Посилання дійсне протягом 24 години.

+

Якщо Ви не створювали акаунт у Versum — просто проігноруйте цей лист.

+
+ +
+ +
+ + + \ No newline at end of file diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index 3f1d97e..5c6bf76 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -6,6 +6,16 @@ enable + + + + + + + PreserveNewest + + + diff --git a/Versum/appsettings.json b/Versum/appsettings.json index 10f68b8..eb89863 100644 --- a/Versum/appsettings.json +++ b/Versum/appsettings.json @@ -1,4 +1,7 @@ { + "AppSettings": { + "BaseUrl": "https://localhost:7014" + }, "Logging": { "LogLevel": { "Default": "Information", From 6de6eb66ee08421c673f1afb6512b5d0615eb22c Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:55:25 +0300 Subject: [PATCH 10/67] Feature/user forgot password backend fix * [FIX] Fixed passwords not being updated in the DB, added token checking feature, fixed typos * Added email format validation and renamed token validation method --- Versum/Controllers/AuthController.cs | 17 ++++++++++++++++- Versum/DdContext/ApplicationDbContext.cs | 2 ++ Versum/Dtos/ResetPasswordDto.cs | 7 +++++++ Versum/Dtos/ResetPasswordTokenDto.cs | 17 +++++++++++++++++ Versum/Services/AuthService.cs | 22 +++++++++++++++++++++- Versum/Services/IAuthService.cs | 1 + 6 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 Versum/Dtos/ResetPasswordTokenDto.cs diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index a0cd7c7..752901a 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -100,7 +100,7 @@ public async Task ForgotReset([FromBody] ForgotPasswordDto dto) } [HttpPost("reset-password")] - public async Task ResetReset([FromBody] ResetPasswordDto dto) + public async Task ResetPassword([FromBody] ResetPasswordDto dto) { if (!ModelState.IsValid) return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 @@ -112,6 +112,21 @@ public async Task ResetReset([FromBody] ResetPasswordDto dto) return Ok(new { message = "Пароль успішно змінено" }); } + [HttpPost("reset-password-token-check")] + public async Task ResetPasswordTokenCheck([FromBody] ResetPasswordTokenDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 + + var (success, error) = await _authService.ResetPasswordTokenCheckAsync(dto); + + if (!success) + { + return BadRequest(new { message = error }); + } + return Ok(new { message = "Токен Підтверджено" }); + } + // Allow to see added users in the table(only for dev to try it out): shall be deleted or changed. [HttpGet("users")] public async Task>> GetUsers( diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index ceb56b5..28d2ba1 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -17,6 +17,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity().HasIndex(u => u.Email).IsUnique(); + modelBuilder.Entity() + .HasIndex(u => new { u.Email, u.PasswordResetToken }); modelBuilder.Entity().HasIndex(u => u.EmailConfirmationTokenHash).IsUnique(); } diff --git a/Versum/Dtos/ResetPasswordDto.cs b/Versum/Dtos/ResetPasswordDto.cs index 3c9476a..0f8e5c2 100644 --- a/Versum/Dtos/ResetPasswordDto.cs +++ b/Versum/Dtos/ResetPasswordDto.cs @@ -7,11 +7,18 @@ public class ResetPasswordDto [Required(ErrorMessage = "Введіть код підтвердження")] [StringLength(6, ErrorMessage = "Код повинен складатися з 6 символів")] public string Token { get; set; } = string.Empty; + [Required(ErrorMessage = "Введіть новий пароль")] [StringLength(20, MinimumLength = 8, ErrorMessage = "Пароль має містити від 8 до 20 символів")] [RegularExpression(@"^\S+$", ErrorMessage = "Пароль не може містити пробіли")] public string NewPassword { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введіть email")] + [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", + ErrorMessage = "Неправильний email")] // checks if gmail is in right form + [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] + public string Email { get; set; } = string.Empty; } } diff --git a/Versum/Dtos/ResetPasswordTokenDto.cs b/Versum/Dtos/ResetPasswordTokenDto.cs new file mode 100644 index 0000000..fbae4df --- /dev/null +++ b/Versum/Dtos/ResetPasswordTokenDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class ResetPasswordTokenDto + { + [Required(ErrorMessage = "Введіть код підтвердження")] + [StringLength(6, ErrorMessage = "Код повинен складатися з 6 символів")] + public string Token { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введіть email")] + [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", + ErrorMessage = "Неправильний email")] // checks if gmail is in right form + [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] + public string Email { get; set; } = string.Empty; + } +} diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 82617f2..08b9a51 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -164,7 +164,10 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura public async Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto) { - var user = await _db.Users.FirstOrDefaultAsync(u => u.PasswordResetToken == dto.Token); + var user = await _db.Users.FirstOrDefaultAsync( + u => u.Email == dto.Email + && u.PasswordResetToken == dto.Token + ); if (user == null) { return (false, "Недійсний токен."); @@ -178,6 +181,7 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura user.PasswordResetToken = null; user.ResetTokenExpires = null; string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.NewPassword); + user.PasswordHash = passwordHash; await _db.SaveChangesAsync(); return (true, null); @@ -192,7 +196,23 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura } } + public async Task<(bool success, string? error)> ResetPasswordTokenCheckAsync(ResetPasswordTokenDto dto) + { + var user = await _db.Users.FirstOrDefaultAsync( + u => u.Email == dto.Email + && u.PasswordResetToken == dto.Token + ); + if (user == null) + { + return (false, "Недійсний токен."); + } + if (user.ResetTokenExpires < DateTime.UtcNow) + { + return (false, "Термін дії токена вичерпано. Запитуйте відновлення знову."); + } + return (true, null); + } } } diff --git a/Versum/Services/IAuthService.cs b/Versum/Services/IAuthService.cs index c94f2d5..3ee096c 100644 --- a/Versum/Services/IAuthService.cs +++ b/Versum/Services/IAuthService.cs @@ -13,4 +13,5 @@ public interface IAuthService Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto); Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto); + Task<(bool success, string? error)> ResetPasswordTokenCheckAsync(ResetPasswordTokenDto dto); } \ No newline at end of file From 9c6439f065d51e5f3ff9cb5225bc992aa06d5891 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:21:22 +0300 Subject: [PATCH 11/67] Add or update the Azure App Service build and deployment workflow config --- .github/workflows/develop_versum.yml | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/develop_versum.yml diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml new file mode 100644 index 0000000..a8a9ca2 --- /dev/null +++ b/.github/workflows/develop_versum.yml @@ -0,0 +1,65 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy ASP.Net Core app to Azure Web App - Versum + +on: + push: + branches: + - develop + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read #This is required for actions/checkout + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Build with dotnet + run: dotnet build --configuration Release + + - name: dotnet publish + run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: .net-app + path: ${{env.DOTNET_ROOT}}/myapp + + deploy: + runs-on: ubuntu-latest + needs: build + permissions: + id-token: write #This is required for requesting the JWT + contents: read #This is required for actions/checkout + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: .net-app + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_4DD1EC8B47024BD99BA5F65587876A67 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_726EC5D313014335B630675FC8206A35 }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5EB1055CD681463E9EC60B4848B073FE }} + + - name: Deploy to Azure Web App + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'Versum' + slot-name: 'Production' + package: . + \ No newline at end of file From 08f0714c1e7cccb955c6f7a4435b3fed900559b9 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:27:27 +0300 Subject: [PATCH 12/67] Add or update the Azure App Service build and deployment workflow config --- .github/workflows/develop_versum.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index a8a9ca2..4734a58 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -51,9 +51,9 @@ jobs: - name: Login to Azure uses: azure/login@v2 with: - client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_4DD1EC8B47024BD99BA5F65587876A67 }} - tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_726EC5D313014335B630675FC8206A35 }} - subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5EB1055CD681463E9EC60B4848B073FE }} + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_5E1BC94159C64267A043F9099A4C5F99 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_A1E48CA181014019B830FABAF530E0B3 }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_E7D51F17967C4247B87128E9606C8BA3 }} - name: Deploy to Azure Web App id: deploy-to-webapp From ef327ca654211332bc4601ac984f3c0d6f5a7a10 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:38:25 +0300 Subject: [PATCH 13/67] Add or update the Azure App Service build and deployment workflow config --- .github/workflows/develop_versum.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index 4734a58..af4a3fa 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -51,9 +51,9 @@ jobs: - name: Login to Azure uses: azure/login@v2 with: - client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_5E1BC94159C64267A043F9099A4C5F99 }} - tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_A1E48CA181014019B830FABAF530E0B3 }} - subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_E7D51F17967C4247B87128E9606C8BA3 }} + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_E1A151645519484388D8610703C3F319 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_7EA0EA099A634F608D8A7349EBE6DAFE }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_89B30947B5B34F7B8B916AF82746C262 }} - name: Deploy to Azure Web App id: deploy-to-webapp From aff7ad7c941d3eb7af6d89fd3efeb01ba16f3817 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sun, 26 Apr 2026 04:20:23 +0300 Subject: [PATCH 14/67] Configured CORS for public domain (#10) --- Versum/Program.cs | 5 ++++- Versum/appsettings.json | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Versum/Program.cs b/Versum/Program.cs index 09cef38..99008f1 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -15,9 +15,12 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() + ?? new[] { "https://versum.social" }; + builder.Services.AddCors(options => { options.AddPolicy("NuxtPolicy", policy => { - policy.WithOrigins("http://localhost:3000") + policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); diff --git a/Versum/appsettings.json b/Versum/appsettings.json index eb89863..d8e11cf 100644 --- a/Versum/appsettings.json +++ b/Versum/appsettings.json @@ -1,6 +1,6 @@ { "AppSettings": { - "BaseUrl": "https://localhost:7014" + "BaseUrl": "https://backend.versum.social" }, "Logging": { "LogLevel": { @@ -8,5 +8,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "AllowedOrigins": [ "https://versum.social", "https://www.versum.social" ] } From ff1faea0a6a46748b71e9d16b7c6d940bcbe2d10 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:04:52 +0300 Subject: [PATCH 15/67] Feature/change profile information (#9) * [FEATURE] Implemented UserProfile table and Update Profile method * [FIX] Fixed Profile Update Method logic, added postman files to gitignore * [HOTFIX] Fixed user profile information not being updated in the db and added unique username check * [FIX] Updated SendEmail method to catch exceptions * [FIX] various typos fixed --- .gitignore | 9 + Versum/Controllers/ProfileController.cs | 44 +++++ Versum/DdContext/ApplicationDbContext.cs | 7 + Versum/Dtos/RegisterDto.cs | 4 +- Versum/Dtos/ResetPasswordDto.cs | 2 +- Versum/Dtos/ResetPasswordTokenDto.cs | 4 +- Versum/Dtos/UserProfileDto.cs | 23 +++ ...20260425185401_add UserProfile.Designer.cs | 162 ++++++++++++++++++ .../20260425185401_add UserProfile.cs | 69 ++++++++ .../ApplicationDbContextModelSnapshot.cs | 49 ++++++ Versum/Models/User.cs | 2 + Versum/Models/UserProfile.cs | 18 ++ Versum/Program.cs | 25 ++- Versum/Services/AuthService.cs | 34 +++- Versum/Services/EmailService.cs | 26 +-- Versum/Services/IProfileService.cs | 6 + Versum/Services/ProfileService.cs | 61 +++++++ Versum/Versum.csproj | 2 +- 18 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 Versum/Controllers/ProfileController.cs create mode 100644 Versum/Dtos/UserProfileDto.cs create mode 100644 Versum/Migrations/20260425185401_add UserProfile.Designer.cs create mode 100644 Versum/Migrations/20260425185401_add UserProfile.cs create mode 100644 Versum/Models/UserProfile.cs create mode 100644 Versum/Services/IProfileService.cs create mode 100644 Versum/Services/ProfileService.cs diff --git a/.gitignore b/.gitignore index 46a79e8..e2947c0 100644 --- a/.gitignore +++ b/.gitignore @@ -422,3 +422,12 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Postman +*.postman_environment.json +*.postman_globals.json + +# Postman local settings and metadata +.postman/ +postman/ +*.yaml diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs new file mode 100644 index 0000000..05fe34d --- /dev/null +++ b/Versum/Controllers/ProfileController.cs @@ -0,0 +1,44 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Versum.Services; + +namespace Versum.Controllers +{ + + + [ApiController] + [Route("api/[controller]")] + public class ProfileController : ControllerBase + { + private readonly IProfileService _profileService; + + public ProfileController(IProfileService profileService) + { + _profileService = profileService; + } + + [Authorize] + [HttpPost("update-profile")] + public async Task UpdateProfile([FromBody] UserProfileDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 + + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + + if (userIdClaim == null) return Unauthorized(); + + int currentUserId = int.Parse(userIdClaim.Value); + + var (success, error) = await _profileService.UpdateProfileAsync(currentUserId, dto); + if (!success) + { + return BadRequest(new { message = error }); + } + return Ok(new { message = "Профіль успішно оновлено" }); + } + + } +} diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index 28d2ba1..3599e03 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -11,16 +11,23 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Posts { get; set; } public DbSet Users { get; set; } + public DbSet Profiles { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity().HasIndex(u => u.Email).IsUnique(); + modelBuilder.Entity() .HasIndex(u => new { u.Email, u.PasswordResetToken }); modelBuilder.Entity().HasIndex(u => u.EmailConfirmationTokenHash).IsUnique(); + modelBuilder.Entity() + .HasOne(u => u.Profile) + .WithOne(p => p.User) + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs index abb9249..f639b08 100644 --- a/Versum/Dtos/RegisterDto.cs +++ b/Versum/Dtos/RegisterDto.cs @@ -9,7 +9,7 @@ public class RegisterDto [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can't be null [RegularExpression(@"^[a-z0-9_]+$", ErrorMessage = "Нікнейм може містити лише цифри, малі літери та підкреслення")] // Allowed symbols: only lowercase (a-z), numbers (0-9), underscores (_) - [MaxLength( 50, ErrorMessage = "поле нікнейму неможе містити більше 50-ти символів")] + [MaxLength( 50, ErrorMessage = "Поле нікнейму неможе містити більше 50-ти символів")] public string Username { get; set; } = ""; [Required(ErrorMessage = "Введіть свій пароль")] @@ -24,7 +24,7 @@ public class RegisterDto [Required(ErrorMessage = "Введіть email")] [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", ErrorMessage = "Неправильний email")] // checks if gmail is in right form - [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] + [MaxLength(50, ErrorMessage = "Поле email не може містити більше 50-ти символів")] public string Email { get; set; } = string.Empty; } } diff --git a/Versum/Dtos/ResetPasswordDto.cs b/Versum/Dtos/ResetPasswordDto.cs index 0f8e5c2..afc431d 100644 --- a/Versum/Dtos/ResetPasswordDto.cs +++ b/Versum/Dtos/ResetPasswordDto.cs @@ -18,7 +18,7 @@ public class ResetPasswordDto [Required(ErrorMessage = "Введіть email")] [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", ErrorMessage = "Неправильний email")] // checks if gmail is in right form - [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] + [MaxLength(50, ErrorMessage = "Поле email не може містити більше 50-ти символів")] public string Email { get; set; } = string.Empty; } } diff --git a/Versum/Dtos/ResetPasswordTokenDto.cs b/Versum/Dtos/ResetPasswordTokenDto.cs index fbae4df..5844831 100644 --- a/Versum/Dtos/ResetPasswordTokenDto.cs +++ b/Versum/Dtos/ResetPasswordTokenDto.cs @@ -10,8 +10,8 @@ public class ResetPasswordTokenDto [Required(ErrorMessage = "Введіть email")] [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", - ErrorMessage = "Неправильний email")] // checks if gmail is in right form - [MaxLength(50, ErrorMessage = "поле email неможе містити більше 50-ти символів")] + ErrorMessage = "Неправильний email")] + [MaxLength(50, ErrorMessage = "Поле email не може містити більше 50-ти символів")] public string Email { get; set; } = string.Empty; } } diff --git a/Versum/Dtos/UserProfileDto.cs b/Versum/Dtos/UserProfileDto.cs new file mode 100644 index 0000000..97b355e --- /dev/null +++ b/Versum/Dtos/UserProfileDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class UserProfileDto + { + [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can't be null + [RegularExpression(@"^[a-z0-9_]+$", + ErrorMessage = "Нікнейм може містити лише цифри, малі літери та підкреслення")] // Allowed symbols: only lowercase (a-z), numbers (0-9), underscores (_) + [MaxLength(50, ErrorMessage = "Поле нікнейму не може містити більше 50-ти символів")] + public string Username { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введіть своє ім'я")] + [MaxLength(30, ErrorMessage = "Поле імені не може містити більше 30-ти символів")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "Введіть свою біографію")] + [MaxLength(200, ErrorMessage = "Поле біографії не може містити більше 200-ти символів")] + public string Bio { get; set; } = string.Empty; + + + } +} \ No newline at end of file diff --git a/Versum/Migrations/20260425185401_add UserProfile.Designer.cs b/Versum/Migrations/20260425185401_add UserProfile.Designer.cs new file mode 100644 index 0000000..a4a5b84 --- /dev/null +++ b/Versum/Migrations/20260425185401_add UserProfile.Designer.cs @@ -0,0 +1,162 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260425185401_add UserProfile")] + partial class addUserProfile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260425185401_add UserProfile.cs b/Versum/Migrations/20260425185401_add UserProfile.cs new file mode 100644 index 0000000..97b1af6 --- /dev/null +++ b/Versum/Migrations/20260425185401_add UserProfile.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class addUserProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "Profiles", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + Bio = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + UserId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Profiles", x => x.Id); + table.ForeignKey( + name: "FK_Profiles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users", + columns: new[] { "Email", "PasswordResetToken" }); + + migrationBuilder.CreateIndex( + name: "IX_Profiles_UserId", + table: "Profiles", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Profiles"); + + migrationBuilder.DropIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index b33d3f4..a96c880 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -72,6 +72,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailTokenExpiryDate") .HasColumnType("timestamp with time zone"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("IsEmailConfirmed") .HasColumnType("boolean"); @@ -102,8 +105,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Username") .IsUnique(); + b.HasIndex("Email", "PasswordResetToken"); + b.ToTable("Users"); }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("Profile") + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index eb1d2ff..f74b71d 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -21,6 +21,8 @@ public class User public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up public string? PasswordResetToken { get; set; } public DateTime? ResetTokenExpires { get; set; } + public UserProfile Profile { get; set; } + public bool IsDeleted { get; set; } } } diff --git a/Versum/Models/UserProfile.cs b/Versum/Models/UserProfile.cs new file mode 100644 index 0000000..77e6bba --- /dev/null +++ b/Versum/Models/UserProfile.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +namespace Versum +{ + public class UserProfile + { + public int Id { get; set; } + + [MaxLength(30)] public string? Name { get; set; } + + [MaxLength(200)] public string? Bio { get; set; } + + + + public int UserId { get; set; } + public User User { get; set; } = null!; + + } +} diff --git a/Versum/Program.cs b/Versum/Program.cs index 99008f1..14ccc18 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -1,13 +1,31 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.Text; using Versum; using Versum.Hubs; using Versum.Services; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, // Перевіряє, чи не прострочений токен + ValidateIssuerSigningKey = true, // Перевіряє підпис сервера + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) + }; + }); +builder.Services.AddAuthorization(); builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSignalR(); @@ -29,9 +47,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -42,8 +63,6 @@ app.UseHttpsRedirection(); -app.UseAuthorization(); - app.UseCors("NuxtPolicy"); app.MapHub("/notificationHub"); diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 08b9a51..2768487 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -1,7 +1,7 @@ -using MailKit.Security; using Microsoft.EntityFrameworkCore; -using MimeKit; -using System.Buffers.Text; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; using System.Security.Cryptography; using System.Text; using Versum.Dtos; @@ -129,13 +129,35 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura return (false, "Невірний логін або пароль", string.Empty, string.Empty); } - - string jwtToken = "dummy_jwt_token_here"; - + string jwtToken = GenerateJwtToken(user); + + return (true, jwtToken, user.Email, user.Username); } + public string GenerateJwtToken(User user) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), // currentUserId + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"])); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _configuration["Jwt:Issuer"], + audience: _configuration["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpireMinutes"])), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } public async Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.Email); diff --git a/Versum/Services/EmailService.cs b/Versum/Services/EmailService.cs index 8efcca3..c98d8f0 100644 --- a/Versum/Services/EmailService.cs +++ b/Versum/Services/EmailService.cs @@ -47,20 +47,26 @@ public async Task SendEmailAsync(string toEmail, string subject, string htmlMess using (var client = new SmtpClient()) { // Connecting to Mailtrap server - await client.ConnectAsync( - emailSettings["SmtpServer"], - int.Parse(emailSettings["Port"]), - MailKit.Security.SecureSocketOptions.StartTls - ); + try { + await client.ConnectAsync( + emailSettings["SmtpServer"], + int.Parse(emailSettings["Port"]), + MailKit.Security.SecureSocketOptions.StartTls + ); // Client Auth - await client.AuthenticateAsync(emailSettings["Username"], emailSettings["Password"]); + await client.AuthenticateAsync(emailSettings["Username"], emailSettings["Password"]); // Sending an email - await client.SendAsync(emailMessage); - - // disconnecting - await client.DisconnectAsync(true); + await client.SendAsync(emailMessage); + } catch (Exception ex) + { + Console.WriteLine($"Mail Error: {ex.Message}"); + } finally + { + // disconnecting + await client.DisconnectAsync(true); + } } } } \ No newline at end of file diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs new file mode 100644 index 0000000..32e9751 --- /dev/null +++ b/Versum/Services/IProfileService.cs @@ -0,0 +1,6 @@ +using Versum.Dtos; + +public interface IProfileService +{ + Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto); +} \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs new file mode 100644 index 0000000..a673780 --- /dev/null +++ b/Versum/Services/ProfileService.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; +using Versum.Dtos; + +namespace Versum.Services +{ + public class ProfileService : IProfileService + { + + private readonly ApplicationDbContext _db; + public ProfileService(ApplicationDbContext db) + + { + _db = db; + } + public async Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto) + { + var user = await _db.Users + .Include(u => u.Profile) + .FirstOrDefaultAsync(u => u.Id == UserId); + if (user == null) + { + return (false,"Чому нас вважають за одну людину?"); + } + bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); + if (usernameExists) + return (false, "Цей нікнейм вже існує"); + + try + { + if (user.Profile == null) + { + user.Profile = new UserProfile(); + } + if (user.Username != dto.Username) + { + user.Username = dto.Username; + } + if (user.Profile.Name != dto.Name) + { + user.Profile.Name = dto.Name; + } + if (user.Profile.Bio != dto.Bio) + { + user.Profile.Bio = dto.Bio; + } + await _db.SaveChangesAsync(); + return (true, null); + } + catch (DbUpdateException ex) + { + return (false, "Сталася помилка при зверненні до бази даних."); + } + catch (Exception ex) + { + return (false, "Сталася непередбачувана помилка на сервері."); + } + } + } +} diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index 5c6bf76..d8db822 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -19,6 +19,7 @@ + all @@ -34,7 +35,6 @@ - From a859c1a06ce3ab2d698820c4e8b69d87820aabb8 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:11:28 +0300 Subject: [PATCH 16/67] [HOTFIX] Fixed users not being able to keep their username when changing profile information (#12) --- Versum/Program.cs | 4 ++-- Versum/Services/ProfileService.cs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Versum/Program.cs b/Versum/Program.cs index 14ccc18..ded5c29 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -51,6 +51,8 @@ var app = builder.Build(); +app.UseCors("NuxtPolicy"); + app.UseAuthentication(); app.UseAuthorization(); @@ -63,8 +65,6 @@ app.UseHttpsRedirection(); -app.UseCors("NuxtPolicy"); - app.MapHub("/notificationHub"); diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index a673780..7a82980 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -23,8 +23,9 @@ public ProfileService(ApplicationDbContext db) { return (false,"Чому нас вважають за одну людину?"); } + bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); - if (usernameExists) + if (usernameExists && user.Username != dto.Username) return (false, "Цей нікнейм вже існує"); try From 35e59946f3769391a3c11483e423120fb3e4b735 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:15:02 +0300 Subject: [PATCH 17/67] [Feature] Profile Page Get Info (#11) --- Versum/Controllers/ProfileController.cs | 12 ++++++++++++ Versum/Dtos/UserProfileResponseDto.cs | 10 ++++++++++ Versum/Services/IProfileService.cs | 1 + Versum/Services/ProfileService.cs | 14 ++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 Versum/Dtos/UserProfileResponseDto.cs diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index 05fe34d..971fe6a 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -40,5 +40,17 @@ public async Task UpdateProfile([FromBody] UserProfileDto dto) return Ok(new { message = "Профіль успішно оновлено" }); } + [HttpGet("profile/{username}")] + public async Task GetProfile(string username) + { + var profile = await _profileService.GetProfileByUsernameAsync(username.ToLower()); + + if (profile == null) + { + return NotFound(new { message = "Користувача не знайдено" }); + } + + return Ok(profile); + } } } diff --git a/Versum/Dtos/UserProfileResponseDto.cs b/Versum/Dtos/UserProfileResponseDto.cs new file mode 100644 index 0000000..21221d9 --- /dev/null +++ b/Versum/Dtos/UserProfileResponseDto.cs @@ -0,0 +1,10 @@ +namespace Versum.Dtos +{ + public class UserProfileResponseDto + { + public string Username { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + } +} diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index 32e9751..6d44b43 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -3,4 +3,5 @@ public interface IProfileService { Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto); + Task GetProfileByUsernameAsync(string username); } \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 7a82980..1cc9ebf 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -58,5 +58,19 @@ public ProfileService(ApplicationDbContext db) return (false, "Сталася непередбачувана помилка на сервері."); } } + + public async Task GetProfileByUsernameAsync(string username) + { + return await _db.Users + .Where(u => u.Username == username) + .Select(u => new UserProfileResponseDto + { + Username = u.Username, + Name = u.Profile.Name ?? "none", + Bio = u.Profile.Bio ?? "none", + CreatedAt = u.CreatedAt + }) + .FirstOrDefaultAsync(); + } } } From f47b9d982eb97abc5a085ca287c42d096dc30570 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:45:34 +0300 Subject: [PATCH 18/67] [Fix] Profile page url fix (#13) --- Versum/Controllers/ProfileController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index 971fe6a..e31d315 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -40,7 +40,7 @@ public async Task UpdateProfile([FromBody] UserProfileDto dto) return Ok(new { message = "Профіль успішно оновлено" }); } - [HttpGet("profile/{username}")] + [HttpGet("{username}")] public async Task GetProfile(string username) { var profile = await _profileService.GetProfileByUsernameAsync(username.ToLower()); From 1714fe1937a8f871f91d41552f7ba16a51d23bfb Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:13:33 +0300 Subject: [PATCH 19/67] [Feature] Profile Page Ownership Check (#15) * [Feature] Profile Page Ownership Check * [Fix] Renamed variable --- Versum/Controllers/ProfileController.cs | 6 +++++- Versum/Dtos/UserProfileResponseDto.cs | 1 + Versum/Services/IProfileService.cs | 2 +- Versum/Services/ProfileService.cs | 5 +++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index e31d315..dc2e744 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -43,7 +43,11 @@ public async Task UpdateProfile([FromBody] UserProfileDto dto) [HttpGet("{username}")] public async Task GetProfile(string username) { - var profile = await _profileService.GetProfileByUsernameAsync(username.ToLower()); + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + int? senderUserId = null; + if (userIdClaim != null) senderUserId = int.Parse(userIdClaim.Value); + + var profile = await _profileService.GetProfileByUsernameAsync(username.ToLower(), senderUserId); if (profile == null) { diff --git a/Versum/Dtos/UserProfileResponseDto.cs b/Versum/Dtos/UserProfileResponseDto.cs index 21221d9..a4e17a7 100644 --- a/Versum/Dtos/UserProfileResponseDto.cs +++ b/Versum/Dtos/UserProfileResponseDto.cs @@ -6,5 +6,6 @@ public class UserProfileResponseDto public string Name { get; set; } = string.Empty; public string Bio { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } + public bool IsOwner { get; set; } } } diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index 6d44b43..88c54e1 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -3,5 +3,5 @@ public interface IProfileService { Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto); - Task GetProfileByUsernameAsync(string username); + Task GetProfileByUsernameAsync(string username, int? claimedUserID); } \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 1cc9ebf..9d14d67 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -59,7 +59,7 @@ public ProfileService(ApplicationDbContext db) } } - public async Task GetProfileByUsernameAsync(string username) + public async Task GetProfileByUsernameAsync(string username, int? claimedUserID) { return await _db.Users .Where(u => u.Username == username) @@ -68,7 +68,8 @@ public ProfileService(ApplicationDbContext db) Username = u.Username, Name = u.Profile.Name ?? "none", Bio = u.Profile.Bio ?? "none", - CreatedAt = u.CreatedAt + CreatedAt = u.CreatedAt, + IsOwner = u.Id == claimedUserID }) .FirstOrDefaultAsync(); } From 551ce8dca1c1714812e3ba0fad816caaca368acb Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Wed, 29 Apr 2026 22:52:07 +0300 Subject: [PATCH 20/67] Feature/become author badge (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEATURE] Implement become author button(author-badge) * [FIX] Added BCAutorService * [FIX] Added try catch * [FIX] Added POST method for update author bio * [FIX] Minimal changes * [FIX] Added IBCAuthorService * [FIX] Get bio by username --------- Co-authored-by: Вікторія Свирид --- Versum/Controllers/BecomeAuthorController.cs | 83 ++++++++++ Versum/DdContext/ApplicationDbContext.cs | 5 + Versum/Dtos/BecomeAuthorDto.cs | 19 +++ .../20260426144005_AuthorsTable.Designer.cs | 153 ++++++++++++++++++ .../Migrations/20260426144005_AuthorsTable.cs | 54 +++++++ ...20260426155528_AuthorsTableFix.Designer.cs | 144 +++++++++++++++++ .../20260426155528_AuthorsTableFix.cs | 82 ++++++++++ .../ApplicationDbContextModelSnapshot.cs | 49 ++++-- Versum/Models/Author.cs | 17 ++ Versum/Models/User.cs | 5 + Versum/Program.cs | 41 +++-- Versum/Services/BCAuthorService.cs | 90 +++++++++++ Versum/Services/IBCAuthorService.cs | 13 ++ Versum/Versum.csproj | 3 + 14 files changed, 731 insertions(+), 27 deletions(-) create mode 100644 Versum/Controllers/BecomeAuthorController.cs create mode 100644 Versum/Dtos/BecomeAuthorDto.cs create mode 100644 Versum/Migrations/20260426144005_AuthorsTable.Designer.cs create mode 100644 Versum/Migrations/20260426144005_AuthorsTable.cs create mode 100644 Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs create mode 100644 Versum/Migrations/20260426155528_AuthorsTableFix.cs create mode 100644 Versum/Models/Author.cs create mode 100644 Versum/Services/BCAuthorService.cs create mode 100644 Versum/Services/IBCAuthorService.cs diff --git a/Versum/Controllers/BecomeAuthorController.cs b/Versum/Controllers/BecomeAuthorController.cs new file mode 100644 index 0000000..2cf279a --- /dev/null +++ b/Versum/Controllers/BecomeAuthorController.cs @@ -0,0 +1,83 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Versum.Models; +using Versum.Services; + + +namespace Versum.Controllers +{ + + + [ApiController] + [Route("api/[controller]")] + public class BecomeAuthorController : ControllerBase + { + private readonly IBCAuthorService _authorService; + + public BecomeAuthorController(IBCAuthorService authorService) + { + _authorService = authorService; + } + + [HttpPost("become-author-button")] + [Authorize] + public async Task BecomeAuthor([FromBody] BecomeAuthorDto dto) + { + var id = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var (success, error) = await _authorService.BecomeAuthorAsync(id, dto); + + if (!success) + { + if (error == "NotFound") return NotFound(); + return BadRequest(new { message = error }); + } + + return Ok(new + { + message = "Вітаємо, ви стали автором!", + opportunities = new[] + { + "Можливість писати", + + }, + + }); + } + + [HttpGet("{Username}/author-bio")] + public async Task GetAuthorBio(string Username) + { + var (success, bio, error) = await _authorService.GetAuthorBioAsync(Username); + + if (!success) + { + if (error == "NotFound") + return NotFound("Цей користувач не є автором або профілю не існує."); + } + + return Ok(new { bio }); + } + [HttpPost("update-author-bio")] + [Authorize] + public async Task UpdateAuthorBio([FromBody] BecomeAuthorDto dto) + { + var id = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var (success, error) = await _authorService.UpdateAuthorBioAsync(id, dto); + + if (!success) + { + if (error == "NotFound") return NotFound("Ви не є автором."); + if (error == "ServerError") return StatusCode(500, new { message = "Щось пішло не так. Спробуйте пізніше." }); + } + + return Ok(new { message = "Біо успішно оновлено!" }); + } + + + } +}; \ No newline at end of file diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index 3599e03..78fb2ce 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Versum.Models; namespace Versum { @@ -11,8 +12,12 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Posts { get; set; } public DbSet Users { get; set; } + public DbSet Profiles { get; set; } + public DbSet Authors { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Versum/Dtos/BecomeAuthorDto.cs b/Versum/Dtos/BecomeAuthorDto.cs new file mode 100644 index 0000000..f20e0e6 --- /dev/null +++ b/Versum/Dtos/BecomeAuthorDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class BecomeAuthorDto + { + + + + [Required(ErrorMessage = "Будь ласка, заповніть біографію автора")] + + [MinLength(10, ErrorMessage = "Мінімум 10 символів")] + [MaxLength(500, ErrorMessage = "Максимум 500 символів")] + public string AuthorBio { get; set; } = ""; + + + + } +} diff --git a/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs b/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs new file mode 100644 index 0000000..01e1d41 --- /dev/null +++ b/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs @@ -0,0 +1,153 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260426144005_AuthorsTable")] + partial class AuthorsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.Property("AuthorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuthorId")); + + b.Property("AuthorBio") + .HasColumnType("text"); + + b.Property("Id") + .HasColumnType("integer"); + + b.HasKey("AuthorId"); + + b.HasIndex("Id") + .IsUnique(); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.IsAuthor", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260426144005_AuthorsTable.cs b/Versum/Migrations/20260426144005_AuthorsTable.cs new file mode 100644 index 0000000..02679ff --- /dev/null +++ b/Versum/Migrations/20260426144005_AuthorsTable.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AuthorsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + AuthorId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AuthorBio = table.Column(type: "text", nullable: true), + Id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.AuthorId); + table.ForeignKey( + name: "FK_Authors_Users_Id", + column: x => x.Id, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + + + migrationBuilder.CreateIndex( + name: "IX_Authors_Id", + table: "Authors", + column: "Id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Authors"); + + migrationBuilder.DropIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs b/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs new file mode 100644 index 0000000..783fde6 --- /dev/null +++ b/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs @@ -0,0 +1,144 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260426155528_AuthorsTableFix")] + partial class AuthorsTableFix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasColumnType("text"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.IsAuthor", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260426155528_AuthorsTableFix.cs b/Versum/Migrations/20260426155528_AuthorsTableFix.cs new file mode 100644 index 0000000..4ddce3e --- /dev/null +++ b/Versum/Migrations/20260426155528_AuthorsTableFix.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AuthorsTableFix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Authors_Users_Id", + table: "Authors"); + + migrationBuilder.DropIndex( + name: "IX_Authors_Id", + table: "Authors"); + + migrationBuilder.DropColumn( + name: "Id", + table: "Authors"); + + migrationBuilder.AlterColumn( + name: "AuthorId", + table: "Authors", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AddForeignKey( + name: "FK_Authors_Users_AuthorId", + table: "Authors", + column: "AuthorId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Authors_Users_AuthorId", + table: "Authors"); + + migrationBuilder.AlterColumn( + name: "AuthorId", + table: "Authors", + type: "integer", + nullable: false, + oldClrType: typeof(int), + oldType: "integer") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + migrationBuilder.AddColumn( + name: "Id", + table: "Authors", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Authors_Id", + table: "Authors", + column: "Id", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Authors_Users_Id", + table: "Authors", + column: "Id", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index a96c880..7b7ba64 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,6 +22,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasColumnType("text"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + modelBuilder.Entity("Versum.Post", b => { b.Property("Id") @@ -141,19 +154,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.HasOne("Versum.User", "User") .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("Versum.UserProfile", "UserId"); - b.Navigation("User"); - }); + modelBuilder.Entity("Versum.Models.IsAuthor", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.IsAuthor", "AuthorId") - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("Profile") - .IsRequired(); - }); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("AuthorProfile"); + + }); #pragma warning restore 612, 618 + }); } - } -} + }; +} \ No newline at end of file diff --git a/Versum/Models/Author.cs b/Versum/Models/Author.cs new file mode 100644 index 0000000..5a13f62 --- /dev/null +++ b/Versum/Models/Author.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Versum.Models +{ + public class Author + { + + + [Key, ForeignKey("User")] + public int AuthorId { get; set; } + + public string? AuthorBio { get; set; } + + public virtual User User { get; set; } = null!; + } +} diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index f74b71d..5bc1ad7 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Versum.Models; namespace Versum { public class User @@ -21,8 +22,12 @@ public class User public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // Date and Time when user signed up public string? PasswordResetToken { get; set; } public DateTime? ResetTokenExpires { get; set; } + public UserProfile Profile { get; set; } public bool IsDeleted { get; set; } + public virtual Author? AuthorProfile { get; set; } + + } } diff --git a/Versum/Program.cs b/Versum/Program.cs index ded5c29..8912937 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; + +using Microsoft.OpenApi; + using System.Text; using Versum; using Versum.Hubs; @@ -8,21 +11,6 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, // Перевіряє, чи не прострочений токен - ValidateIssuerSigningKey = true, // Перевіряє підпис сервера - ValidIssuer = builder.Configuration["Jwt:Issuer"], - ValidAudience = builder.Configuration["Jwt:Audience"], - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) - }; - }); builder.Services.AddAuthorization(); builder.Services.AddControllers(); @@ -45,17 +33,40 @@ }); }); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) + }; +}); var app = builder.Build(); + app.UseCors("NuxtPolicy"); app.UseAuthentication(); app.UseAuthorization(); + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/Versum/Services/BCAuthorService.cs b/Versum/Services/BCAuthorService.cs new file mode 100644 index 0000000..ed28ecb --- /dev/null +++ b/Versum/Services/BCAuthorService.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Services +{ + + + public class BCAuthorService : IBCAuthorService + { + private readonly ApplicationDbContext _db; + + public BCAuthorService(ApplicationDbContext db) + { + _db = db; + } + + + + public async Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto) + { + try + { + var user = await _db.Users + .Include(u => u.AuthorProfile) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user == null) return (false, "NotFound"); + if (user.AuthorProfile != null) return (false, "Ви вже є автором."); + + user.AuthorProfile = new Author + { + AuthorId = user.Id, + AuthorBio = dto.AuthorBio.Trim() + }; + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"BecomeAuthorAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + public async Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username) + { + try + { + var author = await _db.Authors + .Include(a => a.User) + .FirstOrDefaultAsync(a => a.User.Username == username); + + if (author == null) return (false, null, "NotFound"); + + return (true, author.AuthorBio, null); + } + catch (Exception ex) + { + Console.WriteLine($"GetAuthorBioAsync error: {ex.Message}"); + return (false, null, "ServerError"); + } + + + } + public async Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto) + { + try + { + var author = await _db.Authors + .FirstOrDefaultAsync(a => a.AuthorId == userId); + + if (author == null) return (false, "NotFound"); + + author.AuthorBio = dto.AuthorBio.Trim(); + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"UpdateAuthorBioAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + + } + } diff --git a/Versum/Services/IBCAuthorService.cs b/Versum/Services/IBCAuthorService.cs new file mode 100644 index 0000000..9c4ec02 --- /dev/null +++ b/Versum/Services/IBCAuthorService.cs @@ -0,0 +1,13 @@ +using Versum.Dtos; + +namespace Versum.Services +{ + + public interface IBCAuthorService + { + Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto); + Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username); + Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto); + } + +} diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index d8db822..ccbd74f 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -18,6 +18,7 @@ + @@ -31,6 +32,8 @@ + + From 4382f6933fd5ef4a60c07d693d4cbadcf19e318d Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 30 Apr 2026 10:16:42 +0300 Subject: [PATCH 21/67] Feature/delete account (#16) * add delete and anonymize account endpoint --- Versum/Controllers/ProfileController.cs | 20 ++++++++++++ Versum/Dtos/DeleteAccountDto.cs | 15 +++++++++ Versum/Services/IProfileService.cs | 2 ++ Versum/Services/ProfileService.cs | 43 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 Versum/Dtos/DeleteAccountDto.cs diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index dc2e744..f0329ac 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -56,5 +56,25 @@ public async Task GetProfile(string username) return Ok(profile); } + + [Authorize] + [HttpPost("delete-account")] + public async Task DeleteAccount([FromBody] DeleteAccountDto dto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); + if (userIdClaim == null) return Unauthorized(); + + int currentUserId = int.Parse(userIdClaim.Value); + + var (success, error) = await _profileService.DeleteAndAnonymizeAccount(currentUserId, dto); + if (!success) + return BadRequest(new { message = error }); + + return Ok(new { message = "Акаунт успішно видалено та анонімізовано" }); + } + } } diff --git a/Versum/Dtos/DeleteAccountDto.cs b/Versum/Dtos/DeleteAccountDto.cs new file mode 100644 index 0000000..f75a130 --- /dev/null +++ b/Versum/Dtos/DeleteAccountDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class DeleteAccountDto + { + [Required(ErrorMessage = "Введіть свій пароль")] + [StringLength(20, MinimumLength = 8, + ErrorMessage = "Пароль має містити від 8 до 20 символів")] + [RegularExpression(@"^\S+$", + ErrorMessage = "Пароль не може містити пробіли")] + public string Password { get; set; } = string.Empty; + } +} +} diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index 88c54e1..bfaf19b 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -4,4 +4,6 @@ public interface IProfileService { Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto); Task GetProfileByUsernameAsync(string username, int? claimedUserID); + + Task<(bool success, string? error)> DeleteAndAnonymizeAccount(int userId, DeleteAccountDto deleteDto); } \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 9d14d67..c86c382 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -73,5 +73,48 @@ public ProfileService(ApplicationDbContext db) }) .FirstOrDefaultAsync(); } + + + public async Task<(bool success, string? error)> DeleteAndAnonymizeAccount(int userId, DeleteAccountDto deleteDto) + { + var user = await _db.Users + .Include(u => u.Profile) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user == null) + return (false, "Користувача не знайдено."); + + + bool passwordValid = BCrypt.Net.BCrypt.Verify(deleteDto.Password, user.PasswordHash); + if (!passwordValid) + return (false, "Неправильний пароль, введіть ще раз або вийдіть."); + + + + user.Email = $"deleted_{Guid.NewGuid()}@anonymized.com"; + user.Username = $"anon_{Guid.NewGuid():N}"; + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); + user.IsDeleted = true; + + if (user.Profile != null) + { + user.Profile.Name = "Анонімний автор"; + user.Profile.Bio = "Акаунт видалено"; + } + + try + { + await _db.SaveChangesAsync(); + return (true, null); + } + catch (DbUpdateException) + { + return (false, "Сталася помилка при зверненні до бази даних."); + } + catch (Exception) + { + return (false, "Сталася непередбачувана помилка на сервері."); + } + } } } From eb1bbe337c44830060087d8655b8dacc9087fd1f Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:17:54 +0300 Subject: [PATCH 22/67] [FIX] Delete Account: Guid error fix (#17) --- Versum/Dtos/DeleteAccountDto.cs | 2 +- Versum/Services/ProfileService.cs | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Versum/Dtos/DeleteAccountDto.cs b/Versum/Dtos/DeleteAccountDto.cs index f75a130..69ef486 100644 --- a/Versum/Dtos/DeleteAccountDto.cs +++ b/Versum/Dtos/DeleteAccountDto.cs @@ -12,4 +12,4 @@ public class DeleteAccountDto public string Password { get; set; } = string.Empty; } } -} + diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index c86c382..6868f1c 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -84,16 +84,15 @@ public ProfileService(ApplicationDbContext db) if (user == null) return (false, "Користувача не знайдено."); - bool passwordValid = BCrypt.Net.BCrypt.Verify(deleteDto.Password, user.PasswordHash); if (!passwordValid) return (false, "Неправильний пароль, введіть ще раз або вийдіть."); + var shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); - - user.Email = $"deleted_{Guid.NewGuid()}@anonymized.com"; - user.Username = $"anon_{Guid.NewGuid():N}"; - user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); + user.Email = $"del_{shortGuid}@anon.com"; // Близько 21 символу + user.Username = $"anon_{shortGuid}"; // 13 символів + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); user.IsDeleted = true; if (user.Profile != null) @@ -107,12 +106,16 @@ public ProfileService(ApplicationDbContext db) await _db.SaveChangesAsync(); return (true, null); } - catch (DbUpdateException) + catch (DbUpdateException ex) { - return (false, "Сталася помилка при зверненні до бази даних."); + var realError = ex.InnerException?.Message ?? ex.Message; + Console.WriteLine($"DB_ERROR при видаленні акаунта: {realError}"); + + return (false, "Сталася помилка при зверненні до бази даних. Перевірте консоль сервера."); } - catch (Exception) + catch (Exception ex) { + Console.WriteLine($"СЕРВЕРНА_ПОМИЛКА: {ex.Message}"); return (false, "Сталася непередбачувана помилка на сервері."); } } From 6d8968c4212be09aed69c08af58bd7bdeb98c012 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sat, 2 May 2026 21:46:10 +0300 Subject: [PATCH 23/67] [FIX] More login return data (#18) --- Versum/Controllers/AuthController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index 752901a..b492cf1 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -80,7 +80,7 @@ public async Task Login([FromBody] LoginDto dto)// JSON converts Console.WriteLine($"Gmail sending error: {ex.Message}");// logs error but doesn't stop the login process } - return Ok(new { token = resultMessage, message = "Вхід успішний" }); + return Ok(new { token = resultMessage, userGmail, username, message = "Вхід успішний" }); } [HttpPost("forgot-password")] From 97727b39fb3ccbae2559bb347d24360ee94d316f Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Sun, 3 May 2026 18:18:40 +0300 Subject: [PATCH 24/67] [FIX] Post table rework (#19) [FIX] Reworked Post table, added Genre table Reworked "Post" table to include a user foreign key, description, genres and added character limitations for various fields Added Genre table Added a character limitation for AuthorBio field in the "Author" table [HOTFIX] Added default value to the Description field [FIX] Removed Redundant Migrations --- Versum/DdContext/ApplicationDbContext.cs | 12 +- .../20260405145838_InitialCreate.Designer.cs | 52 ------ .../20260405145838_InitialCreate.cs | 38 ----- .../20260408161839_AddPostsTable.Designer.cs | 58 ------- .../20260408161839_AddPostsTable.cs | 57 ------- .../20260411150626_AddUsersTable.Designer.cs | 100 ------------ .../20260411150626_AddUsersTable.cs | 53 ------ .../20260411185632_FixHashLenght.Designer.cs | 101 ------------ .../20260411185632_FixHashLenght.cs | 36 ----- ...222_AddEmailConfirmationFields.Designer.cs | 104 ------------ ...260415164222_AddEmailConfirmationFields.cs | 39 ----- ...0416090608_ChangedGmailOnEmail.Designer.cs | 104 ------------ .../20260416090608_ChangedGmailOnEmail.cs | 38 ----- ...260419184915_UserTokenDBUpdate.Designer.cs | 110 ------------- .../20260419184915_UserTokenDBUpdate.cs | 39 ----- ...0140306_EmailConfirmationToken.Designer.cs | 110 ------------- .../20260420140306_EmailConfirmationToken.cs | 28 ---- .../20260420173851_User-Revert.Designer.cs | 110 ------------- .../20260420174100_User-Hash-Back.Designer.cs | 110 ------------- .../20260420174100_User-Hash-Back.cs | 22 --- .../20260420174229_Hash.Designer.cs | 110 ------------- Versum/Migrations/20260420174229_Hash.cs | 28 ---- ...60425103338_AddEmailTokenIndex.Designer.cs | 113 ------------- .../20260425103338_AddEmailTokenIndex.cs | 28 ---- .../20260425185401_add UserProfile.cs | 69 -------- .../20260426144005_AuthorsTable.Designer.cs | 153 ------------------ .../Migrations/20260426144005_AuthorsTable.cs | 54 ------- ...20260426155528_AuthorsTableFix.Designer.cs | 144 ----------------- .../20260426155528_AuthorsTableFix.cs | 82 ---------- ...0503150257_Posts Table Rework.Designer.cs} | 110 ++++++++++++- ...s => 20260503150257_Posts Table Rework.cs} | 2 +- .../ApplicationDbContextModelSnapshot.cs | 129 +++++++++++---- Versum/Models/Author.cs | 4 +- Versum/Models/Genre.cs | 9 ++ Versum/Models/Post.cs | 11 +- Versum/Models/User.cs | 2 +- 36 files changed, 234 insertions(+), 2135 deletions(-) delete mode 100644 Versum/Migrations/20260405145838_InitialCreate.Designer.cs delete mode 100644 Versum/Migrations/20260405145838_InitialCreate.cs delete mode 100644 Versum/Migrations/20260408161839_AddPostsTable.Designer.cs delete mode 100644 Versum/Migrations/20260408161839_AddPostsTable.cs delete mode 100644 Versum/Migrations/20260411150626_AddUsersTable.Designer.cs delete mode 100644 Versum/Migrations/20260411150626_AddUsersTable.cs delete mode 100644 Versum/Migrations/20260411185632_FixHashLenght.Designer.cs delete mode 100644 Versum/Migrations/20260411185632_FixHashLenght.cs delete mode 100644 Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs delete mode 100644 Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs delete mode 100644 Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs delete mode 100644 Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs delete mode 100644 Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs delete mode 100644 Versum/Migrations/20260419184915_UserTokenDBUpdate.cs delete mode 100644 Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs delete mode 100644 Versum/Migrations/20260420140306_EmailConfirmationToken.cs delete mode 100644 Versum/Migrations/20260420173851_User-Revert.Designer.cs delete mode 100644 Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs delete mode 100644 Versum/Migrations/20260420174100_User-Hash-Back.cs delete mode 100644 Versum/Migrations/20260420174229_Hash.Designer.cs delete mode 100644 Versum/Migrations/20260420174229_Hash.cs delete mode 100644 Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs delete mode 100644 Versum/Migrations/20260425103338_AddEmailTokenIndex.cs delete mode 100644 Versum/Migrations/20260425185401_add UserProfile.cs delete mode 100644 Versum/Migrations/20260426144005_AuthorsTable.Designer.cs delete mode 100644 Versum/Migrations/20260426144005_AuthorsTable.cs delete mode 100644 Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs delete mode 100644 Versum/Migrations/20260426155528_AuthorsTableFix.cs rename Versum/Migrations/{20260425185401_add UserProfile.Designer.cs => 20260503150257_Posts Table Rework.Designer.cs} (59%) rename Versum/Migrations/{20260420173851_User-Revert.cs => 20260503150257_Posts Table Rework.cs} (87%) create mode 100644 Versum/Models/Genre.cs diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index 78fb2ce..aec4a96 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -12,10 +12,9 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Posts { get; set; } public DbSet Users { get; set; } - public DbSet Profiles { get; set; } - public DbSet Authors { get; set; } + public DbSet Genres { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -33,6 +32,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(p => p.User) .HasForeignKey(p => p.UserId) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(p => p.User) + .WithMany(u => u.Posts) + .HasForeignKey(p => p.UserId); + + modelBuilder.Entity() + .HasMany(p => p.Posts) + .WithMany(g => g.Genres); } } } diff --git a/Versum/Migrations/20260405145838_InitialCreate.Designer.cs b/Versum/Migrations/20260405145838_InitialCreate.Designer.cs deleted file mode 100644 index 2185125..0000000 --- a/Versum/Migrations/20260405145838_InitialCreate.Designer.cs +++ /dev/null @@ -1,52 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260405145838_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.WeatherForecast", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Date") - .HasColumnType("date"); - - b.Property("Summary") - .HasColumnType("text"); - - b.Property("TemperatureC") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Forecasts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260405145838_InitialCreate.cs b/Versum/Migrations/20260405145838_InitialCreate.cs deleted file mode 100644 index 8165c15..0000000 --- a/Versum/Migrations/20260405145838_InitialCreate.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Forecasts", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Date = table.Column(type: "date", nullable: false), - TemperatureC = table.Column(type: "integer", nullable: false), - Summary = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Forecasts", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Forecasts"); - } - } -} diff --git a/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs b/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs deleted file mode 100644 index 4d81561..0000000 --- a/Versum/Migrations/20260408161839_AddPostsTable.Designer.cs +++ /dev/null @@ -1,58 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260408161839_AddPostsTable")] - partial class AddPostsTable - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260408161839_AddPostsTable.cs b/Versum/Migrations/20260408161839_AddPostsTable.cs deleted file mode 100644 index 52b913c..0000000 --- a/Versum/Migrations/20260408161839_AddPostsTable.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AddPostsTable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Forecasts"); - - migrationBuilder.CreateTable( - name: "Posts", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Title = table.Column(type: "text", nullable: false), - Content = table.Column(type: "text", nullable: false), - Author = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Posts", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Posts"); - - migrationBuilder.CreateTable( - name: "Forecasts", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Date = table.Column(type: "date", nullable: false), - Summary = table.Column(type: "text", nullable: true), - TemperatureC = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Forecasts", x => x.Id); - }); - } - } -} diff --git a/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs b/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs deleted file mode 100644 index f109db0..0000000 --- a/Versum/Migrations/20260411150626_AddUsersTable.Designer.cs +++ /dev/null @@ -1,100 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260411150626_AddUsersTable")] - partial class AddUsersTable - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EmailConfirmationToken") - .HasColumnType("text"); - - b.Property("Gmail") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Gmail") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260411150626_AddUsersTable.cs b/Versum/Migrations/20260411150626_AddUsersTable.cs deleted file mode 100644 index 7c9854f..0000000 --- a/Versum/Migrations/20260411150626_AddUsersTable.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AddUsersTable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - PasswordHash = table.Column(type: "text", nullable: false), - Gmail = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - IsEmailConfirmed = table.Column(type: "boolean", nullable: false), - EmailConfirmationToken = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Users_Gmail", - table: "Users", - column: "Gmail", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Username", - table: "Users", - column: "Username", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs b/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs deleted file mode 100644 index f8b9406..0000000 --- a/Versum/Migrations/20260411185632_FixHashLenght.Designer.cs +++ /dev/null @@ -1,101 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260411185632_FixHashLenght")] - partial class FixHashLenght - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EmailConfirmationToken") - .HasColumnType("text"); - - b.Property("Gmail") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Gmail") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260411185632_FixHashLenght.cs b/Versum/Migrations/20260411185632_FixHashLenght.cs deleted file mode 100644 index e5cf057..0000000 --- a/Versum/Migrations/20260411185632_FixHashLenght.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class FixHashLenght : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "PasswordHash", - table: "Users", - type: "character varying(60)", - maxLength: 60, - nullable: false, - oldClrType: typeof(string), - oldType: "text"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "PasswordHash", - table: "Users", - type: "text", - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(60)", - oldMaxLength: 60); - } - } -} diff --git a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs deleted file mode 100644 index a7ec7cc..0000000 --- a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.Designer.cs +++ /dev/null @@ -1,104 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260415164222_AddEmailConfirmationFields")] - partial class AddEmailConfirmationFields - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Gmail") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Gmail") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs b/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs deleted file mode 100644 index 5a17331..0000000 --- a/Versum/Migrations/20260415164222_AddEmailConfirmationFields.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AddEmailConfirmationFields : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "EmailConfirmationToken", - table: "Users", - newName: "EmailConfirmationTokenHash"); - - migrationBuilder.AddColumn( - name: "EmailTokenExpiryDate", - table: "Users", - type: "timestamp with time zone", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "EmailTokenExpiryDate", - table: "Users"); - - migrationBuilder.RenameColumn( - name: "EmailConfirmationTokenHash", - table: "Users", - newName: "EmailConfirmationToken"); - } - } -} diff --git a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs deleted file mode 100644 index 7ef6c09..0000000 --- a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.Designer.cs +++ /dev/null @@ -1,104 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260416090608_ChangedGmailOnEmail")] - partial class ChangedGmailOnEmail - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs b/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs deleted file mode 100644 index 123751a..0000000 --- a/Versum/Migrations/20260416090608_ChangedGmailOnEmail.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class ChangedGmailOnEmail : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "Gmail", - table: "Users", - newName: "Email"); - - migrationBuilder.RenameIndex( - name: "IX_Users_Gmail", - table: "Users", - newName: "IX_Users_Email"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "Email", - table: "Users", - newName: "Gmail"); - - migrationBuilder.RenameIndex( - name: "IX_Users_Email", - table: "Users", - newName: "IX_Users_Gmail"); - } - } -} diff --git a/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs b/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs deleted file mode 100644 index 989afaa..0000000 --- a/Versum/Migrations/20260419184915_UserTokenDBUpdate.Designer.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260419184915_UserTokenDBUpdate")] - partial class UserTokenDBUpdate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs b/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs deleted file mode 100644 index 9fc0e3b..0000000 --- a/Versum/Migrations/20260419184915_UserTokenDBUpdate.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class UserTokenDBUpdate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "PasswordResetToken", - table: "Users", - type: "text", - nullable: true); - - migrationBuilder.AddColumn( - name: "ResetTokenExpires", - table: "Users", - type: "timestamp with time zone", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "PasswordResetToken", - table: "Users"); - - migrationBuilder.DropColumn( - name: "ResetTokenExpires", - table: "Users"); - } - } -} diff --git a/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs b/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs deleted file mode 100644 index ce63f06..0000000 --- a/Versum/Migrations/20260420140306_EmailConfirmationToken.Designer.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260420140306_EmailConfirmationToken")] - partial class EmailConfirmationToken - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationToken") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260420140306_EmailConfirmationToken.cs b/Versum/Migrations/20260420140306_EmailConfirmationToken.cs deleted file mode 100644 index c0e327b..0000000 --- a/Versum/Migrations/20260420140306_EmailConfirmationToken.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class EmailConfirmationToken : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "EmailConfirmationTokenHash", - table: "Users", - newName: "EmailConfirmationToken"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "EmailConfirmationToken", - table: "Users", - newName: "EmailConfirmationTokenHash"); - } - } -} diff --git a/Versum/Migrations/20260420173851_User-Revert.Designer.cs b/Versum/Migrations/20260420173851_User-Revert.Designer.cs deleted file mode 100644 index 245ca4b..0000000 --- a/Versum/Migrations/20260420173851_User-Revert.Designer.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260420173851_User-Revert")] - partial class UserRevert - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs b/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs deleted file mode 100644 index ebd5d0b..0000000 --- a/Versum/Migrations/20260420174100_User-Hash-Back.Designer.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260420174100_User-Hash-Back")] - partial class UserHashBack - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260420174100_User-Hash-Back.cs b/Versum/Migrations/20260420174100_User-Hash-Back.cs deleted file mode 100644 index f4e586c..0000000 --- a/Versum/Migrations/20260420174100_User-Hash-Back.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class UserHashBack : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Versum/Migrations/20260420174229_Hash.Designer.cs b/Versum/Migrations/20260420174229_Hash.Designer.cs deleted file mode 100644 index ed9394e..0000000 --- a/Versum/Migrations/20260420174229_Hash.Designer.cs +++ /dev/null @@ -1,110 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260420174229_Hash")] - partial class Hash - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260420174229_Hash.cs b/Versum/Migrations/20260420174229_Hash.cs deleted file mode 100644 index c79822e..0000000 --- a/Versum/Migrations/20260420174229_Hash.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class Hash : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "EmailConfirmationToken", - table: "Users", - newName: "EmailConfirmationTokenHash"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "EmailConfirmationTokenHash", - table: "Users", - newName: "EmailConfirmationToken"); - } - } -} diff --git a/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs b/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs deleted file mode 100644 index e7723ee..0000000 --- a/Versum/Migrations/20260425103338_AddEmailTokenIndex.Designer.cs +++ /dev/null @@ -1,113 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260425103338_AddEmailTokenIndex")] - partial class AddEmailTokenIndex - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs b/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs deleted file mode 100644 index 7ccf556..0000000 --- a/Versum/Migrations/20260425103338_AddEmailTokenIndex.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AddEmailTokenIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users", - column: "EmailConfirmationTokenHash", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users"); - } - } -} diff --git a/Versum/Migrations/20260425185401_add UserProfile.cs b/Versum/Migrations/20260425185401_add UserProfile.cs deleted file mode 100644 index 97b1af6..0000000 --- a/Versum/Migrations/20260425185401_add UserProfile.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class addUserProfile : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "Users", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.CreateTable( - name: "Profiles", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), - Bio = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), - UserId = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Profiles", x => x.Id); - table.ForeignKey( - name: "FK_Profiles_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users", - columns: new[] { "Email", "PasswordResetToken" }); - - migrationBuilder.CreateIndex( - name: "IX_Profiles_UserId", - table: "Profiles", - column: "UserId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Profiles"); - - migrationBuilder.DropIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users"); - - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "Users"); - } - } -} diff --git a/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs b/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs deleted file mode 100644 index 01e1d41..0000000 --- a/Versum/Migrations/20260426144005_AuthorsTable.Designer.cs +++ /dev/null @@ -1,153 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260426144005_AuthorsTable")] - partial class AuthorsTable - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Models.IsAuthor", b => - { - b.Property("AuthorId") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuthorId")); - - b.Property("AuthorBio") - .HasColumnType("text"); - - b.Property("Id") - .HasColumnType("integer"); - - b.HasKey("AuthorId"); - - b.HasIndex("Id") - .IsUnique(); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.HasIndex("Email", "PasswordResetToken"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.Models.IsAuthor", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.IsAuthor", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260426144005_AuthorsTable.cs b/Versum/Migrations/20260426144005_AuthorsTable.cs deleted file mode 100644 index 02679ff..0000000 --- a/Versum/Migrations/20260426144005_AuthorsTable.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AuthorsTable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Authors", - columns: table => new - { - AuthorId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - AuthorBio = table.Column(type: "text", nullable: true), - Id = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Authors", x => x.AuthorId); - table.ForeignKey( - name: "FK_Authors_Users_Id", - column: x => x.Id, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - - - migrationBuilder.CreateIndex( - name: "IX_Authors_Id", - table: "Authors", - column: "Id", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Authors"); - - migrationBuilder.DropIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users"); - } - } -} diff --git a/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs b/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs deleted file mode 100644 index 783fde6..0000000 --- a/Versum/Migrations/20260426155528_AuthorsTableFix.Designer.cs +++ /dev/null @@ -1,144 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260426155528_AuthorsTableFix")] - partial class AuthorsTableFix - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Versum.Models.IsAuthor", b => - { - b.Property("AuthorId") - .HasColumnType("integer"); - - b.Property("AuthorBio") - .HasColumnType("text"); - - b.HasKey("AuthorId"); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.HasIndex("Email", "PasswordResetToken"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.Models.IsAuthor", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.IsAuthor", "AuthorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260426155528_AuthorsTableFix.cs b/Versum/Migrations/20260426155528_AuthorsTableFix.cs deleted file mode 100644 index 4ddce3e..0000000 --- a/Versum/Migrations/20260426155528_AuthorsTableFix.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class AuthorsTableFix : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Authors_Users_Id", - table: "Authors"); - - migrationBuilder.DropIndex( - name: "IX_Authors_Id", - table: "Authors"); - - migrationBuilder.DropColumn( - name: "Id", - table: "Authors"); - - migrationBuilder.AlterColumn( - name: "AuthorId", - table: "Authors", - type: "integer", - nullable: false, - oldClrType: typeof(int), - oldType: "integer") - .OldAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - - migrationBuilder.AddForeignKey( - name: "FK_Authors_Users_AuthorId", - table: "Authors", - column: "AuthorId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Authors_Users_AuthorId", - table: "Authors"); - - migrationBuilder.AlterColumn( - name: "AuthorId", - table: "Authors", - type: "integer", - nullable: false, - oldClrType: typeof(int), - oldType: "integer") - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - - migrationBuilder.AddColumn( - name: "Id", - table: "Authors", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.CreateIndex( - name: "IX_Authors_Id", - table: "Authors", - column: "Id", - unique: true); - - migrationBuilder.AddForeignKey( - name: "FK_Authors_Users_Id", - table: "Authors", - column: "Id", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - } - } -} diff --git a/Versum/Migrations/20260425185401_add UserProfile.Designer.cs b/Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs similarity index 59% rename from Versum/Migrations/20260425185401_add UserProfile.Designer.cs rename to Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs index a4a5b84..8784413 100644 --- a/Versum/Migrations/20260425185401_add UserProfile.Designer.cs +++ b/Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs @@ -12,8 +12,8 @@ namespace Versum.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260425185401_add UserProfile")] - partial class addUserProfile + [Migration("20260503150257_Posts Table Rework")] + partial class PostsTableRework { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -25,7 +25,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Versum.Post", b => + modelBuilder.Entity("Genre", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -33,23 +33,78 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Author") + b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Content") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + b.Property("Title") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); b.HasKey("Id"); + b.HasIndex("UserId"); + b.ToTable("Posts"); }); @@ -140,6 +195,43 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Profiles"); }); + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Versum.UserProfile", b => { b.HasOne("Versum.User", "User") @@ -153,6 +245,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Versum.User", b => { + b.Navigation("AuthorProfile"); + + b.Navigation("Posts"); + b.Navigation("Profile") .IsRequired(); }); diff --git a/Versum/Migrations/20260420173851_User-Revert.cs b/Versum/Migrations/20260503150257_Posts Table Rework.cs similarity index 87% rename from Versum/Migrations/20260420173851_User-Revert.cs rename to Versum/Migrations/20260503150257_Posts Table Rework.cs index 06d36de..addb51e 100644 --- a/Versum/Migrations/20260420173851_User-Revert.cs +++ b/Versum/Migrations/20260503150257_Posts Table Rework.cs @@ -5,7 +5,7 @@ namespace Versum.Migrations { /// - public partial class UserRevert : Migration + public partial class PostsTableRework : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 7b7ba64..89bc4d2 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,13 +22,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Versum.Models.IsAuthor", b => + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => { b.Property("AuthorId") .HasColumnType("integer"); b.Property("AuthorBio") - .HasColumnType("text"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.HasKey("AuthorId"); @@ -43,23 +77,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Author") - .IsRequired() - .HasColumnType("text"); - b.Property("Content") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + b.Property("Title") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); b.HasKey("Id"); + b.HasIndex("UserId"); + b.ToTable("Posts"); }); @@ -150,35 +192,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Profiles"); }); - modelBuilder.Entity("Versum.UserProfile", b => + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => { b.HasOne("Versum.User", "User") - .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId"); + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); - modelBuilder.Entity("Versum.Models.IsAuthor", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.IsAuthor", "AuthorId") + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.Navigation("User"); + }); - b.Navigation("User"); - }); + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - modelBuilder.Entity("Versum.User", b => - { + b.Navigation("User"); + }); - b.Navigation("Profile") - .IsRequired(); + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); - b.Navigation("AuthorProfile"); + b.Navigation("Posts"); - }); -#pragma warning restore 612, 618 + b.Navigation("Profile") + .IsRequired(); }); +#pragma warning restore 612, 618 } - }; -} \ No newline at end of file + } +} diff --git a/Versum/Models/Author.cs b/Versum/Models/Author.cs index 5a13f62..96cdc72 100644 --- a/Versum/Models/Author.cs +++ b/Versum/Models/Author.cs @@ -8,9 +8,9 @@ public class Author [Key, ForeignKey("User")] - public int AuthorId { get; set; } + public int AuthorId { get; set; } - public string? AuthorBio { get; set; } + [MaxLength(500)] public string? AuthorBio { get; set; } public virtual User User { get; set; } = null!; } diff --git a/Versum/Models/Genre.cs b/Versum/Models/Genre.cs new file mode 100644 index 0000000..725f53c --- /dev/null +++ b/Versum/Models/Genre.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Versum; + +public class Genre +{ + public int Id { get; set; } + [MaxLength(50)] public string Name { get; set; } = string.Empty; + public ICollection Posts { get; set; } = new List(); +} \ No newline at end of file diff --git a/Versum/Models/Post.cs b/Versum/Models/Post.cs index 38613f8..d6a19fd 100644 --- a/Versum/Models/Post.cs +++ b/Versum/Models/Post.cs @@ -1,11 +1,16 @@ +using System.ComponentModel.DataAnnotations; + namespace Versum { public class Post { public int Id { get; set; } - public string Title { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; - public string Author { get; set; } = "NotAsasha"; + [MaxLength(100)] public string Title { get; set; } = string.Empty; + [MaxLength(600)] public string Description { get; set; } = string.Empty; + [MinLength(10)][MaxLength(500000)] public string Content { get; set; } = string.Empty; + public int UserId { get; set; } + public User User { get; set; } = null!; // Post's Author + public ICollection Genres { get; set; } = new List(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index 5bc1ad7..31d924a 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -28,6 +28,6 @@ public class User public virtual Author? AuthorProfile { get; set; } - + public ICollection Posts { get; set; } = new List(); } } From 5337865c6e2c389e6614928f635a02d2f1b3079b Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Mon, 4 May 2026 21:49:59 +0300 Subject: [PATCH 25/67] [FIX] GetUserInfo retrns IsAuthor (#22) --- Versum/Dtos/UserProfileResponseDto.cs | 1 + Versum/Services/ProfileService.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/Versum/Dtos/UserProfileResponseDto.cs b/Versum/Dtos/UserProfileResponseDto.cs index a4e17a7..322bcd8 100644 --- a/Versum/Dtos/UserProfileResponseDto.cs +++ b/Versum/Dtos/UserProfileResponseDto.cs @@ -6,6 +6,7 @@ public class UserProfileResponseDto public string Name { get; set; } = string.Empty; public string Bio { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } + public bool IsAuthor { get; set; } public bool IsOwner { get; set; } } } diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 6868f1c..b03a021 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -69,6 +69,7 @@ public ProfileService(ApplicationDbContext db) Name = u.Profile.Name ?? "none", Bio = u.Profile.Bio ?? "none", CreatedAt = u.CreatedAt, + IsAuthor = (u.AuthorProfile != null), IsOwner = u.Id == claimedUserID }) .FirstOrDefaultAsync(); From b45950d4bd7d3dc13a81035b208f117b0717f310 Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Thu, 7 May 2026 15:38:31 +0300 Subject: [PATCH 26/67] [Feature] Implement endpoints for publish and create draft (TO BE REWORKED) (#24) --- Versum/Controllers/PostsController.cs | 76 +++++++++++++++++----- Versum/DdContext/ApplicationDbContext.cs | 4 +- Versum/Dtos/PostDto.cs | 24 +++++++ Versum/Models/Author.cs | 1 + Versum/Models/Post.cs | 6 +- Versum/Models/User.cs | 1 - Versum/Program.cs | 1 + Versum/Services/IPostService.cs | 10 +++ Versum/Services/PostService.cs | 80 ++++++++++++++++++++++++ 9 files changed, 184 insertions(+), 19 deletions(-) create mode 100644 Versum/Dtos/PostDto.cs create mode 100644 Versum/Services/IPostService.cs create mode 100644 Versum/Services/PostService.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index a99d589..ea205fd 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -1,40 +1,88 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Versum.Dtos; using Versum.Hubs; +using Versum.Services; namespace Versum.Controllers { - //"Route" turns class name into a link - api/[className - "Controller"] + [ApiController] [Route("api/[controller]")] public class PostsController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IHubContext _hubContext; + private readonly IPostService _postService; - public PostsController(ApplicationDbContext context, IHubContext hubContext) + public PostsController(ApplicationDbContext context, IHubContext hubContext, IPostService postService) { _context = context; _hubContext = hubContext; + _postService = postService; } - //when you call GET on /api/posts - [HttpGet] - public async Task>> GetPosts() + + + + [HttpPost("create-post")] + [Authorize] + public async Task CreatePost([FromBody] PostDto dto) { - return await _context.Posts.OrderByDescending(p => p.CreatedAt).ToListAsync(); + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int authorId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.PublishPostAsync(authorId, dto); + if (!success) + { + if (error == "AuthorNotFound") return NotFound(new { message = "Профіль автора не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Твір успішно опубліковано" + }); + } - //try to guess - [HttpPost] - public async Task> CreatePost(Post post) + + [HttpPost("create-draft")] + [Authorize] + public async Task CreateDraft([FromBody] PostDto dto) { - _context.Posts.Add(post); - await _context.SaveChangesAsync(); - await _hubContext.Clients.All.SendAsync("NewPostPublished"); - return CreatedAtAction(nameof(GetPosts), new { id = post.Id }, post); + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int authorId)) + { + return Unauthorized(); + } + + var (success, error, postId) = await _postService.CreateDraftAsync(authorId, dto); + + if (!success) + { + if (error == "AuthorNotFound") return NotFound(new { message = "Профіль автора не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Чернетку збережено", + postId = postId + }); + } + + [HttpGet] + public async Task>> GetPosts() + { + return await _context.Posts.OrderByDescending(p => p.CreatedAt).ToListAsync(); } } } diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DdContext/ApplicationDbContext.cs index aec4a96..c2b5670 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DdContext/ApplicationDbContext.cs @@ -34,9 +34,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() - .HasOne(p => p.User) + .HasOne(p => p.Author) .WithMany(u => u.Posts) - .HasForeignKey(p => p.UserId); + .HasForeignKey(p => p.AuthorId); modelBuilder.Entity() .HasMany(p => p.Posts) diff --git a/Versum/Dtos/PostDto.cs b/Versum/Dtos/PostDto.cs new file mode 100644 index 0000000..6db7bed --- /dev/null +++ b/Versum/Dtos/PostDto.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + + +namespace Versum.Dtos +{ + public class PostDto + { + [Required(ErrorMessage = "Введіть назву твору")] + [MaxLength(100, ErrorMessage = "Максимум 100 символів")] + public string Title { get; set; } = ""; + + [Required(ErrorMessage = "Введіть опис твору")] + [MaxLength(600, ErrorMessage = "Максимум 600 символів")] + public string Description { get; set; } = ""; + + [Required(ErrorMessage = "Твір не може бути порожнім")] + [MaxLength(500000, ErrorMessage = "Максимум 600 символів")] + [MinLength(10, ErrorMessage = "Мінімум 10 символів")] + public string Content { get; set; } = ""; + + + + } +} \ No newline at end of file diff --git a/Versum/Models/Author.cs b/Versum/Models/Author.cs index 96cdc72..01ba267 100644 --- a/Versum/Models/Author.cs +++ b/Versum/Models/Author.cs @@ -13,5 +13,6 @@ public class Author [MaxLength(500)] public string? AuthorBio { get; set; } public virtual User User { get; set; } = null!; + public virtual ICollection Posts { get; set; } = new List(); } } diff --git a/Versum/Models/Post.cs b/Versum/Models/Post.cs index d6a19fd..4fe585c 100644 --- a/Versum/Models/Post.cs +++ b/Versum/Models/Post.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Versum.Models; namespace Versum { @@ -8,9 +9,10 @@ public class Post [MaxLength(100)] public string Title { get; set; } = string.Empty; [MaxLength(600)] public string Description { get; set; } = string.Empty; [MinLength(10)][MaxLength(500000)] public string Content { get; set; } = string.Empty; - public int UserId { get; set; } - public User User { get; set; } = null!; // Post's Author + public int AuthorId { get; set; } + public virtual Author Author { get; set; } = null!; // Post's Author public ICollection Genres { get; set; } = new List(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsDraft { get; set; } } } diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index 31d924a..28f4f91 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -28,6 +28,5 @@ public class User public virtual Author? AuthorProfile { get; set; } - public ICollection Posts { get; set; } = new List(); } } diff --git a/Versum/Program.cs b/Versum/Program.cs index 8912937..e6bfe64 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs new file mode 100644 index 0000000..aebff4d --- /dev/null +++ b/Versum/Services/IPostService.cs @@ -0,0 +1,10 @@ +using Versum.Dtos; + +namespace Versum.Services +{ + public interface IPostService + { + Task<(bool Success, string? Error)> PublishPostAsync(int Id, PostDto dto); + Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, PostDto dto); + } +} \ No newline at end of file diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs new file mode 100644 index 0000000..088a1d4 --- /dev/null +++ b/Versum/Services/PostService.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Services +{ + public class PostService : IPostService + { + + private readonly ApplicationDbContext _db; + + public PostService(ApplicationDbContext db) + { + _db = db; + } + + + public async Task<(bool Success, string? Error)> PublishPostAsync(int authorId, PostDto dto) + { + try + { + var author = await _db.Authors.FirstOrDefaultAsync(u => u.AuthorId == authorId); + + if (author == null) return (false, "AuthorNotFound"); + + var newPost = new Post + { + Title = dto.Title, + Description = dto.Description, + Content = dto.Content, + AuthorId = author.AuthorId, + CreatedAt = DateTime.UtcNow + + }; + + _db.Posts.Add(newPost); + + + await _db.SaveChangesAsync(); + + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"PublishPostAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + public async Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, PostDto dto) + { + try + { + var author = await _db.Authors.FirstOrDefaultAsync(a => a.AuthorId == authorId); + if (author == null) return (false, "AuthorNotFound", null); + + var draftPost = new Post + { + Title = dto.Title, + Description = dto.Description ?? string.Empty, + Content = dto.Content ?? string.Empty, + AuthorId = authorId, + IsDraft = true, + CreatedAt = DateTime.UtcNow + }; + + _db.Posts.Add(draftPost); + await _db.SaveChangesAsync(); + + return (true, null, draftPost.Id); + } + catch (Exception ex) + { + Console.WriteLine($"SaveDraftAsync error: {ex.Message}"); + return (false, "ServerError", null); + } + } + + } +} \ No newline at end of file From fdc5afdb03b26d3c268bf19a75a7d386ad49847d Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Thu, 7 May 2026 18:35:11 +0300 Subject: [PATCH 27/67] Test/add x unit tests (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [TEST] Add xUnit tests for Auth and Profile services * [FIX] Update namespace Versum to Versum.Context * [FIX] Update namespace Versum to Versum.Context * Idk how it should work * [FIX]Fixed unit tests * [FIX] Add private modifier --------- Co-authored-by: Вікторія Свирид Co-authored-by: notasasha --- TestProject/ServicesTest/AuthServiceTests.cs | 273 +++++++++++++++++ .../ServicesTest/BCAuthorServiceTests.cs | 10 + TestProject/ServicesTest/PostServiceTests.cs | 10 + .../ServicesTest/ProfileServiceTests.cs | 287 ++++++++++++++++++ TestProject/VersumTestProject.csproj | 31 ++ Versum.slnx | 3 + Versum/Controllers/AuthController.cs | 2 +- Versum/Controllers/PostsController.cs | 2 + .../ApplicationDbContext.cs | 2 +- ...60503150257_Posts Table Rework.Designer.cs | 258 ---------------- .../20260503150257_Posts Table Rework.cs | 22 -- .../ApplicationDbContextModelSnapshot.cs | 2 +- Versum/Program.cs | 2 +- Versum/Services/AuthService.cs | 1 + Versum/Services/BCAuthorService.cs | 1 + Versum/Services/EmailService.cs | 2 +- Versum/Services/ProfileService.cs | 5 + Versum/Versum.csproj | 2 +- 18 files changed, 629 insertions(+), 286 deletions(-) create mode 100644 TestProject/ServicesTest/AuthServiceTests.cs create mode 100644 TestProject/ServicesTest/BCAuthorServiceTests.cs create mode 100644 TestProject/ServicesTest/PostServiceTests.cs create mode 100644 TestProject/ServicesTest/ProfileServiceTests.cs create mode 100644 TestProject/VersumTestProject.csproj rename Versum/{DdContext => DbContext}/ApplicationDbContext.cs (98%) delete mode 100644 Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs delete mode 100644 Versum/Migrations/20260503150257_Posts Table Rework.cs diff --git a/TestProject/ServicesTest/AuthServiceTests.cs b/TestProject/ServicesTest/AuthServiceTests.cs new file mode 100644 index 0000000..8cb3844 --- /dev/null +++ b/TestProject/ServicesTest/AuthServiceTests.cs @@ -0,0 +1,273 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using System.Security.Cryptography; +using System.Text; +using Versum; +using Versum.Context; +using Versum.Dtos; +using Versum.Services; + +namespace VersumTestProject.ServicesTest +{ + public class AuthServiceTests: IDisposable + { + + private readonly ApplicationDbContext _context; + private readonly ApplicationDbContext _assertContext; + private readonly Mock _emailServiceMock; + private readonly Mock _configurationMock; + private readonly AuthService _authService; + + private const string TestUsername = "vixy"; + private const string TestEmail = "vixy@test.com"; + private const string TestPassword = "SecurePassword123"; + private const string ExistingUser = "existing_user"; + private const string ExistingEmail = "existing_user"; + + + private const string ValidRawToken = "valid_token_123"; + private const string ExpiredRawToken = "expired_token_123"; + + // SET UP + public AuthServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; + + _emailServiceMock = new Mock(); + _configurationMock = new Mock(); + _configurationMock.Setup(c => c["AppSettings:BaseUrl"]).Returns("https://localhost:7014"); + + _context = new ApplicationDbContext(options); + _authService = new AuthService(_context, _emailServiceMock.Object, _configurationMock.Object); + _assertContext = new ApplicationDbContext(options); + + + TemporaryTemplate(); + + } + private void TemporaryTemplate() + { + var templatesDir = Path.Combine(Directory.GetCurrentDirectory(), "Templates"); + if (!Directory.Exists(templatesDir)) + { + Directory.CreateDirectory(templatesDir); + } + File.WriteAllText( + Path.Combine(templatesDir, "ConfRegistrationTemplate.html"), + "Привіт, {Username}! Посилання: {confirmLink}" + ); + } + + private string ComputeSha256Hash(string rawData) + { + using (var sha256 = SHA256.Create()) + { + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(rawData)); + return Convert.ToBase64String(bytes); + } + } + + // TEARDOWN + public void Dispose() + { + _context.Dispose(); + _assertContext.Dispose(); + + var templatesDir = Path.Combine(AppContext.BaseDirectory, "Templates"); + if (Directory.Exists(templatesDir)) + { + Directory.Delete(templatesDir, true); + } + } + + [Fact] + public async Task RegisterAsync_WhenDataIsValid_SuccessfullyRegistersUserAndSendsEmail() + { + // Arrange + var dto = new RegisterDto { Username = TestUsername, Email = TestEmail, Password = TestPassword }; + + // Act + var (success, error, field) = await _authService.RegisterAsync(dto); + + // Assert + Assert.True(success); + Assert.Null(error); + Assert.Null(field); + + // checks if user is saved in database + var userInDb = await _assertContext.Users.FirstOrDefaultAsync(u => u.Email == TestEmail); + + Assert.NotNull(userInDb); + Assert.Equal(TestUsername, userInDb.Username); + + // checks if password is hashed + Assert.NotEqual(TestPassword, userInDb.PasswordHash); + + // checks token + Assert.NotNull(userInDb.EmailConfirmationTokenHash); + Assert.True(userInDb.EmailTokenExpiryDate > DateTime.UtcNow); + + // checks email sending with use of Email Mock + // У тестах ви просто робите так: + + _emailServiceMock.Verify( + x => x.SendEmailAsync( + TestEmail, + "Підтвердження реєстрації — Versum", + It.Is(body => body.Contains("https://localhost:7014/api/Auth/confirm-email") && body.Contains(TestUsername)) + ), + Times.Once + ); + } + + + + [Fact] + public async Task RegisterAsync_WhenUsernameAlreadyExists_ReturnsError() + { + // Arrange + + _context.Users.Add(new User { Id = 1, Username = ExistingUser, Email = "old@test.com", PasswordHash = "123yuyuyyuy" }); + await _context.SaveChangesAsync(); + var dto = new RegisterDto { Username = ExistingUser, Email = "new@test.com", Password = "Password123!" }; + + // Act + var (success, error, field) = await _authService.RegisterAsync(dto); + + // Assert + Assert.False(success); + Assert.Equal("Цей нікнейм вже існує", error); + Assert.Equal("username", field); + } + + [Fact] + public async Task RegisterAsync_WhenEmailAlreadyExists_ReturnsError() + { + // Arrange + _context.Users.Add(new User { Id = 1, Username = "old_user", Email = ExistingEmail, PasswordHash = "ere998rer" }); + await _context.SaveChangesAsync(); + var dto = new RegisterDto { Username = "new_user", Email = ExistingEmail, Password = "Password123!" }; + + // Act + var (success, error, field) = await _authService.RegisterAsync(dto); + + // Assert + Assert.False(success); + Assert.Equal("Цей імейл вже існує", error); + Assert.Equal("email", field); + } + + + + [Fact] + public async Task ConfirmEmailAsync_WithValidAndActiveToken_ReturnsSuccessAndUpdatesUser() + { + // Arrange + string rawToken = ValidRawToken; + string tokenHash = ComputeSha256Hash(rawToken); + + var user = new User + { + Id = 1, + Username = TestUsername, + Email = TestEmail, + PasswordHash = "hashed_pw", + IsEmailConfirmed = false, + EmailConfirmationTokenHash = tokenHash, + EmailTokenExpiryDate = DateTime.UtcNow.AddHours(24) + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _authService.ConfirmEmailAsync(rawToken); + + // Assert + Assert.True(success); + Assert.Null(error); + + var updatedUser = await _assertContext.Users.FirstAsync(u => u.Id == 1); + + Assert.True(updatedUser.IsEmailConfirmed); + Assert.Null(updatedUser.EmailConfirmationTokenHash); + Assert.Null(updatedUser.EmailTokenExpiryDate); + } + + + [Fact] + public async Task ConfirmEmailAsync_WithInvalidToken_ReturnsFalseAndError() + { + // Arrange + string rawTokenInDb = "correct_token"; + string tokenHash = ComputeSha256Hash(rawTokenInDb); + + var user = new User + { + Id = 1, + Username = TestUsername, + Email = TestEmail, + PasswordHash = "hashed_pw", + IsEmailConfirmed = false, + EmailConfirmationTokenHash = tokenHash, + EmailTokenExpiryDate = DateTime.UtcNow.AddHours(2) + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + var authService = new global::Versum.Services.AuthService(_context, _emailServiceMock.Object, _configurationMock.Object); + + // Act + var (success, error) = await authService.ConfirmEmailAsync("wrong_token"); + + // Assert + Assert.False(success); + Assert.Equal("Посилання недійсне або термін дії вичерпано", error); + + var notUpdatedUser = await _assertContext.Users.FirstAsync(u => u.Id == 1); + Assert.False(notUpdatedUser.IsEmailConfirmed); + Assert.NotNull(notUpdatedUser.EmailConfirmationTokenHash); + } + + [Fact] + public async Task ConfirmEmailAsync_WithExpiredToken_ReturnsFalseAndError() + { + // Arrange + + string rawToken = ExpiredRawToken; + string tokenHash = ComputeSha256Hash(rawToken); + + var user = new User + { + Id = 1, + Username = TestUsername, + Email = TestEmail, + PasswordHash = "hashed_pw", + IsEmailConfirmed = false, + EmailConfirmationTokenHash = tokenHash, + EmailTokenExpiryDate = DateTime.UtcNow.AddMinutes(-10) // Time ends 10 min ago + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _authService.ConfirmEmailAsync(rawToken); + + // Assert + Assert.False(success); + Assert.Equal("Посилання недійсне або термін дії вичерпано", error); + + var notUpdatedUser = await _assertContext.Users.FirstAsync(u => u.Id == 1); + Assert.False(notUpdatedUser.IsEmailConfirmed); + Assert.NotNull(notUpdatedUser.EmailConfirmationTokenHash); + } + } +} + + + diff --git a/TestProject/ServicesTest/BCAuthorServiceTests.cs b/TestProject/ServicesTest/BCAuthorServiceTests.cs new file mode 100644 index 0000000..52abbb0 --- /dev/null +++ b/TestProject/ServicesTest/BCAuthorServiceTests.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace VersumTestProject.ServicesTest +{ + internal class BCAuthorServiceTests + { + } +} diff --git a/TestProject/ServicesTest/PostServiceTests.cs b/TestProject/ServicesTest/PostServiceTests.cs new file mode 100644 index 0000000..05edff8 --- /dev/null +++ b/TestProject/ServicesTest/PostServiceTests.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace VersumTestProject.ServicesTest +{ + internal class PostServiceTests + { + } +} diff --git a/TestProject/ServicesTest/ProfileServiceTests.cs b/TestProject/ServicesTest/ProfileServiceTests.cs new file mode 100644 index 0000000..7afd09d --- /dev/null +++ b/TestProject/ServicesTest/ProfileServiceTests.cs @@ -0,0 +1,287 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using System.Security.Cryptography; +using System.Text; +using Versum; +using Versum.Context; +using Versum.Dtos; +using Versum.Services; + + + + + +namespace VersumTestProject.ServicesTest +{ + public class ProfileServiceTests : IDisposable + { + + private readonly ApplicationDbContext _context; + private readonly ApplicationDbContext _assertContext; + private readonly ProfileService _profileService; + + private const string TestUsername = "vixy"; + private const string UpdatedUsername = "vixy_upd"; + private const string TestEmail = "vixy@test.com"; + private const string TestBio = "bio"; + private const string TestName = "viktoria"; + + + // SET UP + public ProfileServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; + + _context = new ApplicationDbContext(options); + _profileService = new ProfileService(_context); + _assertContext = new ApplicationDbContext(options); + } + + + // TEARDOWN + public void Dispose() + { + _context.Dispose(); + _assertContext.Dispose(); + + } + + [Fact] + public async Task GetProfileByUsernameAsync_WhenProfileExists_ReturnsUserProfileile() + { + //Arrange data + + var testUser = new User { Id = 1, Username = TestUsername, Email = TestEmail }; + var testProfile = new UserProfile { Id = 1, UserId = 1, Name = TestName, Bio = TestBio }; + + _context.Users.Add(testUser); + _context.Profiles.Add(testProfile); + await _context.SaveChangesAsync(); + + //Act: testing method + var result = await _profileService.GetProfileByUsernameAsync(TestUsername, 1); + + //Assert: checking result + Assert.NotNull(result); + Assert.Equal(TestBio, result.Bio); + Assert.Equal(TestName, result.Name); + Assert.Equal(TestUsername, result.Username); + } + + [Fact] + public async Task GetProfileByUsernameAsync_WhenUserDoesNotExist_ReturnsNull() + { + // Arrange + + // Act + var result = await _profileService.GetProfileByUsernameAsync("nonexistent_user", 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetProfileByUsernameAsync_WhenUserExistsButProfileDoesNot_ReturnsNone() + { + // Arrange + + var testUser = new User { Id = 2, Username = "noprofile", Email = "no@example.com" }; + _context.Users.Add(testUser); + await _context.SaveChangesAsync(); + + // Act + var result = await _profileService.GetProfileByUsernameAsync("noprofile", 2); + + // Assert + Assert.NotNull(result); + Assert.Equal("none", result.Bio); + Assert.Equal("none", result.Name); + + } + + [Fact] + public async Task GetProfileByUsernameAsync_WhenUsernameIsNullOrEmpty_ThrowsArgumentException() + { + // Arrange + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await _profileService.GetProfileByUsernameAsync("", 2); + }); + } + + [Fact] + public async Task UpdateProfileAsync_WhenUserExists_UpdatesDataAndReturnsSuccess() + { + // Arrange + + var testUser = new User { Id = 1, Username = "old_username", Email = TestEmail }; + var testProfile = new UserProfile { Id = 1, UserId = 1, Name = "Old Name", Bio = "Old Bio" }; + + _context.Users.Add(testUser); + _context.Profiles.Add(testProfile); + await _context.SaveChangesAsync(); + + var dto = new UserProfileDto { Username = "new_username", Name = "New Name", Bio = "New Bio" }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + + var updatedUser = await _assertContext.Users.Include(u => u.Profile).FirstAsync(u => u.Id == 1); + + Assert.Equal("new_username", updatedUser.Username); + Assert.Equal("New Name", updatedUser.Profile.Name); + Assert.Equal("New Bio", updatedUser.Profile.Bio); + } + + + [Fact] + public async Task UpdateProfileAsync_WhenUserDoesNotExist_ReturnsFalseAndErrorMessage() + { + // Arrange + + + var dto = new UserProfileDto { Username = TestUsername, Name = TestName, Bio = TestBio }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(99989897, dto); + + // Assert + Assert.False(success); + Assert.Equal("Чому нас вважають за одну людину?", error); + } + + [Fact] + public async Task UpdateProfileAsync_WhenUsernameIsTakenByAnotherUser_ReturnsFalseAndErrorMessage() + { + // Arrange + var userToUpdate = new User { Id = 1, Username = "user1", Email = "u1@test.com" }; + var existingUser = new User { Id = 2, Username = "taken_username", Email = "u2@test.com" }; + + _context.Users.AddRange(userToUpdate, existingUser); + await _context.SaveChangesAsync(); + + + var dto = new UserProfileDto { Username = "taken_username", Name = "Any Name", Bio = "Any Bio" }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.False(success); + Assert.Equal("Цей нікнейм вже існує", error); + } + + [Fact] + public async Task UpdateProfileAsync_WhenUserHasNoProfileYet_CreatesNewProfileAndReturnsSuccess() + { + // Arrange + + var userWithoutProfile = new User { Id = 1, Username = TestUsername, Email = TestEmail }; + _context.Users.Add(userWithoutProfile); + await _context.SaveChangesAsync(); + + + var dto = new UserProfileDto { Username = TestUsername, Name = TestName, Bio = TestBio }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + + var updatedUser = await _assertContext.Users.Include(u => u.Profile).FirstAsync(u => u.Id == 1); + + Assert.NotNull(updatedUser.Profile); + Assert.Equal(TestName, updatedUser.Profile.Name); + Assert.Equal(TestBio, updatedUser.Profile.Bio); + } + + [Fact] + public async Task UpdateBio_WhenKeepingOwnUsername_ReturnsSuccess() + { + // Arrange + var user = new User { Id = 1, Username = TestUsername, Email = TestEmail }; + var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName , Bio = "Old Bio" }; + + _context.Users.Add(user); + _context.Profiles.Add(profile); + await _context.SaveChangesAsync(); + + + var dto = new UserProfileDto { Username = TestUsername, Name = "new_name", Bio = "New Bio" }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + var updatedProfile = await _assertContext.Profiles.FirstAsync(p => p.UserId == 1); + Assert.Equal("New Bio", updatedProfile.Bio); + } + [Fact] + public async Task UpdateName_WhenKeepingOwnUsername_ReturnsSuccess() + { + // Arrange + + var user = new User { Id = 1, Username = TestUsername, Email = TestEmail }; + var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName, Bio = "Old Bio" }; + + _context.Users.Add(user); + _context.Profiles.Add(profile); + await _context.SaveChangesAsync(); + + var dto = new UserProfileDto { Username = TestUsername, Name = "Vik", Bio = "Old Bio" }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + var updatedProfile = await _assertContext.Profiles.FirstAsync(p => p.UserId == 1); + Assert.Equal("Vik", updatedProfile.Name); + } + + [Fact] + public async Task UpdateUserNameOnlyReturnsSuccess() + { + // Arrange + + var user = new User { Id = 1, Username = TestUsername, Email = TestEmail }; + var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName, Bio = "Old Bio" }; + + _context.Users.Add(user); + _context.Profiles.Add(profile); + await _context.SaveChangesAsync(); + + var dto = new UserProfileDto { Username = UpdatedUsername, Name = TestName, Bio = "Old Bio" }; + + // Act + var (success, error) = await _profileService.UpdateProfileAsync(1, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + + var updatedUser = await _assertContext.Users.Include(u => u.Profile).FirstAsync(u => u.Id == 1); + Assert.Equal(UpdatedUsername, updatedUser.Username); + } + + } + } + diff --git a/TestProject/VersumTestProject.csproj b/TestProject/VersumTestProject.csproj new file mode 100644 index 0000000..4070470 --- /dev/null +++ b/TestProject/VersumTestProject.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Versum.slnx b/Versum.slnx index f3411f9..f4631b6 100644 --- a/Versum.slnx +++ b/Versum.slnx @@ -1,3 +1,6 @@ + + + diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index b492cf1..1fa6d37 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -1,7 +1,7 @@ using global::Versum.Dtos; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Versum.Services; +using Versum.Context; namespace Versum.Controllers { diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index ea205fd..89f2d02 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -5,8 +5,10 @@ using System.Security.Claims; using Versum.Dtos; using Versum.Hubs; +using Versum.Context; using Versum.Services; + namespace Versum.Controllers { diff --git a/Versum/DdContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs similarity index 98% rename from Versum/DdContext/ApplicationDbContext.cs rename to Versum/DbContext/ApplicationDbContext.cs index c2b5670..798e0bf 100644 --- a/Versum/DdContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Versum.Models; -namespace Versum +namespace Versum.Context { public class ApplicationDbContext : DbContext { diff --git a/Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs b/Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs deleted file mode 100644 index 8784413..0000000 --- a/Versum/Migrations/20260503150257_Posts Table Rework.Designer.cs +++ /dev/null @@ -1,258 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260503150257_Posts Table Rework")] - partial class PostsTableRework - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Genre", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Genres"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.Property("GenresId") - .HasColumnType("integer"); - - b.Property("PostsId") - .HasColumnType("integer"); - - b.HasKey("GenresId", "PostsId"); - - b.HasIndex("PostsId"); - - b.ToTable("GenrePost"); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.Property("AuthorId") - .HasColumnType("integer"); - - b.Property("AuthorBio") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.HasKey("AuthorId"); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Content") - .IsRequired() - .HasMaxLength(500000) - .HasColumnType("character varying(500000)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(600) - .HasColumnType("character varying(600)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.HasIndex("Email", "PasswordResetToken"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Bio") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Name") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("Profiles"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.HasOne("Genre", null) - .WithMany() - .HasForeignKey("GenresId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.Post", null) - .WithMany() - .HasForeignKey("PostsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.Author", "AuthorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.HasOne("Versum.User", "User") - .WithMany("Posts") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.HasOne("Versum.User", "User") - .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - - b.Navigation("Posts"); - - b.Navigation("Profile") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260503150257_Posts Table Rework.cs b/Versum/Migrations/20260503150257_Posts Table Rework.cs deleted file mode 100644 index addb51e..0000000 --- a/Versum/Migrations/20260503150257_Posts Table Rework.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class PostsTableRework : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 89bc4d2..8b63acf 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; +using Versum.Context; #nullable disable diff --git a/Versum/Program.cs b/Versum/Program.cs index e6bfe64..6c54c5d 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -18,7 +18,7 @@ builder.Services.AddSwaggerGen(); builder.Services.AddSignalR(); -builder.Services.AddDbContext(options => +builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index 2768487..f979b90 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Text; using Versum.Dtos; +using Versum.Context; namespace Versum.Services { diff --git a/Versum/Services/BCAuthorService.cs b/Versum/Services/BCAuthorService.cs index ed28ecb..4e44ba6 100644 --- a/Versum/Services/BCAuthorService.cs +++ b/Versum/Services/BCAuthorService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Versum.Dtos; using Versum.Models; +using Versum.Context; namespace Versum.Services { diff --git a/Versum/Services/EmailService.cs b/Versum/Services/EmailService.cs index c98d8f0..52a1903 100644 --- a/Versum/Services/EmailService.cs +++ b/Versum/Services/EmailService.cs @@ -2,7 +2,7 @@ using MailKit.Net.Smtp; using MimeKit; using Microsoft.Extensions.Options; - +using Versum.Context; public class EmailService : IEmailService { private readonly IConfiguration _config; diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index b03a021..a786835 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using Versum.Dtos; +using Versum.Context; namespace Versum.Services { @@ -61,6 +62,10 @@ public ProfileService(ApplicationDbContext db) public async Task GetProfileByUsernameAsync(string username, int? claimedUserID) { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("Username cannot be null or empty.", nameof(username)); + } return await _db.Users .Where(u => u.Username == username) .Select(u => new UserProfileResponseDto diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index ccbd74f..2b33b37 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -37,7 +37,7 @@ - + From b6fc27bbb19e2973d0067cc491de715d40b0a78a Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 7 May 2026 18:42:36 +0300 Subject: [PATCH 28/67] [Fix] Added context dependency --- Versum/Services/PostService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 088a1d4..ed747d8 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Versum.Dtos; using Versum.Models; +using Versum.Context; namespace Versum.Services { From ec72e6b06ec143e926d0644798c19df3a02eeede Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 7 May 2026 19:15:52 +0300 Subject: [PATCH 29/67] [TEST] Added automatic testing for GitHub actions --- .github/workflows/develop_versum.yml | 35 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index af4a3fa..949179c 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -13,7 +13,7 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: read #This is required for actions/checkout + contents: read steps: - uses: actions/checkout@v4 @@ -23,11 +23,17 @@ jobs: with: dotnet-version: '10.x' + - name: Restore dependencies + run: dotnet restore + + - name: Test + run: dotnet test TestProject/TestProject.csproj --configuration Release --no-restore + - name: Build with dotnet - run: dotnet build --configuration Release + run: dotnet build --configuration Release --no-restore - name: dotnet publish - run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp + run: dotnet publish Versum/Versum.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp - name: Upload artifact for deployment job uses: actions/upload-artifact@v4 @@ -38,22 +44,22 @@ jobs: deploy: runs-on: ubuntu-latest needs: build - permissions: - id-token: write #This is required for requesting the JWT - contents: read #This is required for actions/checkout + permissions: + id-token: write + contents: read steps: - name: Download artifact from build job uses: actions/download-artifact@v4 with: name: .net-app - - - name: Login to Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_E1A151645519484388D8610703C3F319 }} - tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_7EA0EA099A634F608D8A7349EBE6DAFE }} - subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_89B30947B5B34F7B8B916AF82746C262 }} + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_E1A151645519484388D8610703C3F319 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_7EA0EA099A634F608D8A7349EBE6DAFE }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_89B30947B5B34F7B8B916AF82746C262 }} - name: Deploy to Azure Web App id: deploy-to-webapp @@ -61,5 +67,4 @@ jobs: with: app-name: 'Versum' slot-name: 'Production' - package: . - \ No newline at end of file + package: . \ No newline at end of file From 6f9f753b6dcdc0a16e11d300994011cc33193bd4 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 7 May 2026 19:47:32 +0300 Subject: [PATCH 30/67] [FIX] Test folder configured --- .github/workflows/develop_versum.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index 949179c..a3fc76d 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -27,7 +27,7 @@ jobs: run: dotnet restore - name: Test - run: dotnet test TestProject/TestProject.csproj --configuration Release --no-restore + run: dotnet test VersumTestProject/VersumTestProject.csproj --configuration Release --no-restore - name: Build with dotnet run: dotnet build --configuration Release --no-restore From d7f92b12efde670b24d971d41379ed6490372966 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 7 May 2026 19:50:05 +0300 Subject: [PATCH 31/67] [FIX] Fixed test folder --- .github/workflows/develop_versum.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index a3fc76d..7eec36c 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -27,7 +27,7 @@ jobs: run: dotnet restore - name: Test - run: dotnet test VersumTestProject/VersumTestProject.csproj --configuration Release --no-restore + run: dotnet test TestProject/VersumTestProject.csproj --configuration Release --no-restore - name: Build with dotnet run: dotnet build --configuration Release --no-restore From ee6f4e67bd6c929cfc95bb6a05e0019447367df5 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Thu, 7 May 2026 19:53:32 +0300 Subject: [PATCH 32/67] [Fix] Fixed github actions file Updated the Azure workflow for building and deploying the ASP.Net Core app. Adjusted permissions and removed unnecessary steps. --- .github/workflows/develop_versum.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml index 7eec36c..ade19b5 100644 --- a/.github/workflows/develop_versum.yml +++ b/.github/workflows/develop_versum.yml @@ -13,7 +13,7 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: read + contents: read #This is required for actions/checkout steps: - uses: actions/checkout@v4 @@ -22,18 +22,15 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: '10.x' - - - name: Restore dependencies - run: dotnet restore - + - name: Test run: dotnet test TestProject/VersumTestProject.csproj --configuration Release --no-restore - name: Build with dotnet - run: dotnet build --configuration Release --no-restore + run: dotnet build --configuration Release - name: dotnet publish - run: dotnet publish Versum/Versum.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp + run: dotnet publish -c Release -o ${{env.DOTNET_ROOT}}/myapp - name: Upload artifact for deployment job uses: actions/upload-artifact@v4 @@ -45,8 +42,8 @@ jobs: runs-on: ubuntu-latest needs: build permissions: - id-token: write - contents: read + id-token: write #This is required for requesting the JWT + contents: read #This is required for actions/checkout steps: - name: Download artifact from build job @@ -67,4 +64,5 @@ jobs: with: app-name: 'Versum' slot-name: 'Production' - package: . \ No newline at end of file + package: . + From 99ea24c24d6b948ffb0ad0dec2d52e3d4ba3ec69 Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Sun, 10 May 2026 01:36:26 +0300 Subject: [PATCH 33/67] Feature/create post (#26) * [Feature]Implement endpoints for publish and save draft --- Versum/Controllers/PostsController.cs | 40 +++++++++++++++++---- Versum/Dtos/CreateDraftDto.cs | 12 +++++++ Versum/Dtos/PostDto.cs | 2 +- Versum/Services/IPostService.cs | 5 +-- Versum/Services/PostService.cs | 51 +++++++++++++++++---------- 5 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 Versum/Dtos/CreateDraftDto.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 89f2d02..41c589c 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -30,17 +30,17 @@ public PostsController(ApplicationDbContext context, IHubContext CreatePost([FromBody] PostDto dto) + public async Task PublishDraft(int postId) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!int.TryParse(userIdClaim, out int authorId)) + if (!int.TryParse(userIdClaim, out int userId)) { return Unauthorized(); } - var (success, error) = await _postService.PublishPostAsync(authorId, dto); + var (success, error) = await _postService.PublishDraftAsync(postId, userId); if (!success) { if (error == "AuthorNotFound") return NotFound(new { message = "Профіль автора не знайдено" }); @@ -57,7 +57,7 @@ public async Task CreatePost([FromBody] PostDto dto) [HttpPost("create-draft")] [Authorize] - public async Task CreateDraft([FromBody] PostDto dto) + public async Task CreateDraft([FromBody] CreateDraftDto dto) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -76,11 +76,39 @@ public async Task CreateDraft([FromBody] PostDto dto) return StatusCode(201, new { - message = "Чернетку збережено", + message = "Чернетку створено", postId = postId }); } + [HttpPost("{postId}/update-draft")] + [Authorize] + public async Task UpdateDraft(int postId,[FromBody] PostDto dto) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.UpdateDraftAsync(postId, userId, dto); + + if (!success) + { + if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Чернетку збережено", + + }); + } + + + [HttpGet] public async Task>> GetPosts() { diff --git a/Versum/Dtos/CreateDraftDto.cs b/Versum/Dtos/CreateDraftDto.cs new file mode 100644 index 0000000..a731e68 --- /dev/null +++ b/Versum/Dtos/CreateDraftDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class CreateDraftDto + { + + [Required(ErrorMessage = "Введіть назву твору")] + [MaxLength(100, ErrorMessage = "Максимум 100 символів")] + public string Title { get; set; } = ""; + } +} diff --git a/Versum/Dtos/PostDto.cs b/Versum/Dtos/PostDto.cs index 6db7bed..4748624 100644 --- a/Versum/Dtos/PostDto.cs +++ b/Versum/Dtos/PostDto.cs @@ -14,7 +14,7 @@ public class PostDto public string Description { get; set; } = ""; [Required(ErrorMessage = "Твір не може бути порожнім")] - [MaxLength(500000, ErrorMessage = "Максимум 600 символів")] + [MaxLength(500000, ErrorMessage = "Максимум 500000 символів")] [MinLength(10, ErrorMessage = "Мінімум 10 символів")] public string Content { get; set; } = ""; diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index aebff4d..666f62e 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -4,7 +4,8 @@ namespace Versum.Services { public interface IPostService { - Task<(bool Success, string? Error)> PublishPostAsync(int Id, PostDto dto); - Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, PostDto dto); + Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId); + Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); + Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); } } \ No newline at end of file diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index ed747d8..a3c9fa3 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -16,27 +16,19 @@ public PostService(ApplicationDbContext db) } - public async Task<(bool Success, string? Error)> PublishPostAsync(int authorId, PostDto dto) + public async Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId) { try { - var author = await _db.Authors.FirstOrDefaultAsync(u => u.AuthorId == authorId); + + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId); - if (author == null) return (false, "AuthorNotFound"); - - var newPost = new Post - { - Title = dto.Title, - Description = dto.Description, - Content = dto.Content, - AuthorId = author.AuthorId, - CreatedAt = DateTime.UtcNow - - }; - - _db.Posts.Add(newPost); + if (post == null) return (false, "PostNotFound"); + if (post.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); + post.IsDraft = false; + await _db.SaveChangesAsync(); return (true, null); @@ -48,7 +40,7 @@ public PostService(ApplicationDbContext db) } } - public async Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, PostDto dto) + public async Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto) { try { @@ -58,8 +50,6 @@ public PostService(ApplicationDbContext db) var draftPost = new Post { Title = dto.Title, - Description = dto.Description ?? string.Empty, - Content = dto.Content ?? string.Empty, AuthorId = authorId, IsDraft = true, CreatedAt = DateTime.UtcNow @@ -77,5 +67,28 @@ public PostService(ApplicationDbContext db) } } + public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) + { + try + { + var draft = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId); + + if (draft == null) return (false, "DraftNotFound"); + if (draft.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); + + draft.Title = dto.Title; + draft.Description = dto.Description; + draft.Content = dto.Content; + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"UpdateAuthorBioAsync error: {ex.Message}"); + return (false, "ServerError"); + } + + } } -} \ No newline at end of file +} From 7546a9c73700bd7032fb0e2d710a28d0751f8202 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Sun, 10 May 2026 21:02:53 +0300 Subject: [PATCH 34/67] [FEATURE] Implement endpoints for post and draft retrieval (#27) * [FEATURE] Implement endpoints for post and draft retrieval * [REFACTOR] major code overhaul * [HOTFIX] Changed error sending logic * [HOTFIX] Removed misplaced curly brackets --- Versum/Controllers/AuthController.cs | 9 ---- Versum/Controllers/PostsController.cs | 64 ++++++++++++++++++--------- Versum/Core/Enums/FilterOptions.cs | 9 ++++ Versum/Dtos/UserDraftsGetDto.cs | 19 ++++++++ Versum/Dtos/UserPostsRequestDto.cs | 14 ++++++ Versum/Extensions/PostExtensions.cs | 62 ++++++++++++++++++++++++++ Versum/Services/IPostService.cs | 6 ++- Versum/Services/PostService.cs | 27 +++++++++++ 8 files changed, 180 insertions(+), 30 deletions(-) create mode 100644 Versum/Core/Enums/FilterOptions.cs create mode 100644 Versum/Dtos/UserDraftsGetDto.cs create mode 100644 Versum/Dtos/UserPostsRequestDto.cs create mode 100644 Versum/Extensions/PostExtensions.cs diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index 1fa6d37..e27d2ab 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -71,15 +71,6 @@ public async Task Login([FromBody] LoginDto dto)// JSON converts if (!success) return Unauthorized(new { message = resultMessage }); // checks if login fails (wrong password or user) -> returns error 401 - try - { - //await _gmailService.SendLoginNotificationAsync(userGmail, username);// awaits email sending to avoid crashes - } - catch (Exception ex) - { - Console.WriteLine($"Gmail sending error: {ex.Message}");// logs error but doesn't stop the login process - } - return Ok(new { token = resultMessage, userGmail, username, message = "Вхід успішний" }); } diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 41c589c..7cb470a 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using System.Security.Claims; +using Versum.Core.Enums; using Versum.Dtos; using Versum.Hubs; using Versum.Context; @@ -80,39 +81,62 @@ public async Task CreateDraft([FromBody] CreateDraftDto dto) postId = postId }); } + + [HttpPost("{postId}/update-draft")] + [Authorize] + public async Task UpdateDraft(int postId,[FromBody] PostDto dto) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.UpdateDraftAsync(postId, userId, dto); + + if (!success) + { + if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Чернетку збережено", + + }); + } - [HttpPost("{postId}/update-draft")] + [HttpGet("get-drafts")] [Authorize] - public async Task UpdateDraft(int postId,[FromBody] PostDto dto) + public async Task>> GetDrafts( + [FromQuery] FilterOptions filter, + [FromQuery] bool ascending) { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!int.TryParse(userIdClaim, out int userId)) + if (!int.TryParse(userIdClaim, out int authorId)) { return Unauthorized(); } + var drafts = await _postService.GetUserDraftsAsync(authorId, filter, ascending); + return Ok(drafts); //Користувачі, які не мають ролі автора або не мають створених чернеток отримують порожній список + } - var (success, error) = await _postService.UpdateDraftAsync(postId, userId, dto); - - if (!success) + [HttpGet("get-posts")] + public async Task>> GetPosts( + [FromQuery] UserPostsRequestDto dto + ) + { + var (posts, error) = await _postService.GetUserPostsAsync(dto); + if (error != null) { - if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); + if (error == "UserNotFound") return NotFound(new { message = "Користувача не знайдено" }); return BadRequest(new { message = error }); } - return StatusCode(201, new - { - message = "Чернетку збережено", - - }); - } - + return Ok(posts); - - [HttpGet] - public async Task>> GetPosts() - { - return await _context.Posts.OrderByDescending(p => p.CreatedAt).ToListAsync(); } } } diff --git a/Versum/Core/Enums/FilterOptions.cs b/Versum/Core/Enums/FilterOptions.cs new file mode 100644 index 0000000..410d276 --- /dev/null +++ b/Versum/Core/Enums/FilterOptions.cs @@ -0,0 +1,9 @@ +namespace Versum.Core.Enums +{ + public enum FilterOptions + { + Title, + Description, + CreatedAt + } +} diff --git a/Versum/Dtos/UserDraftsGetDto.cs b/Versum/Dtos/UserDraftsGetDto.cs new file mode 100644 index 0000000..2c3e7e8 --- /dev/null +++ b/Versum/Dtos/UserDraftsGetDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; + + +namespace Versum.Dtos +{ + public class UserPostsGetDto + { + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public int PostId { get; set; } + public string Username { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public ICollection Genres { get; set; } = new List(); + + } +} \ No newline at end of file diff --git a/Versum/Dtos/UserPostsRequestDto.cs b/Versum/Dtos/UserPostsRequestDto.cs new file mode 100644 index 0000000..5b5fedb --- /dev/null +++ b/Versum/Dtos/UserPostsRequestDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using Versum.Core.Enums; + +namespace Versum.Dtos +{ + public class UserPostsRequestDto + { + [Required(ErrorMessage = "Введіть свій нікнейм")] + public string Username { get; set; } = string.Empty; + public FilterOptions Filter { get; set; } + public bool Ascending { get; set; } + + } +} \ No newline at end of file diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs new file mode 100644 index 0000000..be51649 --- /dev/null +++ b/Versum/Extensions/PostExtensions.cs @@ -0,0 +1,62 @@ +using System.Linq; +using Versum.Core.Enums; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Extensions; + +public static class PostExtensions +{ + public static IQueryable OnlyPublished(this IQueryable query) + { + return query.Where(p => !p.IsDraft); + } + + public static IQueryable OnlyDrafts(this IQueryable query) + { + return query.Where(p => p.IsDraft); + } + + public static IQueryable ApplySorting( + this IQueryable query, + FilterOptions filter, + bool ascending) + { + return ascending + ? query.OrderByField(filter) + : query.OrderByFieldDescending(filter); + } + + private static IQueryable OrderByField(this IQueryable query, FilterOptions filter) => + filter switch + { + FilterOptions.Title => query.OrderBy(p => p.Title), + FilterOptions.CreatedAt => query.OrderBy(p => p.CreatedAt), + FilterOptions.Description => query.OrderBy(p => p.Description), + _ => query.OrderBy(p => p.Id) + }; + + private static IQueryable OrderByFieldDescending(this IQueryable query, FilterOptions filter) => + filter switch + { + FilterOptions.Title => query.OrderByDescending(p => p.Title), + FilterOptions.CreatedAt => query.OrderByDescending(p => p.CreatedAt), + FilterOptions.Description => query.OrderByDescending(p => p.Description), + _ => query.OrderByDescending(p => p.Id) + }; + + public static IQueryable ProjectToPostDto(this IQueryable query) + { + return query.Select(p => new UserPostsGetDto + { + PostId = p.Id, + Title = p.Title, + Description = p.Description ?? "none", + Content = p.Content ?? "none", + CreatedAt = p.CreatedAt, + Username = p.Author.User.Username, + Name = p.Author.User.Profile.Name ?? "none", + Genres = p.Genres + }); + } +} \ No newline at end of file diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index 666f62e..f966e4c 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -1,9 +1,13 @@ -using Versum.Dtos; +using Versum.Core.Enums; +using Versum.Dtos; namespace Versum.Services { public interface IPostService { + + Task> GetUserDraftsAsync(int claimedUserID, FilterOptions filter, bool ascending); + Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto); Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId); Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index a3c9fa3..3ca36b0 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -1,6 +1,9 @@ using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; using Versum.Dtos; using Versum.Models; +using Versum.Core.Enums; +using Versum.Extensions; using Versum.Context; namespace Versum.Services @@ -67,6 +70,30 @@ public PostService(ApplicationDbContext db) } } + public async Task> GetUserDraftsAsync(int authorId, FilterOptions filter, bool ascending) + { + return await _db.Posts + .AsNoTracking() + .Where(p => p.AuthorId == authorId) + .OnlyDrafts() + .ApplySorting(filter, ascending) + .ProjectToPostDto() + .ToListAsync(); + } + + public async Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto) + { + var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Username == dto.Username); + if (user == null) return new (null, "UserNotFound"); + return (await _db.Posts + .AsNoTracking() + .Where(p => p.AuthorId == user.Id) + .OnlyPublished() + .ApplySorting(dto.Filter, dto.Ascending) + .ProjectToPostDto() + .ToListAsync(), null); + } + public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) { try From cc8aac8099b7644199d10b8f10377ade3c3c7336 Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Sun, 10 May 2026 21:05:09 +0300 Subject: [PATCH 35/67] [FEATURE]Implemented delete post (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Вікторія Свирид --- Versum/Controllers/PostsController.cs | 27 +- ...0509183326_AddIsDeletedToPosts.Designer.cs | 267 ++++++++++++++++++ .../20260509183326_AddIsDeletedToPosts.cs | 29 ++ .../ApplicationDbContextModelSnapshot.cs | 27 +- Versum/Models/Post.cs | 2 + Versum/Services/IPostService.cs | 1 + Versum/Services/PostService.cs | 28 +- 7 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 Versum/Migrations/20260509183326_AddIsDeletedToPosts.Designer.cs create mode 100644 Versum/Migrations/20260509183326_AddIsDeletedToPosts.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 7cb470a..2a89738 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -138,5 +138,30 @@ [FromQuery] UserPostsRequestDto dto return Ok(posts); } + + + [HttpPost("{postId}/delete-post")] + [Authorize] + public async Task DeletePost(int postId) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.DeletePostAsync(userId,postId); + if (!success) + { + if (error == "PostNotFound") + return NotFound(new { message = "Твір не знайдено" }); + return BadRequest(new { message = error }); + } + + return Ok(new { message = "Твір успішно видалено" }); + } + } -} + + } diff --git a/Versum/Migrations/20260509183326_AddIsDeletedToPosts.Designer.cs b/Versum/Migrations/20260509183326_AddIsDeletedToPosts.Designer.cs new file mode 100644 index 0000000..ab9094f --- /dev/null +++ b/Versum/Migrations/20260509183326_AddIsDeletedToPosts.Designer.cs @@ -0,0 +1,267 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260509183326_AddIsDeletedToPosts")] + partial class AddIsDeletedToPosts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260509183326_AddIsDeletedToPosts.cs b/Versum/Migrations/20260509183326_AddIsDeletedToPosts.cs new file mode 100644 index 0000000..8de5104 --- /dev/null +++ b/Versum/Migrations/20260509183326_AddIsDeletedToPosts.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddIsDeletedToPosts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Posts", + type: "boolean", + nullable: false, + defaultValue: false); + + + } + + /// + + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 8b63acf..331dae3 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -77,6 +77,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AuthorId") + .HasColumnType("integer"); + b.Property("Content") .IsRequired() .HasMaxLength(500000) @@ -90,17 +93,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(600) .HasColumnType("character varying(600)"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + b.Property("Title") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); - b.Property("UserId") - .HasColumnType("integer"); - b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("AuthorId"); b.ToTable("Posts"); }); @@ -220,13 +226,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Versum.Post", b => { - b.HasOne("Versum.User", "User") + b.HasOne("Versum.Models.Author", "Author") .WithMany("Posts") - .HasForeignKey("UserId") + .HasForeignKey("AuthorId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("User"); + b.Navigation("Author"); }); modelBuilder.Entity("Versum.UserProfile", b => @@ -240,12 +246,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + modelBuilder.Entity("Versum.User", b => { b.Navigation("AuthorProfile"); - b.Navigation("Posts"); - b.Navigation("Profile") .IsRequired(); }); diff --git a/Versum/Models/Post.cs b/Versum/Models/Post.cs index 4fe585c..b6a4752 100644 --- a/Versum/Models/Post.cs +++ b/Versum/Models/Post.cs @@ -14,5 +14,7 @@ public class Post public ICollection Genres { get; set; } = new List(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public bool IsDraft { get; set; } + public bool IsDeleted { get; set; } = false; + } } diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index f966e4c..d557639 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -5,6 +5,7 @@ namespace Versum.Services { public interface IPostService { + Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); Task> GetUserDraftsAsync(int claimedUserID, FilterOptions filter, bool ascending); Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto); diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 3ca36b0..6fd6b19 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -23,7 +23,7 @@ public PostService(ApplicationDbContext db) { try { - + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId); if (post == null) return (false, "PostNotFound"); @@ -31,7 +31,7 @@ public PostService(ApplicationDbContext db) if (post.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); post.IsDraft = false; - + await _db.SaveChangesAsync(); return (true, null); @@ -117,5 +117,29 @@ public async Task> GetUserDraftsAsync(int authorId, Filter } } + + public async Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId) + { + + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId && p.AuthorId == userId && !p.IsDeleted); + + if (post == null) return (false, "PostNotFound"); + + post.IsDeleted = true; + + try + { + + await _db.SaveChangesAsync(); + return (true, null); + + } + catch (Exception ex) + { + Console.WriteLine($"DeletePostAsync error: {ex.Message}"); + return (false, "ServerError"); + } + + } } } From 345b9dd5b6139dbbc1a5186fea43dba6bf14350e Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Tue, 12 May 2026 20:53:08 +0300 Subject: [PATCH 36/67] [FEATURE] Get Drafts method, updated Update Draft method and code cleanup (#29) --- TestProject/ServicesTest/AuthServiceTests.cs | 6 +- .../ServicesTest/ProfileServiceTests.cs | 6 +- Versum/Controllers/AuthController.cs | 8 +- Versum/Controllers/PostsController.cs | 59 ++++++----- Versum/DbContext/ApplicationDbContext.cs | 4 +- Versum/Dtos/LoginDto.cs | 8 +- Versum/Dtos/PostDto.cs | 4 +- Versum/Dtos/RegisterDto.cs | 9 +- Versum/Dtos/UserDraftsGetDto.cs | 2 +- Versum/Dtos/UserProfileDto.cs | 4 +- Versum/Extensions/PostExtensions.cs | 2 +- Versum/Models/UserProfile.cs | 2 +- Versum/Program.cs | 6 +- Versum/Services/AuthService.cs | 31 +++--- Versum/Services/BCAuthorService.cs | 98 +++++++++---------- Versum/Services/EmailService.cs | 13 ++- Versum/Services/IBCAuthorService.cs | 16 +-- Versum/Services/IPostService.cs | 4 +- Versum/Services/PostService.cs | 16 ++- Versum/Services/ProfileService.cs | 2 +- 20 files changed, 158 insertions(+), 142 deletions(-) diff --git a/TestProject/ServicesTest/AuthServiceTests.cs b/TestProject/ServicesTest/AuthServiceTests.cs index 8cb3844..ceeccd3 100644 --- a/TestProject/ServicesTest/AuthServiceTests.cs +++ b/TestProject/ServicesTest/AuthServiceTests.cs @@ -11,7 +11,7 @@ namespace VersumTestProject.ServicesTest { - public class AuthServiceTests: IDisposable + public class AuthServiceTests : IDisposable { private readonly ApplicationDbContext _context; @@ -60,7 +60,7 @@ private void TemporaryTemplate() "Привіт, {Username}! Посилання: {confirmLink}" ); } - + private string ComputeSha256Hash(string rawData) { using (var sha256 = SHA256.Create()) @@ -112,7 +112,7 @@ public async Task RegisterAsync_WhenDataIsValid_SuccessfullyRegistersUserAndSend // checks email sending with use of Email Mock // У тестах ви просто робите так: - + _emailServiceMock.Verify( x => x.SendEmailAsync( TestEmail, diff --git a/TestProject/ServicesTest/ProfileServiceTests.cs b/TestProject/ServicesTest/ProfileServiceTests.cs index 7afd09d..43fbe8c 100644 --- a/TestProject/ServicesTest/ProfileServiceTests.cs +++ b/TestProject/ServicesTest/ProfileServiceTests.cs @@ -212,7 +212,7 @@ public async Task UpdateBio_WhenKeepingOwnUsername_ReturnsSuccess() { // Arrange var user = new User { Id = 1, Username = TestUsername, Email = TestEmail }; - var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName , Bio = "Old Bio" }; + var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName, Bio = "Old Bio" }; _context.Users.Add(user); _context.Profiles.Add(profile); @@ -263,7 +263,7 @@ public async Task UpdateUserNameOnlyReturnsSuccess() var user = new User { Id = 1, Username = TestUsername, Email = TestEmail }; var profile = new UserProfile { Id = 1, UserId = 1, Name = TestName, Bio = "Old Bio" }; - + _context.Users.Add(user); _context.Profiles.Add(profile); await _context.SaveChangesAsync(); @@ -283,5 +283,5 @@ public async Task UpdateUserNameOnlyReturnsSuccess() } } - } +} diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index e27d2ab..c53af59 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -20,10 +20,10 @@ public AuthController(IAuthService authService, IEmailService emailService) _emailService = emailService; } - + [HttpPost("register")] - + public async Task Register([FromBody] RegisterDto dto)// JSON converts to RegisterDtos object { if (!ModelState.IsValid) @@ -35,7 +35,7 @@ public async Task Register([FromBody] RegisterDto dto)// JSON con return Conflict(new { field, message = error }); //checks if data for transfer does not cause conflicts(error 409) return Ok(new { message = "Реєстрація успішна! Перевірте пошту для підтвердження." }); - + } @@ -108,7 +108,7 @@ public async Task ResetPasswordTokenCheck([FromBody] ResetPasswor { if (!ModelState.IsValid) return BadRequest(ModelState);// checks validation attributes from LoginDto -> Smth wrong -> returns error 400 - + var (success, error) = await _authService.ResetPasswordTokenCheckAsync(dto); if (!success) diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 2a89738..5b69811 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -81,32 +81,32 @@ public async Task CreateDraft([FromBody] CreateDraftDto dto) postId = postId }); } - - [HttpPost("{postId}/update-draft")] - [Authorize] - public async Task UpdateDraft(int postId,[FromBody] PostDto dto) - { - - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!int.TryParse(userIdClaim, out int userId)) - { - return Unauthorized(); - } - - var (success, error) = await _postService.UpdateDraftAsync(postId, userId, dto); - - if (!success) - { - if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); - return BadRequest(new { message = error }); - } - - return StatusCode(201, new - { - message = "Чернетку збережено", - - }); - } + + [HttpPost("{postId}/update-draft")] + [Authorize] + public async Task UpdateDraft(int postId, [FromBody] PostDto dto) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.UpdateDraftAsync(postId, userId, dto); + + if (!success) + { + if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Чернетку збережено", + + }); + } [HttpGet("get-drafts")] [Authorize] @@ -138,7 +138,6 @@ [FromQuery] UserPostsRequestDto dto return Ok(posts); } - [HttpPost("{postId}/delete-post")] [Authorize] @@ -162,6 +161,12 @@ public async Task DeletePost(int postId) return Ok(new { message = "Твір успішно видалено" }); } + [HttpGet("get-genres")] + public async Task GetGenres() + { + var genres = await _postService.GetGenresAsync(); + return Ok(genres); + } } } diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 798e0bf..fa0a8f1 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -12,13 +12,13 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Posts { get; set; } public DbSet Users { get; set; } - public DbSet Profiles { get; set; } + public DbSet Profiles { get; set; } public DbSet Authors { get; set; } public DbSet Genres { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) - + { modelBuilder.Entity().HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity().HasIndex(u => u.Email).IsUnique(); diff --git a/Versum/Dtos/LoginDto.cs b/Versum/Dtos/LoginDto.cs index 022f05b..f8497fa 100644 --- a/Versum/Dtos/LoginDto.cs +++ b/Versum/Dtos/LoginDto.cs @@ -4,13 +4,13 @@ namespace Versum.Dtos { public class LoginDto { - [Required(ErrorMessage = "Введіть логін або email")] - [MaxLength(50, ErrorMessage = "Поле не може містити більше 50-ти символів")] + [Required(ErrorMessage = "Введіть логін або email")] + [MaxLength(50, ErrorMessage = "Поле не може містити більше 50-ти символів")] public string UsernameOrGmail { get; set; } = string.Empty; - [Required(ErrorMessage = "Введіть пароль")] - [MaxLength(20, ErrorMessage = "Пароль не може містити більше 20 символів")] + [Required(ErrorMessage = "Введіть пароль")] + [MaxLength(20, ErrorMessage = "Пароль не може містити більше 20 символів")] public string Password { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Versum/Dtos/PostDto.cs b/Versum/Dtos/PostDto.cs index 4748624..0205115 100644 --- a/Versum/Dtos/PostDto.cs +++ b/Versum/Dtos/PostDto.cs @@ -17,8 +17,6 @@ public class PostDto [MaxLength(500000, ErrorMessage = "Максимум 500000 символів")] [MinLength(10, ErrorMessage = "Мінімум 10 символів")] public string Content { get; set; } = ""; - - - + public List Genres { get; set; } = []; } } \ No newline at end of file diff --git a/Versum/Dtos/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs index f639b08..1c8bc12 100644 --- a/Versum/Dtos/RegisterDto.cs +++ b/Versum/Dtos/RegisterDto.cs @@ -2,14 +2,15 @@ using System.ComponentModel.DataAnnotations; -namespace Versum.Dtos{ +namespace Versum.Dtos +{ public class RegisterDto { [Required(ErrorMessage = "Введіть свій нікнейм")] // Field can't be null [RegularExpression(@"^[a-z0-9_]+$", ErrorMessage = "Нікнейм може містити лише цифри, малі літери та підкреслення")] // Allowed symbols: only lowercase (a-z), numbers (0-9), underscores (_) - [MaxLength( 50, ErrorMessage = "Поле нікнейму неможе містити більше 50-ти символів")] + [MaxLength(50, ErrorMessage = "Поле нікнейму неможе містити більше 50-ти символів")] public string Username { get; set; } = ""; [Required(ErrorMessage = "Введіть свій пароль")] @@ -17,9 +18,9 @@ public class RegisterDto ErrorMessage = "Пароль має містити від 8 до 20 символів")] [RegularExpression(@"^\S+$", ErrorMessage = "Пароль не може містити пробіли")] - + public string Password { get; set; } = ""; - + [Required(ErrorMessage = "Введіть email")] [RegularExpression(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", diff --git a/Versum/Dtos/UserDraftsGetDto.cs b/Versum/Dtos/UserDraftsGetDto.cs index 2c3e7e8..bf45d20 100644 --- a/Versum/Dtos/UserDraftsGetDto.cs +++ b/Versum/Dtos/UserDraftsGetDto.cs @@ -13,7 +13,7 @@ public class UserPostsGetDto public int PostId { get; set; } public string Username { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; - public ICollection Genres { get; set; } = new List(); + public ICollection Genres { get; set; } = new List(); } } \ No newline at end of file diff --git a/Versum/Dtos/UserProfileDto.cs b/Versum/Dtos/UserProfileDto.cs index 97b355e..5f35310 100644 --- a/Versum/Dtos/UserProfileDto.cs +++ b/Versum/Dtos/UserProfileDto.cs @@ -12,11 +12,11 @@ public class UserProfileDto [Required(ErrorMessage = "Введіть своє ім'я")] [MaxLength(30, ErrorMessage = "Поле імені не може містити більше 30-ти символів")] - public string Name { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; [Required(ErrorMessage = "Введіть свою біографію")] [MaxLength(200, ErrorMessage = "Поле біографії не може містити більше 200-ти символів")] - public string Bio { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; } diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index be51649..ac6cfbf 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -56,7 +56,7 @@ public static IQueryable ProjectToPostDto(this IQueryable CreatedAt = p.CreatedAt, Username = p.Author.User.Username, Name = p.Author.User.Profile.Name ?? "none", - Genres = p.Genres + Genres = p.Genres.Select(g => g.Name).ToList() }); } } \ No newline at end of file diff --git a/Versum/Models/UserProfile.cs b/Versum/Models/UserProfile.cs index 77e6bba..f52e4b3 100644 --- a/Versum/Models/UserProfile.cs +++ b/Versum/Models/UserProfile.cs @@ -13,6 +13,6 @@ public class UserProfile public int UserId { get; set; } public User User { get; set; } = null!; - + } } diff --git a/Versum/Program.cs b/Versum/Program.cs index 6c54c5d..84a9445 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -24,8 +24,10 @@ var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? new[] { "https://versum.social" }; -builder.Services.AddCors(options => { - options.AddPolicy("NuxtPolicy", policy => { +builder.Services.AddCors(options => +{ + options.AddPolicy("NuxtPolicy", policy => + { policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod() diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index f979b90..eb9d6a6 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -9,12 +9,13 @@ namespace Versum.Services { - public class AuthService : IAuthService { + public class AuthService : IAuthService + { private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; private readonly IConfiguration _configuration; - public AuthService(ApplicationDbContext db,IEmailService emailService,IConfiguration configuration) + public AuthService(ApplicationDbContext db, IEmailService emailService, IConfiguration configuration) { _db = db; _emailService = emailService; @@ -45,10 +46,10 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(registerToken)); RegisterTokenHash = Convert.ToBase64String(bytes); } - + var confLimit = DateTime.UtcNow.AddHours(24); // email confirmation could be valid only during 24 h - - + + var user = new User { @@ -56,7 +57,7 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura PasswordHash = passwordHash, - Email = dto.Email, + Email = dto.Email, EmailConfirmationTokenHash = RegisterTokenHash, EmailTokenExpiryDate = confLimit @@ -79,7 +80,7 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura return (true, null, null); - + } @@ -92,7 +93,7 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura incomingHash = Convert.ToBase64String(bytes); } - + var user = await _db.Users.FirstOrDefaultAsync(u => u.EmailConfirmationTokenHash == incomingHash && u.EmailTokenExpiryDate > DateTime.UtcNow @@ -111,22 +112,22 @@ public AuthService(ApplicationDbContext db,IEmailService emailService,IConfigura } public async Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto) { - + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.UsernameOrGmail || u.Username == dto.UsernameOrGmail); if (user == null) { - + return (false, "Невірний логін або пароль", string.Empty, string.Empty); } - + bool isPasswordValid = BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash); if (!isPasswordValid) { - + return (false, "Невірний логін або пароль", string.Empty, string.Empty); } @@ -209,11 +210,11 @@ public string GenerateJwtToken(User user) await _db.SaveChangesAsync(); return (true, null); } - catch(DbUpdateException ex) + catch (DbUpdateException ex) { return (false, "Сталася помилка при зверненні до бази даних."); } - catch(Exception ex) + catch (Exception ex) { return (false, "Сталася непередбачувана помилка на сервері."); } @@ -233,7 +234,7 @@ public string GenerateJwtToken(User user) { return (false, "Термін дії токена вичерпано. Запитуйте відновлення знову."); } - return (true, null); + return (true, null); } diff --git a/Versum/Services/BCAuthorService.cs b/Versum/Services/BCAuthorService.cs index 4e44ba6..1915300 100644 --- a/Versum/Services/BCAuthorService.cs +++ b/Versum/Services/BCAuthorService.cs @@ -5,48 +5,48 @@ namespace Versum.Services { - - - public class BCAuthorService : IBCAuthorService + + + public class BCAuthorService : IBCAuthorService + { + private readonly ApplicationDbContext _db; + + public BCAuthorService(ApplicationDbContext db) { - private readonly ApplicationDbContext _db; + _db = db; + } - public BCAuthorService(ApplicationDbContext db) + + + public async Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto) + { + try { - _db = db; - } + var user = await _db.Users + .Include(u => u.AuthorProfile) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return (false, "NotFound"); + if (user.AuthorProfile != null) return (false, "Ви вже є автором."); + user.AuthorProfile = new Author + { + AuthorId = user.Id, + AuthorBio = dto.AuthorBio.Trim() + }; - public async Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto) + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) { - try - { - var user = await _db.Users - .Include(u => u.AuthorProfile) - .FirstOrDefaultAsync(u => u.Id == userId); - - if (user == null) return (false, "NotFound"); - if (user.AuthorProfile != null) return (false, "Ви вже є автором."); - - user.AuthorProfile = new Author - { - AuthorId = user.Id, - AuthorBio = dto.AuthorBio.Trim() - }; - - await _db.SaveChangesAsync(); - return (true, null); - } - catch (Exception ex) - { - Console.WriteLine($"BecomeAuthorAsync error: {ex.Message}"); - return (false, "ServerError"); - } + Console.WriteLine($"BecomeAuthorAsync error: {ex.Message}"); + return (false, "ServerError"); } + } - public async Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username) - { + public async Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username) + { try { var author = await _db.Authors @@ -65,27 +65,27 @@ public BCAuthorService(ApplicationDbContext db) } - public async Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto) + public async Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto) + { + try { - try - { - var author = await _db.Authors - .FirstOrDefaultAsync(a => a.AuthorId == userId); + var author = await _db.Authors + .FirstOrDefaultAsync(a => a.AuthorId == userId); - if (author == null) return (false, "NotFound"); + if (author == null) return (false, "NotFound"); - author.AuthorBio = dto.AuthorBio.Trim(); + author.AuthorBio = dto.AuthorBio.Trim(); - await _db.SaveChangesAsync(); - return (true, null); - } - catch (Exception ex) - { - Console.WriteLine($"UpdateAuthorBioAsync error: {ex.Message}"); - return (false, "ServerError"); - } + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"UpdateAuthorBioAsync error: {ex.Message}"); + return (false, "ServerError"); } + } - } } +} diff --git a/Versum/Services/EmailService.cs b/Versum/Services/EmailService.cs index 52a1903..d670608 100644 --- a/Versum/Services/EmailService.cs +++ b/Versum/Services/EmailService.cs @@ -47,22 +47,25 @@ public async Task SendEmailAsync(string toEmail, string subject, string htmlMess using (var client = new SmtpClient()) { // Connecting to Mailtrap server - try { + try + { await client.ConnectAsync( emailSettings["SmtpServer"], int.Parse(emailSettings["Port"]), MailKit.Security.SecureSocketOptions.StartTls ); - // Client Auth + // Client Auth await client.AuthenticateAsync(emailSettings["Username"], emailSettings["Password"]); - // Sending an email + // Sending an email await client.SendAsync(emailMessage); - } catch (Exception ex) + } + catch (Exception ex) { Console.WriteLine($"Mail Error: {ex.Message}"); - } finally + } + finally { // disconnecting await client.DisconnectAsync(true); diff --git a/Versum/Services/IBCAuthorService.cs b/Versum/Services/IBCAuthorService.cs index 9c4ec02..46c9227 100644 --- a/Versum/Services/IBCAuthorService.cs +++ b/Versum/Services/IBCAuthorService.cs @@ -2,12 +2,12 @@ namespace Versum.Services { - - public interface IBCAuthorService - { - Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto); - Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username); - Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto); - } - + + public interface IBCAuthorService + { + Task<(bool Success, string? Error)> BecomeAuthorAsync(int userId, BecomeAuthorDto dto); + Task<(bool Success, string? Bio, string? Error)> GetAuthorBioAsync(string username); + Task<(bool Success, string? Error)> UpdateAuthorBioAsync(int userId, BecomeAuthorDto dto); + } + } diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index d557639..9fc342d 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -5,12 +5,12 @@ namespace Versum.Services { public interface IPostService { - Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); - + Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); Task> GetUserDraftsAsync(int claimedUserID, FilterOptions filter, bool ascending); Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto); Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId); Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); + Task> GetGenresAsync(); } } \ No newline at end of file diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 6fd6b19..95fc51e 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -84,7 +84,7 @@ public async Task> GetUserDraftsAsync(int authorId, Filter public async Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto) { var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Username == dto.Username); - if (user == null) return new (null, "UserNotFound"); + if (user == null) return new(null, "UserNotFound"); return (await _db.Posts .AsNoTracking() .Where(p => p.AuthorId == user.Id) @@ -94,11 +94,13 @@ public async Task> GetUserDraftsAsync(int authorId, Filter .ToListAsync(), null); } - public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) + public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto) { try { - var draft = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId); + var draft = await _db.Posts + .Include(p => p.Genres) + .FirstOrDefaultAsync(p => p.Id == postId); if (draft == null) return (false, "DraftNotFound"); if (draft.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); @@ -106,6 +108,7 @@ public async Task> GetUserDraftsAsync(int authorId, Filter draft.Title = dto.Title; draft.Description = dto.Description; draft.Content = dto.Content; + draft.Genres = _db.Genres.Where(g => dto.Genres.Contains(g.Name)).ToList(); await _db.SaveChangesAsync(); return (true, null); @@ -139,7 +142,10 @@ public async Task> GetUserDraftsAsync(int authorId, Filter Console.WriteLine($"DeletePostAsync error: {ex.Message}"); return (false, "ServerError"); } - + } + public async Task> GetGenresAsync() + { + return await _db.Genres.Select(g => g.Name).ToListAsync(); } } -} +} diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index a786835..59b99eb 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -22,7 +22,7 @@ public ProfileService(ApplicationDbContext db) .FirstOrDefaultAsync(u => u.Id == UserId); if (user == null) { - return (false,"Чому нас вважають за одну людину?"); + return (false, "Чому нас вважають за одну людину?"); } bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); From 7a7a4e63ab08d33527ad5961015423e57f72da6a Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Tue, 12 May 2026 23:44:17 +0300 Subject: [PATCH 37/67] Fix/get post (#30) * [FIX] Added GetPostId function * [FIX] Added authorization to GetPostById --- Versum/Controllers/PostsController.cs | 19 +++++++++++++++++++ Versum/Extensions/PostExtensions.cs | 14 ++++++++++++++ Versum/Services/IPostService.cs | 1 + Versum/Services/PostService.cs | 17 ++++++++++++++++- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 5b69811..ba2d75e 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -139,6 +139,25 @@ [FromQuery] UserPostsRequestDto dto } + [HttpGet("{postId}")] + [Authorize] + public async Task GetPostById(int postId) + { + //not required to be authorized, but allows you to see your drafts + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + int.TryParse(userIdClaim, out int userId); + + var (post, error) = await _postService.GetPostAsync(postId, userId); + + if (error != null) + { + return NotFound(new { message = error }); + } + + return Ok(post); + } + + [HttpPost("{postId}/delete-post")] [Authorize] public async Task DeletePost(int postId) diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index ac6cfbf..ed6847a 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -59,4 +59,18 @@ public static IQueryable ProjectToPostDto(this IQueryable Genres = p.Genres.Select(g => g.Name).ToList() }); } + public static UserPostsGetDto PostToUserPostsGetDto(this Post p) + { + return new UserPostsGetDto + { + PostId = p.Id, + Title = p.Title, + Description = p.Description ?? "none", + Content = p.Content ?? "none", + CreatedAt = p.CreatedAt, + Username = p.Author.User.Username, + Name = p.Author.User.Profile.Name ?? "none", + Genres = p.Genres + }; + } } \ No newline at end of file diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index 9fc342d..d34262c 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -8,6 +8,7 @@ public interface IPostService Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); Task> GetUserDraftsAsync(int claimedUserID, FilterOptions filter, bool ascending); Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto); + Task<(UserPostsGetDto?, string? Error)> GetPostAsync(int postId, int? userId); Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId); Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 95fc51e..5b1df45 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -94,7 +94,22 @@ public async Task> GetUserDraftsAsync(int authorId, Filter .ToListAsync(), null); } - public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto) + public async Task<(UserPostsGetDto?, string? Error)> GetPostAsync(int postId, int? userID) + { + var post = await _db.Posts + .AsNoTracking() + .Where(p => p.Id == postId) + .FirstOrDefaultAsync(); + + if (post == null || (post.IsDraft && post.AuthorId != userID)) + { + return (null, "Твір не знайдено або він ще не опублікований"); + } + + return (post.PostToUserPostsGetDto(), null); + } + + public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) { try { From 49826bda86d8912cc4e648ab6735ba4ab6363f53 Mon Sep 17 00:00:00 2001 From: notasasha Date: Tue, 12 May 2026 23:50:12 +0300 Subject: [PATCH 38/67] [FIX] Fixed mistype in PostToUserPostsGetDto --- Versum/Extensions/PostExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index ed6847a..6567d54 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -70,7 +70,7 @@ public static UserPostsGetDto PostToUserPostsGetDto(this Post p) CreatedAt = p.CreatedAt, Username = p.Author.User.Username, Name = p.Author.User.Profile.Name ?? "none", - Genres = p.Genres + Genres = p.Genres.Select(g => g.Name).ToList() }; } } \ No newline at end of file From ede18b1cb3c37f6569420accf01ffb4c07eec955 Mon Sep 17 00:00:00 2001 From: notasasha Date: Wed, 13 May 2026 00:32:52 +0300 Subject: [PATCH 39/67] [FIX] Anouthorized users can get posts --- Versum/Controllers/PostsController.cs | 1 - Versum/Dtos/{UserDraftsGetDto.cs => UserPostsGetDto.cs} | 0 Versum/Extensions/PostExtensions.cs | 6 +++--- Versum/Services/PostService.cs | 4 ++++ 4 files changed, 7 insertions(+), 4 deletions(-) rename Versum/Dtos/{UserDraftsGetDto.cs => UserPostsGetDto.cs} (100%) diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index ba2d75e..4c0522a 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -140,7 +140,6 @@ [FromQuery] UserPostsRequestDto dto } [HttpGet("{postId}")] - [Authorize] public async Task GetPostById(int postId) { //not required to be authorized, but allows you to see your drafts diff --git a/Versum/Dtos/UserDraftsGetDto.cs b/Versum/Dtos/UserPostsGetDto.cs similarity index 100% rename from Versum/Dtos/UserDraftsGetDto.cs rename to Versum/Dtos/UserPostsGetDto.cs diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index 6567d54..b2e652b 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -68,9 +68,9 @@ public static UserPostsGetDto PostToUserPostsGetDto(this Post p) Description = p.Description ?? "none", Content = p.Content ?? "none", CreatedAt = p.CreatedAt, - Username = p.Author.User.Username, - Name = p.Author.User.Profile.Name ?? "none", - Genres = p.Genres.Select(g => g.Name).ToList() + Username = p.Author?.User?.Username ?? "Unknown", + Name = p.Author?.User?.Profile?.Name ?? "none", + Genres = p.Genres?.Select(g => g.Name).ToList() ?? new List() }; } } \ No newline at end of file diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 5b1df45..0b4f3e1 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -98,6 +98,10 @@ public async Task> GetUserDraftsAsync(int authorId, Filter { var post = await _db.Posts .AsNoTracking() + .Include(p => p.Author) + .ThenInclude(a => a.User) + .ThenInclude(u => u.Profile) + .Include(p => p.Genres) .Where(p => p.Id == postId) .FirstOrDefaultAsync(); From 9856ce43a7e05a8323d3ef52276e2e7ee3c0e0d5 Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Wed, 13 May 2026 16:10:18 +0300 Subject: [PATCH 40/67] [FIX]Fix update and publish draft (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FIX]Fix update and publish draft * [HOTFIX] --------- Co-authored-by: Вікторія Свирид --- TestProject/ServicesTest/PostServiceTests.cs | 55 +++++++++++++++++++- Versum/Dtos/PostDto.cs | 5 +- Versum/Services/PostService.cs | 17 ++++-- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/TestProject/ServicesTest/PostServiceTests.cs b/TestProject/ServicesTest/PostServiceTests.cs index 05edff8..7ba9027 100644 --- a/TestProject/ServicesTest/PostServiceTests.cs +++ b/TestProject/ServicesTest/PostServiceTests.cs @@ -1,10 +1,61 @@ -using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Moq; +using System; using System.Collections.Generic; using System.Text; +using Versum.Context; +using Versum.Dtos; +using Versum.Services; namespace VersumTestProject.ServicesTest { - internal class PostServiceTests + internal class PostServiceTests : IDisposable { + private readonly ApplicationDbContext _context; + private readonly ApplicationDbContext _assertContext; + private readonly PostService _postService; + + // SET UP + public PostServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; + + _context = new ApplicationDbContext(options); + _postService = new PostService(_context); + _assertContext = new ApplicationDbContext(options); + } + + // TEARDOWN + public void Dispose() + { + _context.Dispose(); + _assertContext.Dispose(); + + + } + + + + /* [Fact] + public async Task PublishDraftAsyncAsync_SuccessfullPublication() + { + // Arrange + + + var dto = new PostDto { Title = TestUsername, Name = TestName, Bio = TestBio }; + + // Act + var (success, error) = await _postService.UpdateProfileAsync(99989897, dto); + + // Assert + Assert.False(success); + Assert.Equal("Чому нас вважають за одну людину?", error); + } + + + */ } } + diff --git a/Versum/Dtos/PostDto.cs b/Versum/Dtos/PostDto.cs index 0205115..8a825a6 100644 --- a/Versum/Dtos/PostDto.cs +++ b/Versum/Dtos/PostDto.cs @@ -9,13 +9,12 @@ public class PostDto [MaxLength(100, ErrorMessage = "Максимум 100 символів")] public string Title { get; set; } = ""; - [Required(ErrorMessage = "Введіть опис твору")] + [MaxLength(600, ErrorMessage = "Максимум 600 символів")] public string Description { get; set; } = ""; - [Required(ErrorMessage = "Твір не може бути порожнім")] + [MaxLength(500000, ErrorMessage = "Максимум 500000 символів")] - [MinLength(10, ErrorMessage = "Мінімум 10 символів")] public string Content { get; set; } = ""; public List Genres { get; set; } = []; } diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 0b4f3e1..ba09915 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -30,6 +30,16 @@ public PostService(ApplicationDbContext db) if (post.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); + if (!post.IsDraft) return (false, "AlreadyPublished"); + + + if (string.IsNullOrWhiteSpace(post.Title)) return (false, "TitleRequired"); + + if (string.IsNullOrWhiteSpace(post.Description)) return (false, "DescriptionRequired"); + + if (string.IsNullOrWhiteSpace(post.Content)) return (false, "ContentRequired"); + + post.IsDraft = false; await _db.SaveChangesAsync(); @@ -38,7 +48,7 @@ public PostService(ApplicationDbContext db) } catch (Exception ex) { - Console.WriteLine($"PublishPostAsync error: {ex.Message}"); + Console.WriteLine($"PublishDraftAsync error: {ex.Message}"); return (false, "ServerError"); } } @@ -65,7 +75,7 @@ public PostService(ApplicationDbContext db) } catch (Exception ex) { - Console.WriteLine($"SaveDraftAsync error: {ex.Message}"); + Console.WriteLine($"CreateDraftAsync error: {ex.Message}"); return (false, "ServerError", null); } } @@ -122,6 +132,7 @@ public async Task> GetUserDraftsAsync(int authorId, Filter .FirstOrDefaultAsync(p => p.Id == postId); if (draft == null) return (false, "DraftNotFound"); + if (draft.IsDraft == false) return (false, "You can't edit published writings"); if (draft.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); draft.Title = dto.Title; @@ -134,7 +145,7 @@ public async Task> GetUserDraftsAsync(int authorId, Filter } catch (Exception ex) { - Console.WriteLine($"UpdateAuthorBioAsync error: {ex.Message}"); + Console.WriteLine($"UpdateDraftAsync error: {ex.Message}"); return (false, "ServerError"); } From 44011349bfa390be0eb8eaa5aa89f07b385b71b3 Mon Sep 17 00:00:00 2001 From: Denis Date: Wed, 13 May 2026 23:11:40 +0300 Subject: [PATCH 41/67] Feature/follow (#25) * feat: implement follow system and user following counter * [Fix] follow logic and database schema according to code review --- Versum/Controllers/ProfileController.cs | 29 ++ Versum/DbContext/ApplicationDbContext.cs | 17 + ...0260507081138_AddFollowsSystem.Designer.cs | 314 ++++++++++++++++++ .../20260507081138_AddFollowsSystem.cs | 80 +++++ ...507092646_UpdateFollowingCount.Designer.cs | 314 ++++++++++++++++++ .../20260507092646_UpdateFollowingCount.cs | 28 ++ ...3947_RecreateFollowsTableClean.Designer.cs | 303 +++++++++++++++++ ...0260507093947_RecreateFollowsTableClean.cs | 105 ++++++ ..._RestoreStructureAndFixFollows.Designer.cs | 314 ++++++++++++++++++ ...511091049_RestoreStructureAndFixFollows.cs | 57 ++++ .../ApplicationDbContextModelSnapshot.cs | 56 ++++ Versum/Models/User.cs | 6 + Versum/Models/follower.cs | 16 + Versum/Services/IProfileService.cs | 4 + Versum/Services/ProfileService.cs | 48 +++ 15 files changed, 1691 insertions(+) create mode 100644 Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs create mode 100644 Versum/Migrations/20260507081138_AddFollowsSystem.cs create mode 100644 Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs create mode 100644 Versum/Migrations/20260507092646_UpdateFollowingCount.cs create mode 100644 Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs create mode 100644 Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs create mode 100644 Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs create mode 100644 Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs create mode 100644 Versum/Models/follower.cs diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index f0329ac..50578cf 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; using Versum.Services; namespace Versum.Controllers @@ -76,5 +77,33 @@ public async Task DeleteAccount([FromBody] DeleteAccountDto dto) return Ok(new { message = "Акаунт успішно видалено та анонімізовано" }); } + [Authorize] + [HttpPost("follow/{username}")] + public async Task ToggleFollow(string username) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out int currentUserId)) + { + return Unauthorized(); + } + + var targetUserId = await _profileService.GetUserIdByUsernameAsync(username); + + if (targetUserId == null) + { + return NotFound(new { message = "Користувача не знайдено" }); + } + + var (success, error) = await _profileService.ToggleFollowAsync(currentUserId, targetUserId.Value); + + if (!success) + { + return BadRequest(new { message = error }); + } + + return Ok(new { message = "Статус підписки змінено" }); + } + } } diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index fa0a8f1..5fa6908 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -16,6 +16,7 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Authors { get; set; } public DbSet Genres { get; set; } + public DbSet Follows { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -41,6 +42,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasMany(p => p.Posts) .WithMany(g => g.Genres); + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + + entity.HasKey(f => f.Id); + + + entity.HasOne(f => f.Follower) + .WithMany() + .HasForeignKey(f => f.FollowerId); + + entity.HasOne(f => f.Following) + .WithMany() + .HasForeignKey(f => f.FollowingId); + }); } } } diff --git a/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs b/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs new file mode 100644 index 0000000..6a1f5fe --- /dev/null +++ b/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260507081138_AddFollowsSystem")] + partial class AddFollowsSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowerCount") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260507081138_AddFollowsSystem.cs b/Versum/Migrations/20260507081138_AddFollowsSystem.cs new file mode 100644 index 0000000..439d0fb --- /dev/null +++ b/Versum/Migrations/20260507081138_AddFollowsSystem.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddFollowsSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FollowerCount", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Follows", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FollowerId = table.Column(type: "integer", nullable: false), + FollowingId = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Follows", x => x.Id); + table.ForeignKey( + name: "FK_Follows_Users_FollowerId", + column: x => x.FollowerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Follows_Users_FollowingId", + column: x => x.FollowingId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Follows_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Follows_FollowerId", + table: "Follows", + column: "FollowerId"); + + migrationBuilder.CreateIndex( + name: "IX_Follows_FollowingId", + table: "Follows", + column: "FollowingId"); + + migrationBuilder.CreateIndex( + name: "IX_Follows_UserId", + table: "Follows", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Follows"); + + migrationBuilder.DropColumn( + name: "FollowerCount", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs b/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs new file mode 100644 index 0000000..b5a46c3 --- /dev/null +++ b/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260507092646_UpdateFollowingCount")] + partial class UpdateFollowingCount + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowingCount") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260507092646_UpdateFollowingCount.cs b/Versum/Migrations/20260507092646_UpdateFollowingCount.cs new file mode 100644 index 0000000..179da95 --- /dev/null +++ b/Versum/Migrations/20260507092646_UpdateFollowingCount.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class UpdateFollowingCount : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "FollowerCount", + table: "Users", + newName: "FollowingCount"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "FollowingCount", + table: "Users", + newName: "FollowerCount"); + } + } +} diff --git a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs new file mode 100644 index 0000000..15921e5 --- /dev/null +++ b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs @@ -0,0 +1,303 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260507093947_RecreateFollowsTableClean")] + partial class RecreateFollowsTableClean + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowingCount") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs new file mode 100644 index 0000000..b178e58 --- /dev/null +++ b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class RecreateFollowsTableClean : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Follows_Users_FollowerId", + table: "Follows"); + + migrationBuilder.DropForeignKey( + name: "FK_Follows_Users_FollowingId", + table: "Follows"); + + migrationBuilder.DropIndex( + name: "IX_Users_Email", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_Username", + table: "Users"); + + migrationBuilder.AddForeignKey( + name: "FK_Follows_Users_FollowerId", + table: "Follows", + column: "FollowerId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Follows_Users_FollowingId", + table: "Follows", + column: "FollowingId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Follows_Users_FollowerId", + table: "Follows"); + + migrationBuilder.DropForeignKey( + name: "FK_Follows_Users_FollowingId", + table: "Follows"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users", + columns: new[] { "Email", "PasswordResetToken" }); + + migrationBuilder.CreateIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users", + column: "EmailConfirmationTokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Follows_Users_FollowerId", + table: "Follows", + column: "FollowerId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Follows_Users_FollowingId", + table: "Follows", + column: "FollowingId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs new file mode 100644 index 0000000..b1ff889 --- /dev/null +++ b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260511091049_RestoreStructureAndFixFollows")] + partial class RestoreStructureAndFixFollows + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowingCount") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.User", "User") + .WithMany("Posts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs new file mode 100644 index 0000000..0bba130 --- /dev/null +++ b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class RestoreStructureAndFixFollows : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users", + columns: new[] { "Email", "PasswordResetToken" }); + + migrationBuilder.CreateIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users", + column: "EmailConfirmationTokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_Email", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_Email_PasswordResetToken", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_EmailConfirmationTokenHash", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Users_Username", + table: "Users"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 331dae3..00431d0 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -69,6 +69,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Authors"); }); + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + modelBuilder.Entity("Versum.Post", b => { b.Property("Id") @@ -133,6 +161,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailTokenExpiryDate") .HasColumnType("timestamp with time zone"); + b.Property("FollowingCount") + .HasColumnType("integer"); + b.Property("IsDeleted") .HasColumnType("boolean"); @@ -224,6 +255,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + modelBuilder.Entity("Versum.Post", b => { b.HasOne("Versum.Models.Author", "Author") @@ -255,6 +309,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("AuthorProfile"); + b.Navigation("Posts"); + b.Navigation("Profile") .IsRequired(); }); diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index 28f4f91..035e301 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -28,5 +28,11 @@ public class User public virtual Author? AuthorProfile { get; set; } + public ICollection Posts { get; set; } = new List(); + + public int FollowingCount { get; set; } = 0; + + public ICollection Followers { get; set; } = new List(); + } } diff --git a/Versum/Models/follower.cs b/Versum/Models/follower.cs new file mode 100644 index 0000000..4283d7d --- /dev/null +++ b/Versum/Models/follower.cs @@ -0,0 +1,16 @@ +using System; +using Versum; + +namespace Versum.Models +{ + public class Follow + { + public int Id { get; set; } + + public int FollowerId { get; set; } + public User Follower { get; set; } = null!; + + public int FollowingId { get; set; } + public User Following { get; set; } = null!; + } +} \ No newline at end of file diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index bfaf19b..84eb379 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -6,4 +6,8 @@ public interface IProfileService Task GetProfileByUsernameAsync(string username, int? claimedUserID); Task<(bool success, string? error)> DeleteAndAnonymizeAccount(int userId, DeleteAccountDto deleteDto); + + Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId); + + Task GetUserIdByUsernameAsync(string username); } \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 59b99eb..ff9ac0f 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using Versum.Dtos; +using Versum.Models; using Versum.Context; namespace Versum.Services @@ -124,6 +125,53 @@ public ProfileService(ApplicationDbContext db) Console.WriteLine($"СЕРВЕРНА_ПОМИЛКА: {ex.Message}"); return (false, "Сталася непередбачувана помилка на сервері."); } + + + + } + + public async Task GetUserIdByUsernameAsync(string username) + { + return await _db.Users + .Where(u => u.Username.ToLower() == username.ToLower()) + .Select(u => (int?)u.Id) + .FirstOrDefaultAsync(); } + + public async Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId) + { + if (followerId == followingId) + { + return (false, "Ви не можете підписатися на самого себе."); + } + + var currentUser = await _db.Users.FindAsync(followerId); + var targetUser = await _db.Users.FindAsync(followingId); + + if (currentUser == null || targetUser == null) + { + return (false, "Користувача не знайдено."); + } + + var existingFollow = await _db.Follows + .FirstOrDefaultAsync(f => f.FollowerId == followerId && f.FollowingId == followingId); + + if (existingFollow != null) + { + _db.Follows.Remove(existingFollow); + if (currentUser.FollowingCount > 0) currentUser.FollowingCount--; + } + else + { + _db.Follows.Add(new Follow { FollowerId = followerId, FollowingId = followingId }); + currentUser.FollowingCount++; + } + + await _db.SaveChangesAsync(); + return (true, null); + } + } + } + From 45d9f956eff4dd0e0348a68ee7fd9fbe11abf1a1 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Wed, 13 May 2026 23:19:14 +0300 Subject: [PATCH 42/67] [FIX] Added context to migrations --- Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs | 1 + .../Migrations/20260507092646_UpdateFollowingCount.Designer.cs | 1 + .../20260507093947_RecreateFollowsTableClean.Designer.cs | 2 +- .../20260511091049_RestoreStructureAndFixFollows.Designer.cs | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs b/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs index 6a1f5fe..53ba769 100644 --- a/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs +++ b/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Versum; +using Versum.Context; #nullable disable diff --git a/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs b/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs index b5a46c3..8741124 100644 --- a/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs +++ b/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Versum; +using Versum.Context; #nullable disable diff --git a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs index 15921e5..65d4fb3 100644 --- a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs +++ b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Versum; - +using Versum.Context; #nullable disable namespace Versum.Migrations diff --git a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs index b1ff889..4f09982 100644 --- a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs +++ b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Versum; +using Versum.Context; #nullable disable From 8196ab2ed67979b1effef724b2a3d8b44f847e46 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 14 May 2026 00:31:27 +0300 Subject: [PATCH 43/67] [FIX] Merged Migrations --- Versum/DbContext/ApplicationDbContext.cs | 1 - ...0260507081138_AddFollowsSystem.Designer.cs | 315 ------------------ ...507092646_UpdateFollowingCount.Designer.cs | 315 ------------------ .../20260507092646_UpdateFollowingCount.cs | 28 -- ...3947_RecreateFollowsTableClean.Designer.cs | 303 ----------------- ...0260507093947_RecreateFollowsTableClean.cs | 105 ------ ...511091049_RestoreStructureAndFixFollows.cs | 57 ---- ... 20260513211501_Added-follows.Designer.cs} | 33 +- ...tem.cs => 20260513211501_Added-follows.cs} | 40 ++- .../ApplicationDbContextModelSnapshot.cs | 11 + 10 files changed, 72 insertions(+), 1136 deletions(-) delete mode 100644 Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs delete mode 100644 Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs delete mode 100644 Versum/Migrations/20260507092646_UpdateFollowingCount.cs delete mode 100644 Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs delete mode 100644 Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs delete mode 100644 Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs rename Versum/Migrations/{20260511091049_RestoreStructureAndFixFollows.Designer.cs => 20260513211501_Added-follows.Designer.cs} (91%) rename Versum/Migrations/{20260507081138_AddFollowsSystem.cs => 20260513211501_Added-follows.cs} (69%) diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 5fa6908..fffdc3c 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -42,7 +42,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasMany(p => p.Posts) .WithMany(g => g.Genres); - base.OnModelCreating(modelBuilder); modelBuilder.Entity(entity => { diff --git a/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs b/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs deleted file mode 100644 index 53ba769..0000000 --- a/Versum/Migrations/20260507081138_AddFollowsSystem.Designer.cs +++ /dev/null @@ -1,315 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; -using Versum.Context; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260507081138_AddFollowsSystem")] - partial class AddFollowsSystem - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Genre", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Genres"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.Property("GenresId") - .HasColumnType("integer"); - - b.Property("PostsId") - .HasColumnType("integer"); - - b.HasKey("GenresId", "PostsId"); - - b.HasIndex("PostsId"); - - b.ToTable("GenrePost"); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.Property("AuthorId") - .HasColumnType("integer"); - - b.Property("AuthorBio") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.HasKey("AuthorId"); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("FollowerId") - .HasColumnType("integer"); - - b.Property("FollowingId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("FollowerId"); - - b.HasIndex("FollowingId"); - - b.HasIndex("UserId"); - - b.ToTable("Follows"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Content") - .IsRequired() - .HasMaxLength(500000) - .HasColumnType("character varying(500000)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(600) - .HasColumnType("character varying(600)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("FollowerCount") - .HasColumnType("integer"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.HasIndex("Email", "PasswordResetToken"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Bio") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Name") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("Profiles"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.HasOne("Genre", null) - .WithMany() - .HasForeignKey("GenresId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.Post", null) - .WithMany() - .HasForeignKey("PostsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.Author", "AuthorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.HasOne("Versum.User", "Follower") - .WithMany() - .HasForeignKey("FollowerId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Versum.User", "Following") - .WithMany() - .HasForeignKey("FollowingId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Versum.User", null) - .WithMany("Followers") - .HasForeignKey("UserId"); - - b.Navigation("Follower"); - - b.Navigation("Following"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.HasOne("Versum.User", "User") - .WithMany("Posts") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.HasOne("Versum.User", "User") - .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - - b.Navigation("Followers"); - - b.Navigation("Posts"); - - b.Navigation("Profile") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs b/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs deleted file mode 100644 index 8741124..0000000 --- a/Versum/Migrations/20260507092646_UpdateFollowingCount.Designer.cs +++ /dev/null @@ -1,315 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; -using Versum.Context; - -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260507092646_UpdateFollowingCount")] - partial class UpdateFollowingCount - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Genre", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Genres"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.Property("GenresId") - .HasColumnType("integer"); - - b.Property("PostsId") - .HasColumnType("integer"); - - b.HasKey("GenresId", "PostsId"); - - b.HasIndex("PostsId"); - - b.ToTable("GenrePost"); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.Property("AuthorId") - .HasColumnType("integer"); - - b.Property("AuthorBio") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.HasKey("AuthorId"); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("FollowerId") - .HasColumnType("integer"); - - b.Property("FollowingId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("FollowerId"); - - b.HasIndex("FollowingId"); - - b.HasIndex("UserId"); - - b.ToTable("Follows"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Content") - .IsRequired() - .HasMaxLength(500000) - .HasColumnType("character varying(500000)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(600) - .HasColumnType("character varying(600)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("FollowingCount") - .HasColumnType("integer"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.HasIndex("Email") - .IsUnique(); - - b.HasIndex("EmailConfirmationTokenHash") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.HasIndex("Email", "PasswordResetToken"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Bio") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Name") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("Profiles"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.HasOne("Genre", null) - .WithMany() - .HasForeignKey("GenresId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.Post", null) - .WithMany() - .HasForeignKey("PostsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.Author", "AuthorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.HasOne("Versum.User", "Follower") - .WithMany() - .HasForeignKey("FollowerId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Versum.User", "Following") - .WithMany() - .HasForeignKey("FollowingId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Versum.User", null) - .WithMany("Followers") - .HasForeignKey("UserId"); - - b.Navigation("Follower"); - - b.Navigation("Following"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.HasOne("Versum.User", "User") - .WithMany("Posts") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.HasOne("Versum.User", "User") - .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - - b.Navigation("Followers"); - - b.Navigation("Posts"); - - b.Navigation("Profile") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260507092646_UpdateFollowingCount.cs b/Versum/Migrations/20260507092646_UpdateFollowingCount.cs deleted file mode 100644 index 179da95..0000000 --- a/Versum/Migrations/20260507092646_UpdateFollowingCount.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class UpdateFollowingCount : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "FollowerCount", - table: "Users", - newName: "FollowingCount"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameColumn( - name: "FollowingCount", - table: "Users", - newName: "FollowerCount"); - } - } -} diff --git a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs deleted file mode 100644 index 65d4fb3..0000000 --- a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.Designer.cs +++ /dev/null @@ -1,303 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; -using Versum.Context; -#nullable disable - -namespace Versum.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260507093947_RecreateFollowsTableClean")] - partial class RecreateFollowsTableClean - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Genre", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Genres"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.Property("GenresId") - .HasColumnType("integer"); - - b.Property("PostsId") - .HasColumnType("integer"); - - b.HasKey("GenresId", "PostsId"); - - b.HasIndex("PostsId"); - - b.ToTable("GenrePost"); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.Property("AuthorId") - .HasColumnType("integer"); - - b.Property("AuthorBio") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.HasKey("AuthorId"); - - b.ToTable("Authors"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("FollowerId") - .HasColumnType("integer"); - - b.Property("FollowingId") - .HasColumnType("integer"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("FollowerId"); - - b.HasIndex("FollowingId"); - - b.HasIndex("UserId"); - - b.ToTable("Follows"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Content") - .IsRequired() - .HasMaxLength(500000) - .HasColumnType("character varying(500000)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(600) - .HasColumnType("character varying(600)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Posts"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("EmailConfirmationTokenHash") - .HasColumnType("text"); - - b.Property("EmailTokenExpiryDate") - .HasColumnType("timestamp with time zone"); - - b.Property("FollowingCount") - .HasColumnType("integer"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("IsEmailConfirmed") - .HasColumnType("boolean"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(60) - .HasColumnType("character varying(60)"); - - b.Property("PasswordResetToken") - .HasColumnType("text"); - - b.Property("ResetTokenExpires") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Bio") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Name") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("UserId") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("Profiles"); - }); - - modelBuilder.Entity("GenrePost", b => - { - b.HasOne("Genre", null) - .WithMany() - .HasForeignKey("GenresId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.Post", null) - .WithMany() - .HasForeignKey("PostsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Versum.Models.Author", b => - { - b.HasOne("Versum.User", "User") - .WithOne("AuthorProfile") - .HasForeignKey("Versum.Models.Author", "AuthorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.Models.Follow", b => - { - b.HasOne("Versum.User", "Follower") - .WithMany() - .HasForeignKey("FollowerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.User", "Following") - .WithMany() - .HasForeignKey("FollowingId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Versum.User", null) - .WithMany("Followers") - .HasForeignKey("UserId"); - - b.Navigation("Follower"); - - b.Navigation("Following"); - }); - - modelBuilder.Entity("Versum.Post", b => - { - b.HasOne("Versum.User", "User") - .WithMany("Posts") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.UserProfile", b => - { - b.HasOne("Versum.User", "User") - .WithOne("Profile") - .HasForeignKey("Versum.UserProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Versum.User", b => - { - b.Navigation("AuthorProfile"); - - b.Navigation("Followers"); - - b.Navigation("Posts"); - - b.Navigation("Profile") - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs b/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs deleted file mode 100644 index b178e58..0000000 --- a/Versum/Migrations/20260507093947_RecreateFollowsTableClean.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class RecreateFollowsTableClean : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Follows_Users_FollowerId", - table: "Follows"); - - migrationBuilder.DropForeignKey( - name: "FK_Follows_Users_FollowingId", - table: "Follows"); - - migrationBuilder.DropIndex( - name: "IX_Users_Email", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_Username", - table: "Users"); - - migrationBuilder.AddForeignKey( - name: "FK_Follows_Users_FollowerId", - table: "Follows", - column: "FollowerId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - - migrationBuilder.AddForeignKey( - name: "FK_Follows_Users_FollowingId", - table: "Follows", - column: "FollowingId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Follows_Users_FollowerId", - table: "Follows"); - - migrationBuilder.DropForeignKey( - name: "FK_Follows_Users_FollowingId", - table: "Follows"); - - migrationBuilder.CreateIndex( - name: "IX_Users_Email", - table: "Users", - column: "Email", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users", - columns: new[] { "Email", "PasswordResetToken" }); - - migrationBuilder.CreateIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users", - column: "EmailConfirmationTokenHash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Username", - table: "Users", - column: "Username", - unique: true); - - migrationBuilder.AddForeignKey( - name: "FK_Follows_Users_FollowerId", - table: "Follows", - column: "FollowerId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_Follows_Users_FollowingId", - table: "Follows", - column: "FollowingId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - } -} diff --git a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs b/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs deleted file mode 100644 index 0bba130..0000000 --- a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Versum.Migrations -{ - /// - public partial class RestoreStructureAndFixFollows : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_Users_Email", - table: "Users", - column: "Email", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users", - columns: new[] { "Email", "PasswordResetToken" }); - - migrationBuilder.CreateIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users", - column: "EmailConfirmationTokenHash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Username", - table: "Users", - column: "Username", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_Email", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_Email_PasswordResetToken", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_EmailConfirmationTokenHash", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_Username", - table: "Users"); - } - } -} diff --git a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs b/Versum/Migrations/20260513211501_Added-follows.Designer.cs similarity index 91% rename from Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs rename to Versum/Migrations/20260513211501_Added-follows.Designer.cs index 4f09982..88ab7cb 100644 --- a/Versum/Migrations/20260511091049_RestoreStructureAndFixFollows.Designer.cs +++ b/Versum/Migrations/20260513211501_Added-follows.Designer.cs @@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Versum; using Versum.Context; #nullable disable @@ -13,8 +12,8 @@ namespace Versum.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260511091049_RestoreStructureAndFixFollows")] - partial class RestoreStructureAndFixFollows + [Migration("20260513211501_Added-follows")] + partial class Addedfollows { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -109,6 +108,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AuthorId") + .HasColumnType("integer"); + b.Property("Content") .IsRequired() .HasMaxLength(500000) @@ -122,16 +124,24 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(600) .HasColumnType("character varying(600)"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + b.Property("Title") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("integer"); b.HasKey("Id"); + b.HasIndex("AuthorId"); + b.HasIndex("UserId"); b.ToTable("Posts"); @@ -278,13 +288,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Versum.Post", b => { - b.HasOne("Versum.User", "User") + b.HasOne("Versum.Models.Author", "Author") .WithMany("Posts") - .HasForeignKey("UserId") + .HasForeignKey("AuthorId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("User"); + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); }); modelBuilder.Entity("Versum.UserProfile", b => @@ -298,6 +312,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + modelBuilder.Entity("Versum.User", b => { b.Navigation("AuthorProfile"); diff --git a/Versum/Migrations/20260507081138_AddFollowsSystem.cs b/Versum/Migrations/20260513211501_Added-follows.cs similarity index 69% rename from Versum/Migrations/20260507081138_AddFollowsSystem.cs rename to Versum/Migrations/20260513211501_Added-follows.cs index 439d0fb..c1f7cc8 100644 --- a/Versum/Migrations/20260507081138_AddFollowsSystem.cs +++ b/Versum/Migrations/20260513211501_Added-follows.cs @@ -6,18 +6,24 @@ namespace Versum.Migrations { /// - public partial class AddFollowsSystem : Migration + public partial class Addedfollows : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( - name: "FollowerCount", + name: "FollowingCount", table: "Users", type: "integer", nullable: false, defaultValue: 0); + migrationBuilder.AddColumn( + name: "UserId", + table: "Posts", + type: "integer", + nullable: true); + migrationBuilder.CreateTable( name: "Follows", columns: table => new @@ -36,13 +42,13 @@ protected override void Up(MigrationBuilder migrationBuilder) column: x => x.FollowerId, principalTable: "Users", principalColumn: "Id", - onDelete: ReferentialAction.Restrict); + onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_Follows_Users_FollowingId", column: x => x.FollowingId, principalTable: "Users", principalColumn: "Id", - onDelete: ReferentialAction.Restrict); + onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_Follows_Users_UserId", column: x => x.UserId, @@ -50,6 +56,11 @@ protected override void Up(MigrationBuilder migrationBuilder) principalColumn: "Id"); }); + migrationBuilder.CreateIndex( + name: "IX_Posts_UserId", + table: "Posts", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_Follows_FollowerId", table: "Follows", @@ -64,17 +75,36 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "IX_Follows_UserId", table: "Follows", column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Posts_Users_UserId", + table: "Posts", + column: "UserId", + principalTable: "Users", + principalColumn: "Id"); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropForeignKey( + name: "FK_Posts_Users_UserId", + table: "Posts"); + migrationBuilder.DropTable( name: "Follows"); + migrationBuilder.DropIndex( + name: "IX_Posts_UserId", + table: "Posts"); + migrationBuilder.DropColumn( - name: "FollowerCount", + name: "FollowingCount", table: "Users"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Posts"); } } } diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 00431d0..c48fd65 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -132,10 +132,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("UserId") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("AuthorId"); + b.HasIndex("UserId"); + b.ToTable("Posts"); }); @@ -286,6 +291,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + b.Navigation("Author"); }); @@ -309,6 +318,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("AuthorProfile"); + b.Navigation("Followers"); + b.Navigation("Posts"); b.Navigation("Profile") From e882415b3f36b68a7b82d3fdcc19b03f205b1c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81?= Date: Thu, 14 May 2026 11:40:58 +0300 Subject: [PATCH 44/67] feat: add profile statistics and follow system --- Versum/Controllers/ProfileController.cs | 8 + Versum/DbContext/ApplicationDbContext.cs | 2 +- Versum/Dtos/UserFollowDto.cs | 8 + Versum/Dtos/UserProfileResponseDto.cs | 4 + .../20260514075714_FixAuthorPosts.Designer.cs | 331 ++++++++++++++++++ .../20260514075714_FixAuthorPosts.cs | 29 ++ .../ApplicationDbContextModelSnapshot.cs | 3 - Versum/Models/User.cs | 6 +- Versum/Services/IProfileService.cs | 1 + Versum/Services/ProfileService.cs | 42 ++- 10 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 Versum/Dtos/UserFollowDto.cs create mode 100644 Versum/Migrations/20260514075714_FixAuthorPosts.Designer.cs create mode 100644 Versum/Migrations/20260514075714_FixAuthorPosts.cs diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index 50578cf..2972bcc 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -105,5 +105,13 @@ public async Task ToggleFollow(string username) return Ok(new { message = "Статус підписки змінено" }); } + [Authorize] + [HttpGet("{username}/followings")] + public async Task GetFollowings(string username) + { + var followings = await _profileService.GetFollowingsListAsync(username.ToLower()); + return Ok(followings); + } + } } diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index fffdc3c..7f55ae2 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -36,7 +36,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasOne(p => p.Author) - .WithMany(u => u.Posts) + .WithMany(a => a.Posts) .HasForeignKey(p => p.AuthorId); modelBuilder.Entity() diff --git a/Versum/Dtos/UserFollowDto.cs b/Versum/Dtos/UserFollowDto.cs new file mode 100644 index 0000000..be328e8 --- /dev/null +++ b/Versum/Dtos/UserFollowDto.cs @@ -0,0 +1,8 @@ +namespace Versum.Dtos +{ + public class UserFollowDto + { + public string Username { get; set; } = string.Empty; + public string? DisplayName { get; set; } + } +} diff --git a/Versum/Dtos/UserProfileResponseDto.cs b/Versum/Dtos/UserProfileResponseDto.cs index 322bcd8..1fdf74a 100644 --- a/Versum/Dtos/UserProfileResponseDto.cs +++ b/Versum/Dtos/UserProfileResponseDto.cs @@ -8,5 +8,9 @@ public class UserProfileResponseDto public DateTime CreatedAt { get; set; } public bool IsAuthor { get; set; } public bool IsOwner { get; set; } + public int WorksCount { get; set; } + public int FollowingCount { get; set; } + public int FollowersCount { get; set; } + public bool IsFollowing { get; set; } } } diff --git a/Versum/Migrations/20260514075714_FixAuthorPosts.Designer.cs b/Versum/Migrations/20260514075714_FixAuthorPosts.Designer.cs new file mode 100644 index 0000000..209a476 --- /dev/null +++ b/Versum/Migrations/20260514075714_FixAuthorPosts.Designer.cs @@ -0,0 +1,331 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260514075714_FixAuthorPosts")] + partial class FixAuthorPosts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260514075714_FixAuthorPosts.cs b/Versum/Migrations/20260514075714_FixAuthorPosts.cs new file mode 100644 index 0000000..7d68a9d --- /dev/null +++ b/Versum/Migrations/20260514075714_FixAuthorPosts.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class FixAuthorPosts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FollowingCount", + table: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FollowingCount", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index c48fd65..dbc6772 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -166,9 +166,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailTokenExpiryDate") .HasColumnType("timestamp with time zone"); - b.Property("FollowingCount") - .HasColumnType("integer"); - b.Property("IsDeleted") .HasColumnType("boolean"); diff --git a/Versum/Models/User.cs b/Versum/Models/User.cs index 035e301..82142ce 100644 --- a/Versum/Models/User.cs +++ b/Versum/Models/User.cs @@ -25,13 +25,9 @@ public class User public UserProfile Profile { get; set; } public bool IsDeleted { get; set; } - + public virtual ICollection Posts { get; set; } = new List(); public virtual Author? AuthorProfile { get; set; } - public ICollection Posts { get; set; } = new List(); - - public int FollowingCount { get; set; } = 0; - public ICollection Followers { get; set; } = new List(); } diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index 84eb379..e084edb 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -9,5 +9,6 @@ public interface IProfileService Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId); + Task> GetFollowingsListAsync(string username); Task GetUserIdByUsernameAsync(string username); } \ No newline at end of file diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index ff9ac0f..bfc0b1d 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -67,6 +67,16 @@ public ProfileService(ApplicationDbContext db) { throw new ArgumentException("Username cannot be null or empty.", nameof(username)); } + + var user = await _db.Users + .Include(u => u.Profile) + .Include(u => u.AuthorProfile) + .FirstOrDefaultAsync(u => u.Username == username); + + bool isCurrentGuest = claimedUserID == null; + + if (user == null) return null; + return await _db.Users .Where(u => u.Username == username) .Select(u => new UserProfileResponseDto @@ -76,7 +86,11 @@ public ProfileService(ApplicationDbContext db) Bio = u.Profile.Bio ?? "none", CreatedAt = u.CreatedAt, IsAuthor = (u.AuthorProfile != null), - IsOwner = u.Id == claimedUserID + IsOwner = u.Id == claimedUserID, + WorksCount = u.AuthorProfile.Posts.Count(p => !p.IsDeleted && !p.IsDraft), + FollowingCount = _db.Follows.Count(f => f.FollowerId == u.Id), + FollowersCount = _db.Follows.Count(f => f.FollowingId == u.Id), + IsFollowing = !isCurrentGuest && _db.Follows.Any(f => f.FollowerId == claimedUserID && f.FollowingId == u.Id) }) .FirstOrDefaultAsync(); } @@ -140,18 +154,7 @@ public ProfileService(ApplicationDbContext db) public async Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId) { - if (followerId == followingId) - { - return (false, "Ви не можете підписатися на самого себе."); - } - - var currentUser = await _db.Users.FindAsync(followerId); - var targetUser = await _db.Users.FindAsync(followingId); - - if (currentUser == null || targetUser == null) - { - return (false, "Користувача не знайдено."); - } + if (followerId == followingId) return (false, "Ви не можете підписатися на себе."); var existingFollow = await _db.Follows .FirstOrDefaultAsync(f => f.FollowerId == followerId && f.FollowingId == followingId); @@ -159,17 +162,26 @@ public ProfileService(ApplicationDbContext db) if (existingFollow != null) { _db.Follows.Remove(existingFollow); - if (currentUser.FollowingCount > 0) currentUser.FollowingCount--; } else { _db.Follows.Add(new Follow { FollowerId = followerId, FollowingId = followingId }); - currentUser.FollowingCount++; } await _db.SaveChangesAsync(); return (true, null); } + public async Task> GetFollowingsListAsync(string username) + { + return await _db.Follows + .Where(f => f.Follower.Username == username) + .Select(f => new UserFollowDto + { + Username = f.Following.Username, + DisplayName = f.Following.Profile.Name + }) + .ToListAsync(); + } } From 55be04f66a7b67454a74263b14d5d47e025b7a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81?= Date: Thu, 14 May 2026 14:28:31 +0300 Subject: [PATCH 45/67] [Fix] Resolved conflicts --- Versum/Controllers/ProfileController.cs | 1 - Versum/Services/ProfileService.cs | 15 +++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index 2972bcc..a525778 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -105,7 +105,6 @@ public async Task ToggleFollow(string username) return Ok(new { message = "Статус підписки змінено" }); } - [Authorize] [HttpGet("{username}/followings")] public async Task GetFollowings(string username) { diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index bfc0b1d..a07f476 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -68,14 +68,7 @@ public ProfileService(ApplicationDbContext db) throw new ArgumentException("Username cannot be null or empty.", nameof(username)); } - var user = await _db.Users - .Include(u => u.Profile) - .Include(u => u.AuthorProfile) - .FirstOrDefaultAsync(u => u.Username == username); - - bool isCurrentGuest = claimedUserID == null; - - if (user == null) return null; + return await _db.Users .Where(u => u.Username == username) @@ -87,10 +80,12 @@ public ProfileService(ApplicationDbContext db) CreatedAt = u.CreatedAt, IsAuthor = (u.AuthorProfile != null), IsOwner = u.Id == claimedUserID, - WorksCount = u.AuthorProfile.Posts.Count(p => !p.IsDeleted && !p.IsDraft), + WorksCount = u.AuthorProfile != null + ? u.AuthorProfile.Posts.Count(p => !p.IsDeleted && !p.IsDraft) + : 0, FollowingCount = _db.Follows.Count(f => f.FollowerId == u.Id), FollowersCount = _db.Follows.Count(f => f.FollowingId == u.Id), - IsFollowing = !isCurrentGuest && _db.Follows.Any(f => f.FollowerId == claimedUserID && f.FollowingId == u.Id) + IsFollowing = claimedUserID != null && _db.Follows.Any(f => f.FollowerId == claimedUserID && f.FollowingId == u.Id) }) .FirstOrDefaultAsync(); } From 083a40ca83903a5886f94056be281b68ae1b9905 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Thu, 14 May 2026 15:16:32 +0300 Subject: [PATCH 46/67] [FIX] Deleted post are now ignored (#33) --- Versum/Controllers/PostsController.cs | 2 +- Versum/Extensions/PostExtensions.cs | 4 ++-- Versum/Services/PostService.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 4c0522a..2817dc3 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -139,7 +139,7 @@ [FromQuery] UserPostsRequestDto dto } - [HttpGet("{postId}")] + [HttpGet("{postId}")] public async Task GetPostById(int postId) { //not required to be authorized, but allows you to see your drafts diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index b2e652b..0e85413 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -9,12 +9,12 @@ public static class PostExtensions { public static IQueryable OnlyPublished(this IQueryable query) { - return query.Where(p => !p.IsDraft); + return query.Where(p => !p.IsDraft && !p.IsDeleted); } public static IQueryable OnlyDrafts(this IQueryable query) { - return query.Where(p => p.IsDraft); + return query.Where(p => p.IsDraft && !p.IsDeleted); } public static IQueryable ApplySorting( diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index ba09915..f1fe332 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -115,7 +115,7 @@ public async Task> GetUserDraftsAsync(int authorId, Filter .Where(p => p.Id == postId) .FirstOrDefaultAsync(); - if (post == null || (post.IsDraft && post.AuthorId != userID)) + if (post == null || post.IsDeleted || (post.IsDraft && post.AuthorId != userID)) { return (null, "Твір не знайдено або він ще не опублікований"); } From 354c899f3b6dd8035c90a95c1d8d4caf53d49dbc Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Mon, 18 May 2026 00:25:04 +0300 Subject: [PATCH 47/67] [FIX] Added html sanitizer (#35) --- TestProject/VersumTestProject.csproj | 1 + Versum/Services/PostService.cs | 7 ++++++- Versum/Versum.csproj | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/TestProject/VersumTestProject.csproj b/TestProject/VersumTestProject.csproj index 4070470..32d8ba1 100644 --- a/TestProject/VersumTestProject.csproj +++ b/TestProject/VersumTestProject.csproj @@ -9,6 +9,7 @@ + diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index f1fe332..dae33f8 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -5,6 +5,7 @@ using Versum.Core.Enums; using Versum.Extensions; using Versum.Context; +using Ganss.Xss; namespace Versum.Services { @@ -135,9 +136,13 @@ public async Task> GetUserDraftsAsync(int authorId, Filter if (draft.IsDraft == false) return (false, "You can't edit published writings"); if (draft.AuthorId != userId) return (false, "YouAreNotAnOwnerOfDraft"); + var sanitizer = new HtmlSanitizer(); + sanitizer.AllowedAttributes.Add("data-description"); + sanitizer.AllowedAttributes.Add("class"); + draft.Title = dto.Title; draft.Description = dto.Description; - draft.Content = dto.Content; + draft.Content = sanitizer.Sanitize(dto.Content); draft.Genres = _db.Genres.Where(g => dto.Genres.Contains(g.Name)).ToList(); await _db.SaveChangesAsync(); diff --git a/Versum/Versum.csproj b/Versum/Versum.csproj index 2b33b37..7f50c78 100644 --- a/Versum/Versum.csproj +++ b/Versum/Versum.csproj @@ -18,6 +18,7 @@ + From f5fde0a537ada62bfb3199cf10e426f562f4820e Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Mon, 18 May 2026 15:07:57 +0300 Subject: [PATCH 48/67] [FIX] Post getters rewrite (#36) --- Versum/Controllers/PostsController.cs | 33 ++++++------ .../{UserPostsGetDto.cs => PostGetDto.cs} | 2 +- Versum/Dtos/PostQueryDto.cs | 10 ++++ Versum/Dtos/UserPostsRequestDto.cs | 14 ----- Versum/Extensions/PostExtensions.cs | 18 ++----- Versum/Services/IPostService.cs | 8 +-- Versum/Services/PostService.cs | 53 ++++++++++--------- 7 files changed, 61 insertions(+), 77 deletions(-) rename Versum/Dtos/{UserPostsGetDto.cs => PostGetDto.cs} (94%) create mode 100644 Versum/Dtos/PostQueryDto.cs delete mode 100644 Versum/Dtos/UserPostsRequestDto.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 2817dc3..c35609a 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -20,12 +20,14 @@ public class PostsController : ControllerBase private readonly ApplicationDbContext _context; private readonly IHubContext _hubContext; private readonly IPostService _postService; + private readonly IProfileService _profileService; - public PostsController(ApplicationDbContext context, IHubContext hubContext, IPostService postService) + public PostsController(ApplicationDbContext context, IHubContext hubContext, IPostService postService, IProfileService profileService) { _context = context; _hubContext = hubContext; _postService = postService; + _profileService = profileService; } @@ -108,35 +110,30 @@ public async Task UpdateDraft(int postId, [FromBody] PostDto dto) }); } - [HttpGet("get-drafts")] + //---CRITICAL: Post content is sent every time, though it is not needed. Reminder: Content can have up to 500k letters... + [HttpGet("drafts")] [Authorize] - public async Task>> GetDrafts( - [FromQuery] FilterOptions filter, - [FromQuery] bool ascending) + public async Task>> GetDrafts([FromQuery] PostQueryDto query) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (!int.TryParse(userIdClaim, out int authorId)) { return Unauthorized(); } - var drafts = await _postService.GetUserDraftsAsync(authorId, filter, ascending); - return Ok(drafts); //Користувачі, які не мають ролі автора або не мають створених чернеток отримують порожній список + var drafts = await _postService.GetUserDraftsAsync(authorId, query); + return Ok(drafts); } - [HttpGet("get-posts")] - public async Task>> GetPosts( - [FromQuery] UserPostsRequestDto dto - ) + //---CRITICAL: Post content is sent every time, though it is not needed. Reminder: Content can have up to 500k letters... + [HttpGet("user/{username}")] + public async Task>> GetPosts([FromRoute] string username, [FromQuery] PostQueryDto query) { - var (posts, error) = await _postService.GetUserPostsAsync(dto); - if (error != null) - { - if (error == "UserNotFound") return NotFound(new { message = "Користувача не знайдено" }); - return BadRequest(new { message = error }); - } + var userId = await _profileService.GetUserIdByUsernameAsync(username); + if (userId == null) + return NotFound(new { message = "Користувача не знайдено" }); + var posts = await _postService.GetUserPostsAsync(userId.Value, query); return Ok(posts); - } [HttpGet("{postId}")] diff --git a/Versum/Dtos/UserPostsGetDto.cs b/Versum/Dtos/PostGetDto.cs similarity index 94% rename from Versum/Dtos/UserPostsGetDto.cs rename to Versum/Dtos/PostGetDto.cs index bf45d20..6c1bb16 100644 --- a/Versum/Dtos/UserPostsGetDto.cs +++ b/Versum/Dtos/PostGetDto.cs @@ -4,7 +4,7 @@ namespace Versum.Dtos { - public class UserPostsGetDto + public class PostGetDto { public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/Versum/Dtos/PostQueryDto.cs b/Versum/Dtos/PostQueryDto.cs new file mode 100644 index 0000000..0d7e3d3 --- /dev/null +++ b/Versum/Dtos/PostQueryDto.cs @@ -0,0 +1,10 @@ +using Versum.Core.Enums; + +namespace Versum.Dtos +{ + public class PostQueryDto + { + public FilterOptions Filter { get; set; } + public bool Ascending { get; set; } + } +} diff --git a/Versum/Dtos/UserPostsRequestDto.cs b/Versum/Dtos/UserPostsRequestDto.cs deleted file mode 100644 index 5b5fedb..0000000 --- a/Versum/Dtos/UserPostsRequestDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Versum.Core.Enums; - -namespace Versum.Dtos -{ - public class UserPostsRequestDto - { - [Required(ErrorMessage = "Введіть свій нікнейм")] - public string Username { get; set; } = string.Empty; - public FilterOptions Filter { get; set; } - public bool Ascending { get; set; } - - } -} \ No newline at end of file diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index 0e85413..8bc4ce4 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -45,23 +45,13 @@ private static IQueryable OrderByFieldDescending(this IQueryable que _ => query.OrderByDescending(p => p.Id) }; - public static IQueryable ProjectToPostDto(this IQueryable query) + public static IQueryable ProjectToPostDto(this IQueryable query) { - return query.Select(p => new UserPostsGetDto - { - PostId = p.Id, - Title = p.Title, - Description = p.Description ?? "none", - Content = p.Content ?? "none", - CreatedAt = p.CreatedAt, - Username = p.Author.User.Username, - Name = p.Author.User.Profile.Name ?? "none", - Genres = p.Genres.Select(g => g.Name).ToList() - }); + return query.Select(p => PostToPostGetDto(p)); } - public static UserPostsGetDto PostToUserPostsGetDto(this Post p) + public static PostGetDto PostToPostGetDto(this Post p) { - return new UserPostsGetDto + return new PostGetDto { PostId = p.Id, Title = p.Title, diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index d34262c..38dccb3 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -5,10 +5,10 @@ namespace Versum.Services { public interface IPostService { - Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); - Task> GetUserDraftsAsync(int claimedUserID, FilterOptions filter, bool ascending); - Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto); - Task<(UserPostsGetDto?, string? Error)> GetPostAsync(int postId, int? userId); + Task<(bool Success, string? Error)> DeletePostAsync(int userId, int postId); + Task?> GetUserPostsAsync(int authorId, PostQueryDto query); + Task?> GetUserDraftsAsync(int authorId, PostQueryDto query); + Task<(PostGetDto?, string? Error)> GetPostAsync(int postId, int? userId); Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId); Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index dae33f8..059454e 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -81,47 +81,48 @@ public PostService(ApplicationDbContext db) } } - public async Task> GetUserDraftsAsync(int authorId, FilterOptions filter, bool ascending) + + //consider combining onto one GetUserPosts + //---CRITICAL: Post content is sent every time, though it is not needed. Reminder: Content can have up to 500k letters... + public async Task?> GetUserDraftsAsync(int authorId, PostQueryDto query) { return await _db.Posts - .AsNoTracking() - .Where(p => p.AuthorId == authorId) - .OnlyDrafts() - .ApplySorting(filter, ascending) - .ProjectToPostDto() - .ToListAsync(); + .AsNoTracking() + .Where(p => p.AuthorId == authorId) + .OnlyDrafts() + .ApplySorting(query.Filter, query.Ascending) + .ProjectToPostDto() + .ToListAsync(); } - public async Task<(List?, string? Error)> GetUserPostsAsync(UserPostsRequestDto dto) + public async Task?> GetUserPostsAsync(int authorId, PostQueryDto query) { - var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Username == dto.Username); - if (user == null) return new(null, "UserNotFound"); - return (await _db.Posts - .AsNoTracking() - .Where(p => p.AuthorId == user.Id) - .OnlyPublished() - .ApplySorting(dto.Filter, dto.Ascending) - .ProjectToPostDto() - .ToListAsync(), null); + return await _db.Posts + .AsNoTracking() + .Where(p => p.AuthorId == authorId) + .OnlyPublished() + .ApplySorting(query.Filter, query.Ascending) + .ProjectToPostDto() + .ToListAsync(); } - public async Task<(UserPostsGetDto?, string? Error)> GetPostAsync(int postId, int? userID) + public async Task<(PostGetDto?, string? Error)> GetPostAsync(int postId, int? userID) { var post = await _db.Posts - .AsNoTracking() - .Include(p => p.Author) - .ThenInclude(a => a.User) - .ThenInclude(u => u.Profile) - .Include(p => p.Genres) - .Where(p => p.Id == postId) - .FirstOrDefaultAsync(); + .AsNoTracking() + .Include(p => p.Author) + .ThenInclude(a => a.User) + .ThenInclude(u => u.Profile) + .Include(p => p.Genres) + .Where(p => p.Id == postId) + .FirstOrDefaultAsync(); if (post == null || post.IsDeleted || (post.IsDraft && post.AuthorId != userID)) { return (null, "Твір не знайдено або він ще не опублікований"); } - return (post.PostToUserPostsGetDto(), null); + return (post.PostToPostGetDto(), null); } public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) From da7e13c6997b0d7caa828458f4899d8b12c12648 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Tue, 19 May 2026 13:33:39 +0300 Subject: [PATCH 49/67] [FIX] Genres now shows --- Versum/Services/PostService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 059454e..c44bd9d 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -90,6 +90,10 @@ public PostService(ApplicationDbContext db) .AsNoTracking() .Where(p => p.AuthorId == authorId) .OnlyDrafts() + .Include(p => p.Author) + .ThenInclude(a => a.User) + .ThenInclude(u => u.Profile) + .Include(p => p.Genres) .ApplySorting(query.Filter, query.Ascending) .ProjectToPostDto() .ToListAsync(); @@ -101,6 +105,10 @@ public PostService(ApplicationDbContext db) .AsNoTracking() .Where(p => p.AuthorId == authorId) .OnlyPublished() + .Include(p => p.Author) + .ThenInclude(a => a.User) + .ThenInclude(u => u.Profile) + .Include(p => p.Genres) .ApplySorting(query.Filter, query.Ascending) .ProjectToPostDto() .ToListAsync(); From 5dde0ba6d9b57c19dff35cdaf17e195801b5deeb Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Thu, 21 May 2026 15:42:16 +0300 Subject: [PATCH 50/67] [FEATURE] Implement dictionary logic (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Вікторія Свирид --- Versum/Controllers/DictController.cs | 109 +++++ Versum/DbContext/ApplicationDbContext.cs | 6 +- Versum/Dtos/DeletePhraseDto.cs | 16 + Versum/Dtos/DictDto.cs | 17 + Versum/Dtos/DictResponceDto.cs | 12 + ...60520063413_AddDictionaryTable.Designer.cs | 395 ++++++++++++++++++ .../20260520063413_AddDictionaryTable.cs | 65 +++ ...520121048_PostRefrenceIsNotNes.Designer.cs | 393 +++++++++++++++++ .../20260520121048_PostRefrenceIsNotNes.cs | 59 +++ .../ApplicationDbContextModelSnapshot.cs | 62 +++ Versum/Models/Dictionary.cs | 20 + Versum/Program.cs | 1 + Versum/Services/DictService.cs | 116 +++++ Versum/Services/IDictService.cs | 14 + ...0\257\321\200\320\273\320\270\320\272.lnk" | Bin 0 -> 889 bytes 15 files changed, 1284 insertions(+), 1 deletion(-) create mode 100644 Versum/Controllers/DictController.cs create mode 100644 Versum/Dtos/DeletePhraseDto.cs create mode 100644 Versum/Dtos/DictDto.cs create mode 100644 Versum/Dtos/DictResponceDto.cs create mode 100644 Versum/Migrations/20260520063413_AddDictionaryTable.Designer.cs create mode 100644 Versum/Migrations/20260520063413_AddDictionaryTable.cs create mode 100644 Versum/Migrations/20260520121048_PostRefrenceIsNotNes.Designer.cs create mode 100644 Versum/Migrations/20260520121048_PostRefrenceIsNotNes.cs create mode 100644 Versum/Models/Dictionary.cs create mode 100644 Versum/Services/DictService.cs create mode 100644 Versum/Services/IDictService.cs create mode 100644 "Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" diff --git a/Versum/Controllers/DictController.cs b/Versum/Controllers/DictController.cs new file mode 100644 index 0000000..7353ac4 --- /dev/null +++ b/Versum/Controllers/DictController.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Versum.Core.Enums; +using Versum.Dtos; +using Versum.Hubs; +using Versum.Context; +using Versum.Services; + +namespace Versum.Controllers +{ + + [ApiController] + [Route("api/[controller]")] + public class DictController : ControllerBase + { + private readonly ApplicationDbContext _context; + private readonly IDictService _dictService; + + public DictController(ApplicationDbContext context, IDictService dictService) + { + _context = context; + _dictService = dictService; + } + + + + + [HttpPost("{postId}/add-phrase")] + [Authorize] + public async Task AddPhrase(int postId, [FromBody] DictDto dto) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _dictService.AddPhraseAsync(userId, postId, dto); + + if (!success) + { + if (error == "PostNotFound") return NotFound(new { message = "Твір не знайдено" }); + if (error == "UserNotFound") return NotFound(new { message = "Користувача не знайдено" }); + if (error == "PhraseAlreadyExists") return BadRequest(new { message = "Це слово вже є у вашому словнику" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Додано до словника", + + }); + } + + [HttpPost("delete-phrase")] + [Authorize] + public async Task DeletePhrase([FromBody] DeletePhraseDto dto) + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _dictService.DeletePhraseAsync(userId, dto); + if (!success) + { + if (error == "PhraseNotFound") + return NotFound(new { message = "Не знайдено" }); + return BadRequest(new { message = error }); + } + + return Ok(new { message = "Успішно видалено" }); + } + + [HttpGet("get-dictionary")] + [Authorize] + public async Task GetPhrase() + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success,phrases,error) = await _dictService.GetPhraseAsync(userId); + + if (!success) + { + if (error == "UserNotFound") return NotFound(new { message = "Користувача не знайдено" }); + + return BadRequest(new { message = error }); + } + + return Ok(phrases); + } + + + + + } +} + diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 7f55ae2..ff3c14c 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -15,8 +15,8 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Profiles { get; set; } public DbSet Authors { get; set; } public DbSet Genres { get; set; } - public DbSet Follows { get; set; } = null!; + public DbSet Dictionary { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -57,6 +57,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(f => f.FollowingId); }); + + modelBuilder.Entity() + .HasIndex(d => new { d.UserId, d.Phrase }) + .IsUnique(); } } } diff --git a/Versum/Dtos/DeletePhraseDto.cs b/Versum/Dtos/DeletePhraseDto.cs new file mode 100644 index 0000000..321fcc1 --- /dev/null +++ b/Versum/Dtos/DeletePhraseDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class DeletePhraseDto + { + + + [Required] + [MaxLength(200)] public string Phrase { get; set; } = string.Empty; + + [Required] + public int PostId { get; set; } + + } +} diff --git a/Versum/Dtos/DictDto.cs b/Versum/Dtos/DictDto.cs new file mode 100644 index 0000000..5795ff7 --- /dev/null +++ b/Versum/Dtos/DictDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class DictDto + { + [Required] + [MaxLength(200)] public string Phrase { get; set; } = string.Empty; + + [Required] + [MaxLength(600)] public string Description { get; set; } = string.Empty; + + [Required] + [MaxLength(600)] public string AnchorId { get; set; } = string.Empty; + + } +} diff --git a/Versum/Dtos/DictResponceDto.cs b/Versum/Dtos/DictResponceDto.cs new file mode 100644 index 0000000..1566e1e --- /dev/null +++ b/Versum/Dtos/DictResponceDto.cs @@ -0,0 +1,12 @@ +namespace Versum.Dtos +{ + public class DictResponceDto + { + public int Id { get; set; } + public int? PostId { get; set; } + public string Phrase { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string AnchorId { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + } +} diff --git a/Versum/Migrations/20260520063413_AddDictionaryTable.Designer.cs b/Versum/Migrations/20260520063413_AddDictionaryTable.Designer.cs new file mode 100644 index 0000000..2a76d80 --- /dev/null +++ b/Versum/Migrations/20260520063413_AddDictionaryTable.Designer.cs @@ -0,0 +1,395 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260520063413_AddDictionaryTable")] + partial class AddDictionaryTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "Phrase") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260520063413_AddDictionaryTable.cs b/Versum/Migrations/20260520063413_AddDictionaryTable.cs new file mode 100644 index 0000000..33dee27 --- /dev/null +++ b/Versum/Migrations/20260520063413_AddDictionaryTable.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddDictionaryTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Dictionary", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + PostId = table.Column(type: "integer", nullable: false), + Phrase = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(600)", maxLength: 600, nullable: false), + AnchorId = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Dictionary", x => x.Id); + table.ForeignKey( + name: "FK_Dictionary_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Dictionary_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_PostId", + table: "Dictionary", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_UserId_Phrase", + table: "Dictionary", + columns: new[] { "UserId", "Phrase" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Dictionary"); + } + } +} diff --git a/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.Designer.cs b/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.Designer.cs new file mode 100644 index 0000000..bde9a18 --- /dev/null +++ b/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.Designer.cs @@ -0,0 +1,393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260520121048_PostRefrenceIsNotNes")] + partial class PostRefrenceIsNotNes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "Phrase") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.cs b/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.cs new file mode 100644 index 0000000..ba2243b --- /dev/null +++ b/Versum/Migrations/20260520121048_PostRefrenceIsNotNes.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class PostRefrenceIsNotNes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Dictionary_Posts_PostId", + table: "Dictionary"); + + migrationBuilder.AlterColumn( + name: "PostId", + table: "Dictionary", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AddForeignKey( + name: "FK_Dictionary_Posts_PostId", + table: "Dictionary", + column: "PostId", + principalTable: "Posts", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Dictionary_Posts_PostId", + table: "Dictionary"); + + migrationBuilder.AlterColumn( + name: "PostId", + table: "Dictionary", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Dictionary_Posts_PostId", + table: "Dictionary", + column: "PostId", + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index dbc6772..be6b641 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -69,6 +69,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Authors"); }); + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "Phrase") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + modelBuilder.Entity("Versum.Models.Follow", b => { b.Property("Id") @@ -257,6 +302,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Versum.Models.Follow", b => { b.HasOne("Versum.User", "Follower") diff --git a/Versum/Models/Dictionary.cs b/Versum/Models/Dictionary.cs new file mode 100644 index 0000000..11b6cc3 --- /dev/null +++ b/Versum/Models/Dictionary.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Models +{ + public class Dictionary + { + public int Id { get; set; } + public int UserId { get; set; } + public int? PostId { get; set; } + public virtual User User { get; set; } = null!; + public virtual Post? Post { get; set; } = null!; + + [MaxLength(200)] public string Phrase { get; set; } = string.Empty; + [MaxLength(600)] public string Description { get; set; } = string.Empty; + [MaxLength(60)] public string AnchorId { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsDeleted { get; set; } = false; + + } +} diff --git a/Versum/Program.cs b/Versum/Program.cs index 84a9445..b017ee1 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -41,6 +41,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { diff --git a/Versum/Services/DictService.cs b/Versum/Services/DictService.cs new file mode 100644 index 0000000..85133f0 --- /dev/null +++ b/Versum/Services/DictService.cs @@ -0,0 +1,116 @@ +using Ganss.Xss; +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Services +{ + + public class DictService : IDictService + { + + private readonly ApplicationDbContext _db; + + public DictService(ApplicationDbContext db) + { + _db = db; + } + + public async Task<(bool Success, string? Error)> AddPhraseAsync(int userId,int postId, DictDto dto) { + + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId); + var post = await _db.Posts.AnyAsync(p => p.Id == postId && !p.IsDraft && !p.IsDeleted); + + if (user == null) return (false, "UserNotFound"); + if (post == false) return (false, "PostNotFound"); + + var exists = await _db.Dictionary.AnyAsync(d => d.UserId == userId && d.Phrase == dto.Phrase); + if (exists) return (false, "PhraseAlreadyExists"); + + var phrase = new Dictionary + { + + UserId = userId, + PostId = postId, + Phrase = dto.Phrase, + Description = dto.Description, + AnchorId = dto.AnchorId, + CreatedAt = DateTime.UtcNow + }; + + _db.Dictionary.Add(phrase); + try + { + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"AddPhraseAsync error: {ex.Message}"); + return (false, "ServerError"); + } + + + } + + + public async Task<(bool Success, string? Error)> DeletePhraseAsync(int userId, DeletePhraseDto dto) + { + + var phrase = await _db.Dictionary + .FirstOrDefaultAsync(p => p.UserId == userId && p.Phrase == dto.Phrase && p.PostId == dto.PostId && !p.IsDeleted); + + + if (phrase == null) return (false, "PhraseNotFound"); + + phrase.IsDeleted = true; + + try + { + + await _db.SaveChangesAsync(); + return (true, null); + + } + catch (Exception ex) + { + Console.WriteLine($"DeletePhraseAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + public async Task<(bool Success , List?, string? Error)> GetPhraseAsync(int userId) + { + + var userExists = await _db.Users.AnyAsync(u => u.Id == userId); + if (!userExists) return (false, null, "UserNotFound"); + + var phrases = await _db.Dictionary + .AsNoTracking() + .Where(d => d.UserId == userId && !d.IsDeleted) + .OrderByDescending(d => d.CreatedAt) + .Select(d => new DictResponceDto + { + Id = d.Id, + PostId = d.PostId, + Phrase = d.Phrase, + Description = d.Description, + AnchorId = d.AnchorId, + CreatedAt = d.CreatedAt + }) + .ToListAsync(); + + return (true, phrases, null); + + } + + + + + } + + } + diff --git a/Versum/Services/IDictService.cs b/Versum/Services/IDictService.cs new file mode 100644 index 0000000..2040dcb --- /dev/null +++ b/Versum/Services/IDictService.cs @@ -0,0 +1,14 @@ +using Versum.Dtos; + +namespace Versum.Services +{ + + public interface IDictService + { + Task<(bool Success, string? Error)> AddPhraseAsync(int userId, int postId, DictDto dto); + Task<(bool Success,List?, string? Error)> GetPhraseAsync(int userId); + + Task<(bool Success, string? Error)> DeletePhraseAsync(int userId,DeletePhraseDto dto); + } + } + diff --git "a/Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" "b/Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" new file mode 100644 index 0000000000000000000000000000000000000000..671fd11e476a5a4a65599eb0df0b0febd012518f GIT binary patch literal 889 zcmah{ZAg<*6h5zeqYuFk<|L$=1r7_Qo4T=$t#>+en;9;~!eD`;w3ZuNn@Q0J^hcN( zA+hjBgvDz9D99j!KExjx{fITf0!t_&D6pJEqJBvBoQ;`3g3jf6?tRZW_c{06_jVAG zP*DUXn#wV?U8D_EhV`y-^E2PbdqHx}>{ouB6ULt_&QE`t6UdrR6!Oz5o5@5^0fiUd+oW^%1!LFv$kQ5P#$Ktlqy0IbS$J;1gbkOMMc=&JOHbvoT- zGkHDHo=DU?;EVbLMCDY6;r#~>kdLC|r$DZ$bwpz;FcW0BOfkOCmZp{j=gam%$D5hG zV&*c(x&f3N>%zm_K*1BEp@0zFYY!tI6f2HM#Xzz}l@E_sAN&tr>51Qzq2)LSR z$%eb;l)S|-vB;;PuC^x~2HWFh`4!b@FcpMf}3ANl%R!dVF!5 zXJgLWKpG4+Vp{AlV5b53`E1tWsFq9Q>>8L4qRG}g%-E~SZe@$Qhp*4yseykJXYfJL zHXXnZ>7#xMp(8(j5Ss!h<%N}L@n`PwvG!^U9NjJ&#AdNY)QM)%B5K5HlE9K^0`opr zDdB|`C_FU&-Usm6ZXZ8fYl*)ufAE&3I%hYhxxzM1;InZBzFY5_`}We`C&YPdMu5d8 sDub Date: Thu, 21 May 2026 15:42:49 +0300 Subject: [PATCH 51/67] [Feature] Subscribe Notifications (#37) --- Versum/Hubs/NotificationHub.cs | 21 ++++++++++++++----- Versum/Program.cs | 1 + Versum/Services/INotificationService.cs | 4 ++++ Versum/Services/IProfileService.cs | 1 + Versum/Services/NotificationService.cs | 28 +++++++++++++++++++++++++ Versum/Services/ProfileService.cs | 23 ++++++++++++++++++-- 6 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 Versum/Services/INotificationService.cs create mode 100644 Versum/Services/NotificationService.cs diff --git a/Versum/Hubs/NotificationHub.cs b/Versum/Hubs/NotificationHub.cs index 3f0664a..b7edcc2 100644 --- a/Versum/Hubs/NotificationHub.cs +++ b/Versum/Hubs/NotificationHub.cs @@ -1,18 +1,29 @@ -using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; namespace Versum.Hubs { + public class NotificationMessage + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Type { get; set; } = ""; + public string Message { get; set; } = ""; + public string ActorUsername { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsRead { get; set; } = false; + } + + [Authorize] public class NotificationHub : Hub { - public async Task SendNotification(string message) + public async Task SendNotificationToUser(string userId, NotificationMessage notification) { - await Clients.All.SendAsync("NewPostPublished", message); + await Clients.User(userId).SendAsync("ReceiveNotification", notification); } - // track users public override async Task OnConnectedAsync() { - Console.WriteLine($"User Connected: {Context.ConnectionId}"); + Console.WriteLine($"User Connected: {Context.UserIdentifier} with ConnectionId: {Context.ConnectionId}"); await base.OnConnectedAsync(); } } diff --git a/Versum/Program.cs b/Versum/Program.cs index b017ee1..72d1317 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -41,6 +41,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication(options => diff --git a/Versum/Services/INotificationService.cs b/Versum/Services/INotificationService.cs new file mode 100644 index 0000000..4e72f45 --- /dev/null +++ b/Versum/Services/INotificationService.cs @@ -0,0 +1,4 @@ +public interface INotificationService +{ + Task SendFollowNotificationAsync(int targetUserId, string actorUsername); +} \ No newline at end of file diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index e084edb..ea43f40 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -11,4 +11,5 @@ public interface IProfileService Task> GetFollowingsListAsync(string username); Task GetUserIdByUsernameAsync(string username); + Task GetUsernameByUserIdAsync(int userId); } \ No newline at end of file diff --git a/Versum/Services/NotificationService.cs b/Versum/Services/NotificationService.cs new file mode 100644 index 0000000..c427821 --- /dev/null +++ b/Versum/Services/NotificationService.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.SignalR; +using Versum.Hubs; + +namespace Versum.Services +{ + public class NotificationService : INotificationService + { + private readonly IHubContext _hubContext; + + public NotificationService(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public async Task SendFollowNotificationAsync(int targetUserId, string actorUsername) + { + var notification = new NotificationMessage + { + Type = "Follower", + Message = $"{actorUsername} почав(ла) читати вас.", + ActorUsername = actorUsername + }; + + await _hubContext.Clients.User(targetUserId.ToString()) + .SendAsync("ReceiveNotification", notification); + } + } +} diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index a07f476..9892b7a 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -11,10 +11,12 @@ public class ProfileService : IProfileService { private readonly ApplicationDbContext _db; - public ProfileService(ApplicationDbContext db) + private readonly INotificationService _notificationService; + public ProfileService(ApplicationDbContext db, INotificationService notificationService) { _db = db; + _notificationService = notificationService; } public async Task<(bool success, string? error)> UpdateProfileAsync(int UserId, UserProfileDto dto) { @@ -146,6 +148,13 @@ public ProfileService(ApplicationDbContext db) .Select(u => (int?)u.Id) .FirstOrDefaultAsync(); } + public async Task GetUsernameByUserIdAsync(int userId) + { + return await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.Username) + .FirstOrDefaultAsync(); + } public async Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId) { @@ -154,13 +163,23 @@ public ProfileService(ApplicationDbContext db) var existingFollow = await _db.Follows .FirstOrDefaultAsync(f => f.FollowerId == followerId && f.FollowingId == followingId); + bool isFollowed = false; + if (existingFollow != null) { - _db.Follows.Remove(existingFollow); + _db.Follows.Remove(existingFollow); //unsub } else { _db.Follows.Add(new Follow { FollowerId = followerId, FollowingId = followingId }); + isFollowed = true; //sub + } + + //notification + if (isFollowed) + { + var username = await GetUsernameByUserIdAsync(followerId); + await _notificationService.SendFollowNotificationAsync(followingId, username ?? "Хтось"); } await _db.SaveChangesAsync(); From 99e75d2eb919523a0ace4d791424196cde5eaf5a Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 21 May 2026 15:47:12 +0300 Subject: [PATCH 52/67] [FIX] fixed tests --- TestProject/ServicesTest/ProfileServiceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TestProject/ServicesTest/ProfileServiceTests.cs b/TestProject/ServicesTest/ProfileServiceTests.cs index 43fbe8c..d66b7da 100644 --- a/TestProject/ServicesTest/ProfileServiceTests.cs +++ b/TestProject/ServicesTest/ProfileServiceTests.cs @@ -21,6 +21,7 @@ public class ProfileServiceTests : IDisposable private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _assertContext; private readonly ProfileService _profileService; + private readonly NotificationService _notificationsService; private const string TestUsername = "vixy"; private const string UpdatedUsername = "vixy_upd"; @@ -36,7 +37,7 @@ public ProfileServiceTests() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; _context = new ApplicationDbContext(options); - _profileService = new ProfileService(_context); + _profileService = new ProfileService(_context, _notificationsService); _assertContext = new ApplicationDbContext(options); } From f0466bb5f7620bf52a55c6932f88812f3b3e7393 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Thu, 21 May 2026 17:01:13 +0300 Subject: [PATCH 53/67] [FIX] Configured HTML sanitizer --- Versum/Services/PostService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index c44bd9d..0a45d59 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -148,6 +148,7 @@ public PostService(ApplicationDbContext db) var sanitizer = new HtmlSanitizer(); sanitizer.AllowedAttributes.Add("data-description"); sanitizer.AllowedAttributes.Add("class"); + sanitizer.AllowedAttributes.Add("id"); draft.Title = dto.Title; draft.Description = dto.Description; From 9bc68637ce499a2f1ce16bf8303fc669fb20e87b Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Sun, 24 May 2026 20:53:11 +0300 Subject: [PATCH 54/67] [TEST] Add unit test for PostService and PostController (#34) --- .../ControllerTest/PostControllerTest.cs | 398 ++++++++++++++++ TestProject/ServicesTest/PostServiceTests.cs | 425 +++++++++++++++++- TestProject/VersumTestProject.csproj | 10 +- Versum/Controllers/PostsController.cs | 9 +- 4 files changed, 816 insertions(+), 26 deletions(-) create mode 100644 TestProject/ControllerTest/PostControllerTest.cs diff --git a/TestProject/ControllerTest/PostControllerTest.cs b/TestProject/ControllerTest/PostControllerTest.cs new file mode 100644 index 0000000..a54904e --- /dev/null +++ b/TestProject/ControllerTest/PostControllerTest.cs @@ -0,0 +1,398 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Versum.Dtos; +using System.Security.Claims; +using Versum.Controllers; +using Versum.Services; + +namespace VersumTestProject.ControllerTest +{ + public class PostControllerTests + { + private readonly Mock _postServiceMock; + private readonly PostsController _controller; + + private const string TestTitle = "title"; + private const string TestDescription = "description"; + private const string TestContent = "SecurePassword123"; + public PostControllerTests() + { + _postServiceMock = new Mock(); + _controller = new PostsController( _postServiceMock.Object); + + // mocking authorized user + var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] + { + new Claim(ClaimTypes.NameIdentifier, "1"), + }, "mock")); + + _controller.ControllerContext = new ControllerContext() + { + HttpContext = new DefaultHttpContext() { User = user } + }; + } + + + [Fact] + public async Task PublishDraft_ReturnsUnauthorized_WhenClaimIsMissingOrInvalid() + { + // Arrange + + var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { }, "mock")); + _controller.ControllerContext.HttpContext.User = user; + + // Act + var result = await _controller.PublishDraft(1); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task PublishDraft_ReturnsNotFound_WhenAuthorProfileIsMissing() + { + // Arrange + int postId = 1; + int userId = 1; + + + _postServiceMock + .Setup(s => s.PublishDraftAsync(postId, userId)) + .ReturnsAsync((false, "AuthorNotFound")); + + // Act + var result = await _controller.PublishDraft(postId); + + // Assert + + var notFoundResult = Assert.IsType(result); + + + Assert.Equal(404, notFoundResult.StatusCode); + + + var response = notFoundResult.Value; + var message = response.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Профіль автора не знайдено", message); + } + + [Fact] + public async Task PublishDraft_ReturnsBadRequest_WhenServiceReturnsGeneralError() + { + // Arrange + int postId = 1; + int userId = 1; + string specificError = "PostIsAlreadyPublished"; + + _postServiceMock + .Setup(s => s.PublishDraftAsync(postId, userId)) + .ReturnsAsync((false, specificError)); + + // Act + var result = await _controller.PublishDraft(postId); + + // Assert + + var badRequestResult = Assert.IsType(result); + Assert.Equal(400, badRequestResult.StatusCode); + + + var response = badRequestResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + + + Assert.Equal(specificError, message); + } + + [Fact] + public async Task PublishDraft_ReturnsCreated_WhenPublishingIsSuccessful() + { + // Arrange + int postId = 1; + int userId = 1; + + + _postServiceMock + .Setup(s => s.PublishDraftAsync(postId, userId)) + .ReturnsAsync((true, string.Empty)); + + // Act + var result = await _controller.PublishDraft(postId); + + // Assert + + var objectResult = Assert.IsType(result); + + + Assert.Equal(201, objectResult.StatusCode); + + + var response = objectResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Твір успішно опубліковано", message); + } + + + [Fact] + public async Task CreateDraft_ReturnsUnauthorized_WhenClaimIsMissingOrInvalid() + { + // Arrange + + var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { }, "mock")); + _controller.ControllerContext.HttpContext.User = user; + var dto = new CreateDraftDto { }; + // Act + var result = await _controller.CreateDraft(dto); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task CreateDraft_ReturnsNotFound_WhenAuthorProfileIsMissing() + { + // Arrange + + int userId = 1; + var dto = new CreateDraftDto { Title = TestTitle}; + + _postServiceMock + .Setup(s => s.CreateDraftAsync(userId,dto)) + .ReturnsAsync((false, "AuthorNotFound",0)); + + // Act + var result = await _controller.CreateDraft(dto); + + // Assert + + var notFoundResult = Assert.IsType(result); + + + Assert.Equal(404, notFoundResult.StatusCode); + + + var response = notFoundResult.Value; + var message = response.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Профіль автора не знайдено", message); + } + + [Fact] + public async Task CreateDraft_ReturnsBadRequest_WhenServiceReturnsError() + { + // Arrange + var dto = new CreateDraftDto { Title = TestTitle}; + int authorId = 1; + string serviceError = "LimitExceeded"; + + _postServiceMock + .Setup(s => s.CreateDraftAsync(authorId, dto)) + .ReturnsAsync((false, serviceError, 0)); + + // Act + var result = await _controller.CreateDraft(dto); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal(400, badRequestResult.StatusCode); + + var response = badRequestResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + Assert.Equal(serviceError, message); + } + + [Fact] + public async Task CreateDraft_WhenCreateIsSuccessfu() + { + // Arrange + var dto = new CreateDraftDto { Title = "Нова чернетка"}; + int authorId = 1; + int expostId = 42; + + _postServiceMock + .Setup(s => s.CreateDraftAsync(authorId, dto)) + .ReturnsAsync((true, string.Empty, expostId)); + + // Act + var result = await _controller.CreateDraft(dto); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(201, objectResult.StatusCode); + + + var response = objectResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + var postId = response?.GetType().GetProperty("postId")?.GetValue(response, null); + + Assert.Equal("Чернетку створено", message); + Assert.Equal(expostId, postId); + } + + [Fact] + public async Task UpdateDraft_ReturnsUnauthorized_WhenClaimIsMissingOrInvalid() + { + // Arrange + + var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { }, "mock")); + _controller.ControllerContext.HttpContext.User = user; + var dto = new PostDto { }; + + // Act + + var result = await _controller.UpdateDraft(1,dto); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task UpdateDraft_ReturnsNotFound_WhenDraftIsMisssing() + { + // Arrange + + int userId = 1; + int postId = 1; + var dto = new PostDto { Title = TestTitle, Description = TestDescription ,Content = TestContent }; + + _postServiceMock + .Setup(s => s.UpdateDraftAsync(postId,userId, dto)) + .ReturnsAsync((false, "DraftNotFound")); + + // Act + var result = await _controller.UpdateDraft(1,dto); + + // Assert + + var notFoundResult = Assert.IsType(result); + + + Assert.Equal(404, notFoundResult.StatusCode); + + + var response = notFoundResult.Value; + var message = response.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Чернетку не знайдено", message); + } + + [Fact] + public async Task UpdateDraft_ReturnsNotFound_WhenDraftIsPublished() + { + // Arrange + + int userId = 1; + int postId = 1; + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + _postServiceMock + .Setup(s => s.UpdateDraftAsync(postId, userId, dto)) + .ReturnsAsync((false, "You can't edit published writings")); + + // Act + var result = await _controller.UpdateDraft(1, dto); + + // Assert + + var notFoundResult = Assert.IsType(result); + + + Assert.Equal(404, notFoundResult.StatusCode); + + + var response = notFoundResult.Value; + var message = response.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Твір уже опубліковано", message); + } + + [Fact] + public async Task UpdateDraft_ReturnsNotFound_WhenWrongAuthor() + { + // Arrange + + int userId = 1; + int postId = 1; + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + _postServiceMock + .Setup(s => s.UpdateDraftAsync(postId, userId, dto)) + .ReturnsAsync((false, "YouAreNotAnOwnerOfDraft")); + + // Act + var result = await _controller.UpdateDraft(1, dto); + + // Assert + + var notFoundResult = Assert.IsType(result); + + + Assert.Equal(404, notFoundResult.StatusCode); + + + var response = notFoundResult.Value; + var message = response.GetType().GetProperty("message")?.GetValue(response, null); + + Assert.Equal("Ви нє автором чернетки", message); + } + + [Fact] + public async Task UpdateDraft_ReturnsBadRequest_WhenServiceReturnsError() + { + // Arrange + + int userId = 1; + int postId = 1; + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + string serviceError = "ContentRequired"; + + _postServiceMock + .Setup(s => s.UpdateDraftAsync(postId,userId, dto)) + .ReturnsAsync((false, serviceError)); + + // Act + var result = await _controller.UpdateDraft(postId,dto); + + // Assert + var badRequestResult = Assert.IsType(result); + Assert.Equal(400, badRequestResult.StatusCode); + + var response = badRequestResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + Assert.Equal(serviceError, message); + } + + [Fact] + public async Task UpdateDraft_WhenUpdateIsSuccessfu() + { + // Arrange + int userId = 1; + int postId = 1; + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + _postServiceMock + .Setup(s => s.UpdateDraftAsync(postId,userId, dto)) + .ReturnsAsync((true, string.Empty)); + + // Act + var result = await _controller.UpdateDraft(postId,dto); + + // Assert + var objectResult = Assert.IsType(result); + Assert.Equal(201, objectResult.StatusCode); + + + var response = objectResult.Value; + var message = response?.GetType().GetProperty("message")?.GetValue(response, null); + + + Assert.Equal("Чернетку збережено", message); + + } + } +} diff --git a/TestProject/ServicesTest/PostServiceTests.cs b/TestProject/ServicesTest/PostServiceTests.cs index 7ba9027..a3b1feb 100644 --- a/TestProject/ServicesTest/PostServiceTests.cs +++ b/TestProject/ServicesTest/PostServiceTests.cs @@ -1,21 +1,26 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Moq; -using System; -using System.Collections.Generic; -using System.Text; +using Versum; using Versum.Context; using Versum.Dtos; +using Versum.Models; using Versum.Services; namespace VersumTestProject.ServicesTest { - internal class PostServiceTests : IDisposable + public class PostServiceTests : IDisposable { private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _assertContext; private readonly PostService _postService; + private const string TestTitle = "title"; + private const string TestDescription = "description"; + private const string TestContent = "SecurePassword123"; + private const string TestUsername = "vixy"; + private const string TestEmail = "vixy@test.com"; + private const string TestBio = "AuthorBio"; + // SET UP public PostServiceTests() { @@ -27,35 +32,419 @@ public PostServiceTests() _assertContext = new ApplicationDbContext(options); } - // TEARDOWN + // TEARDOWN public void Dispose() { _context.Dispose(); _assertContext.Dispose(); + + } + + + + [Fact] + public async Task CreateDraftAsync_ReturnSuccessWhenAthorFound() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + await _context.SaveChangesAsync(); + + var dto = new CreateDraftDto { Title = TestTitle }; + + // Act + var (success, error, postId) = await _postService.CreateDraftAsync(testuser.Id, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + Assert.NotNull(postId); + + var updatedAuthor = await _assertContext.Users.Include(u => u.AuthorProfile).FirstAsync(u => u.Id == 1); + + var postInDb = await _assertContext.Posts.FindAsync(postId); + Assert.NotNull(postInDb); + Assert.Equal(dto.Title, postInDb.Title); + Assert.Equal(Id, postInDb.AuthorId); + Assert.True(postInDb.IsDraft); + + } + + + + [Fact] + public async Task CreateDraftAsync_ReturnErrorWhenAthorNOTFound() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + _context.Users.Add(testuser); + + await _context.SaveChangesAsync(); + + var dto = new CreateDraftDto { Title = TestTitle }; + + // Act + var (success, error, postId) = await _postService.CreateDraftAsync(testuser.Id, dto); + + // Assert + Assert.False(success); + Assert.Null(testuser.AuthorProfile); + Assert.Equal("AuthorNotFound", error); + Assert.Null(postId); + + + } + + [Fact] + public async Task CreateDraftAsync_ReturnsServerError_WhenDatabaseThrowsException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + + var mockContext = new Mock(options) { CallBase = true }; + var testAuthor = new Author { AuthorId = 1, AuthorBio = TestBio }; + + mockContext.Object.Authors.Add(testAuthor); + await mockContext.Object.SaveChangesAsync(); + + + mockContext.Setup(m => m.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new Exception("Database connection failed")); + + var serviceWithMock = new PostService(mockContext.Object); + var dto = new CreateDraftDto { Title = "Test Title" }; + + // 5. Act + var (success, error, postId) = await serviceWithMock.CreateDraftAsync(1, dto); + + // 6. Assert + Assert.False(success); + Assert.Equal("ServerError", error); + Assert.Null(postId); + } + + + [Fact] + public async Task UpdateDraftAsync_ReturnSuccess() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, IsDraft = true, AuthorId = Id }; + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + _context.Posts.Add(testdraft); + + await _context.SaveChangesAsync(); + + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent}; + + // Act + var (success, error) = await _postService.UpdateDraftAsync(testdraft.Id,testuser.Id,dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + + var updatedAuthor = await _assertContext.Users.Include(u => u.AuthorProfile).FirstAsync(u => u.Id == 1); + + var postInDb = await _assertContext.Posts.FindAsync(testdraft.Id); + Assert.NotNull(postInDb); + Assert.Equal(dto.Title, postInDb.Title); + Assert.Equal(dto.Description, postInDb.Description); + Assert.Equal(dto.Content, postInDb.Content); + + } + + + [Fact] + public async Task UpdateDraftAsync_ReturnFalseWnenDraftIsNOTFound() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, Description = TestDescription, Content = TestContent, IsDraft = true}; + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + _context.Posts.Add(testdraft); + + await _context.SaveChangesAsync(); + + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + // Act + var (success, error) = await _postService.UpdateDraftAsync(9000, testuser.Id, dto); + + // Assert + Assert.False(success); + Assert.Equal("DraftNotFound", error); + + } + + [Fact] + public async Task UpdateDraftAsync_ReturnFalseWnenDraftIsFALSE() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, IsDraft = false }; + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + _context.Posts.Add(testdraft); + + await _context.SaveChangesAsync(); + + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + // Act + var (success, error) = await _postService.UpdateDraftAsync(testdraft.Id, testuser.Id, dto); + + // Assert + Assert.False(success); + Assert.Equal("You can't edit published writings", error); + + } + + + [Fact] + public async Task UpdateDraftAsync_ReturnFalseWnenUserIdNotEqualAuthorId() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = 6, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, IsDraft = true }; + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + _context.Posts.Add(testdraft); + + await _context.SaveChangesAsync(); + + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + // Act + var (success, error) = await _postService.UpdateDraftAsync(testdraft.Id, testuser.Id, dto); + + // Assert + Assert.False(success); + Assert.Equal("YouAreNotAnOwnerOfDraft", error); + + } + + [Fact] + public async Task UpdateDraftAsync_ReturnsServerError_WhenDatabaseThrowsException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + + var mockContext = new Mock(options) { CallBase = true }; + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, IsDraft = true , AuthorId = Id}; + + mockContext.Object.Users.Add(testuser); + mockContext.Object.Authors.Add(testauthor); + mockContext.Object.Posts.Add(testdraft); + await mockContext.Object.SaveChangesAsync(); + + + mockContext.Setup(m => m.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new Exception("Database connection failed")); + + var serviceWithMock = new PostService(mockContext.Object); + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; + + + // 5. Act + var (success, error) = await serviceWithMock.UpdateDraftAsync(testdraft.Id,testuser.Id, dto); + + // 6. Assert + Assert.False(success); + Assert.Equal("ServerError", error); } + [Fact] + public async Task UpdateDraftAsync_SuccessUpdateWithGenres() + { + // Arrange + int Id = 1; + var testuser = new User { Id = Id, Username = TestUsername, Email = TestEmail }; + var testauthor = new Author { AuthorId = Id, AuthorBio = TestBio }; + var testdraft = new Post { Id = 1, Title = TestTitle, IsDraft = true, AuthorId = Id }; + var sciFiGenre = new Genre { Name = "Sci-Fi" }; + + _context.Users.Add(testuser); + _context.Authors.Add(testauthor); + _context.Posts.Add(testdraft); + _context.Genres.Add(sciFiGenre); + + await _context.SaveChangesAsync(); + + var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent, Genres = { "Sci-Fi" } }; - /* [Fact] - public async Task PublishDraftAsyncAsync_SuccessfullPublication() - { - // Arrange + // Act + var (success, error) = await _postService.UpdateDraftAsync(testdraft.Id, testuser.Id, dto); + + // Assert + Assert.True(success); + Assert.Null(error); + + var postInDb = await _assertContext.Posts + .Include(p => p.Genres) + .FirstOrDefaultAsync(p => p.Id == testdraft.Id); + + Assert.NotNull(postInDb); + Assert.Equal(dto.Title, postInDb.Title); + Assert.Equal(dto.Description, postInDb.Description); + Assert.Equal(dto.Content, postInDb.Content); + + + Assert.Equal(dto.Genres.Count, postInDb.Genres.Count); + foreach (var expectedGenre in dto.Genres) + { + + Assert.Contains(postInDb.Genres, g => g.Name == expectedGenre); + } + var updatedAuthor = await _assertContext.Users.Include(u => u.AuthorProfile).FirstAsync(u => u.Id == 1); + + } - var dto = new PostDto { Title = TestUsername, Name = TestName, Bio = TestBio }; - // Act - var (success, error) = await _postService.UpdateProfileAsync(99989897, dto); + [Fact] + public async Task PublishDraftAsync_ReturnError_WhenContentIsEmpty() + { + // Arrange + int userId = 1; + var testpost = new Post + { + Id = 1, + AuthorId = userId, + Title = TestTitle, + Description = TestDescription, + Content = "", + IsDraft = true + }; + + _context.Posts.Add(testpost); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _postService.PublishDraftAsync(testpost.Id, userId); + + // Assert + Assert.False(success); + Assert.Equal("ContentRequired", error); + } + + [Fact] + public async Task PublishDraftAsync_ReturnError_WhendescriptionIsEmpty() + { + // Arrange + int userId = 1; + var testpost = new Post + { + Id = 1, + AuthorId = userId, + Title = TestTitle, + Description = "", + Content = TestContent, + IsDraft = true + }; + + _context.Posts.Add(testpost); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _postService.PublishDraftAsync(testpost.Id, userId); + + // Assert + Assert.False(success); + Assert.Equal("DescriptionRequired", error); + } + + [Fact] + public async Task PublishDraftAsync_ReturnError_WhenTitleIsEmpty() + { + // Arrange + int userId = 1; + var testpost = new Post + { + Id = 1, + AuthorId = userId, + Title = " ", + Description = TestDescription, + Content = TestContent, + IsDraft = true + }; + + _context.Posts.Add(testpost); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _postService.PublishDraftAsync(testpost.Id, userId); + + // Assert + Assert.False(success); + Assert.Equal("TitleRequired", error); + } + + [Fact] + public async Task PublishDraftAsync_ReturnError_WhenUserIsNotOwner() + { + // Arrange + int ownerId = 1; + int strangerId = 2; + var testpost = new Post { Id = 1, AuthorId = ownerId, IsDraft = true, Title = TestTitle, Description = TestDescription, Content = TestContent }; + + _context.Posts.Add(testpost); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _postService.PublishDraftAsync(testpost.Id, strangerId); + + // Assert + Assert.False(success); + Assert.Equal("YouAreNotAnOwnerOfDraft", error); + } + + [Fact] + public async Task PublishDraftAsync_ReturnError_WhenAlreadyPublished() + { + // Arrange + int userId = 1; + var testpost = new Post { Id = 1, AuthorId = userId, IsDraft = false, Title = TestTitle, Description = TestDescription, Content = TestContent }; + + _context.Posts.Add(testpost); + await _context.SaveChangesAsync(); + + // Act + var (success, error) = await _postService.PublishDraftAsync(testpost.Id, userId); + + // Assert + Assert.False(success); + Assert.Equal("AlreadyPublished", error); + } + - // Assert - Assert.False(success); - Assert.Equal("Чому нас вважають за одну людину?", error); - } - */ } } diff --git a/TestProject/VersumTestProject.csproj b/TestProject/VersumTestProject.csproj index 32d8ba1..4e8a872 100644 --- a/TestProject/VersumTestProject.csproj +++ b/TestProject/VersumTestProject.csproj @@ -7,6 +7,12 @@ false + + + + + + @@ -25,8 +31,4 @@ - - - - \ No newline at end of file diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index c35609a..57a3ba3 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -17,15 +17,14 @@ namespace Versum.Controllers [Route("api/[controller]")] public class PostsController : ControllerBase { - private readonly ApplicationDbContext _context; - private readonly IHubContext _hubContext; + + /* private readonly IHubContext _hubContext; */ private readonly IPostService _postService; private readonly IProfileService _profileService; public PostsController(ApplicationDbContext context, IHubContext hubContext, IPostService postService, IProfileService profileService) { - _context = context; - _hubContext = hubContext; + _postService = postService; _profileService = profileService; } @@ -100,6 +99,8 @@ public async Task UpdateDraft(int postId, [FromBody] PostDto dto) if (!success) { if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); + if (error == "You can't edit published writings") return NotFound(new { message = "Твір уже опубліковано" }); + if (error == "YouAreNotAnOwnerOfDraft") return NotFound(new { message = "Ви нє автором чернетки" }); return BadRequest(new { message = error }); } From c7e85df25d8f66c4f1c28eae904331188ba1174b Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Sun, 24 May 2026 21:03:56 +0300 Subject: [PATCH 55/67] [FIX] Minor test fix --- TestProject/ControllerTest/PostControllerTest.cs | 4 +++- Versum/Controllers/PostsController.cs | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/TestProject/ControllerTest/PostControllerTest.cs b/TestProject/ControllerTest/PostControllerTest.cs index a54904e..933a6e1 100644 --- a/TestProject/ControllerTest/PostControllerTest.cs +++ b/TestProject/ControllerTest/PostControllerTest.cs @@ -12,6 +12,7 @@ namespace VersumTestProject.ControllerTest public class PostControllerTests { private readonly Mock _postServiceMock; + private readonly Mock _profileServiceMock; private readonly PostsController _controller; private const string TestTitle = "title"; @@ -20,7 +21,8 @@ public class PostControllerTests public PostControllerTests() { _postServiceMock = new Mock(); - _controller = new PostsController( _postServiceMock.Object); + _profileServiceMock = new Mock(); + _controller = new PostsController( _postServiceMock.Object, _profileServiceMock.Object); // mocking authorized user var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index 57a3ba3..a097f8d 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -18,11 +18,10 @@ namespace Versum.Controllers public class PostsController : ControllerBase { - /* private readonly IHubContext _hubContext; */ private readonly IPostService _postService; private readonly IProfileService _profileService; - public PostsController(ApplicationDbContext context, IHubContext hubContext, IPostService postService, IProfileService profileService) + public PostsController(IPostService postService, IProfileService profileService) { _postService = postService; From d2d9ed2e74e4a6119f94ce45ff3044772fe9b96b Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Sun, 24 May 2026 22:21:53 +0300 Subject: [PATCH 56/67] [FIX] Dict: Adding same words with different meanings --- Versum/Controllers/DictController.cs | 20 +- Versum/DbContext/ApplicationDbContext.cs | 2 +- Versum/Dtos/DeletePhraseDto.cs | 8 +- Versum/Dtos/DictDto.cs | 5 +- Versum/Dtos/DictResponceDto.cs | 1 + .../20260524183851_Dict patch.Designer.cs | 393 ++++++++++++++++++ .../Migrations/20260524183851_Dict patch.cs | 38 ++ .../20260524184048_Dict patch 2.Designer.cs | 393 ++++++++++++++++++ .../Migrations/20260524184048_Dict patch 2.cs | 38 ++ .../ApplicationDbContextModelSnapshot.cs | 2 +- Versum/Services/DictService.cs | 121 +++--- Versum/Services/IDictService.cs | 4 +- ...0\257\321\200\320\273\320\270\320\272.lnk" | Bin 889 -> 0 bytes 13 files changed, 928 insertions(+), 97 deletions(-) create mode 100644 Versum/Migrations/20260524183851_Dict patch.Designer.cs create mode 100644 Versum/Migrations/20260524183851_Dict patch.cs create mode 100644 Versum/Migrations/20260524184048_Dict patch 2.Designer.cs create mode 100644 Versum/Migrations/20260524184048_Dict patch 2.cs delete mode 100644 "Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" diff --git a/Versum/Controllers/DictController.cs b/Versum/Controllers/DictController.cs index 7353ac4..5d4bb93 100644 --- a/Versum/Controllers/DictController.cs +++ b/Versum/Controllers/DictController.cs @@ -25,12 +25,9 @@ public DictController(ApplicationDbContext context, IDictService dictService) _dictService = dictService; } - - - - [HttpPost("{postId}/add-phrase")] + [HttpPost("add-phrase")] [Authorize] - public async Task AddPhrase(int postId, [FromBody] DictDto dto) + public async Task AddPhrase([FromBody] DictDto dto) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -39,7 +36,7 @@ public async Task AddPhrase(int postId, [FromBody] DictDto dto) return Unauthorized(); } - var (success, error) = await _dictService.AddPhraseAsync(userId, postId, dto); + var (success, error) = await _dictService.AddPhraseAsync(userId, dto); if (!success) { @@ -80,7 +77,7 @@ public async Task DeletePhrase([FromBody] DeletePhraseDto dto) [HttpGet("get-dictionary")] [Authorize] - public async Task GetPhrase() + public async Task GetDictionary() { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -89,7 +86,7 @@ public async Task GetPhrase() return Unauthorized(); } - var (success,phrases,error) = await _dictService.GetPhraseAsync(userId); + var (success,phrases,error) = await _dictService.GetDictionaryAsync(userId); if (!success) { @@ -100,10 +97,5 @@ public async Task GetPhrase() return Ok(phrases); } - - - - } -} - +} \ No newline at end of file diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index ff3c14c..30ebc13 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -59,7 +59,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); modelBuilder.Entity() - .HasIndex(d => new { d.UserId, d.Phrase }) + .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) .IsUnique(); } } diff --git a/Versum/Dtos/DeletePhraseDto.cs b/Versum/Dtos/DeletePhraseDto.cs index 321fcc1..e0db9fc 100644 --- a/Versum/Dtos/DeletePhraseDto.cs +++ b/Versum/Dtos/DeletePhraseDto.cs @@ -4,13 +4,7 @@ namespace Versum.Dtos { public class DeletePhraseDto { - - [Required] - [MaxLength(200)] public string Phrase { get; set; } = string.Empty; - - [Required] - public int PostId { get; set; } - + public int Id { get; set; } } } diff --git a/Versum/Dtos/DictDto.cs b/Versum/Dtos/DictDto.cs index 5795ff7..8a368ad 100644 --- a/Versum/Dtos/DictDto.cs +++ b/Versum/Dtos/DictDto.cs @@ -4,6 +4,9 @@ namespace Versum.Dtos { public class DictDto { + [Required] + public int PostId { get; set; } + [Required] [MaxLength(200)] public string Phrase { get; set; } = string.Empty; @@ -11,7 +14,7 @@ public class DictDto [MaxLength(600)] public string Description { get; set; } = string.Empty; [Required] - [MaxLength(600)] public string AnchorId { get; set; } = string.Empty; + [MaxLength(60)] public string AnchorId { get; set; } = string.Empty; } } diff --git a/Versum/Dtos/DictResponceDto.cs b/Versum/Dtos/DictResponceDto.cs index 1566e1e..5a13196 100644 --- a/Versum/Dtos/DictResponceDto.cs +++ b/Versum/Dtos/DictResponceDto.cs @@ -4,6 +4,7 @@ public class DictResponceDto { public int Id { get; set; } public int? PostId { get; set; } + public string PostTitle { get; set; } = string.Empty; public string Phrase { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string AnchorId { get; set; } = string.Empty; diff --git a/Versum/Migrations/20260524183851_Dict patch.Designer.cs b/Versum/Migrations/20260524183851_Dict patch.Designer.cs new file mode 100644 index 0000000..44cd09e --- /dev/null +++ b/Versum/Migrations/20260524183851_Dict patch.Designer.cs @@ -0,0 +1,393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260524183851_Dict patch")] + partial class Dictpatch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260524183851_Dict patch.cs b/Versum/Migrations/20260524183851_Dict patch.cs new file mode 100644 index 0000000..3ed22d3 --- /dev/null +++ b/Versum/Migrations/20260524183851_Dict patch.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Dictpatch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Dictionary_UserId_Phrase", + table: "Dictionary"); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_UserId_AnchorId", + table: "Dictionary", + columns: new[] { "UserId", "AnchorId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Dictionary_UserId_AnchorId", + table: "Dictionary"); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_UserId_Phrase", + table: "Dictionary", + columns: new[] { "UserId", "Phrase" }, + unique: true); + } + } +} diff --git a/Versum/Migrations/20260524184048_Dict patch 2.Designer.cs b/Versum/Migrations/20260524184048_Dict patch 2.Designer.cs new file mode 100644 index 0000000..2086e3b --- /dev/null +++ b/Versum/Migrations/20260524184048_Dict patch 2.Designer.cs @@ -0,0 +1,393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260524184048_Dict patch 2")] + partial class Dictpatch2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260524184048_Dict patch 2.cs b/Versum/Migrations/20260524184048_Dict patch 2.cs new file mode 100644 index 0000000..e549ff0 --- /dev/null +++ b/Versum/Migrations/20260524184048_Dict patch 2.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Dictpatch2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Dictionary_UserId_AnchorId", + table: "Dictionary"); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_UserId_PostId_AnchorId", + table: "Dictionary", + columns: new[] { "UserId", "PostId", "AnchorId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Dictionary_UserId_PostId_AnchorId", + table: "Dictionary"); + + migrationBuilder.CreateIndex( + name: "IX_Dictionary_UserId_AnchorId", + table: "Dictionary", + columns: new[] { "UserId", "AnchorId" }, + unique: true); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index be6b641..69cab0b 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -108,7 +108,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PostId"); - b.HasIndex("UserId", "Phrase") + b.HasIndex("UserId", "PostId", "AnchorId") .IsUnique(); b.ToTable("Dictionary"); diff --git a/Versum/Services/DictService.cs b/Versum/Services/DictService.cs index 85133f0..7a6ef8a 100644 --- a/Versum/Services/DictService.cs +++ b/Versum/Services/DictService.cs @@ -1,68 +1,56 @@ -using Ganss.Xss; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Versum.Context; using Versum.Dtos; using Versum.Models; namespace Versum.Services { - - public class DictService : IDictService + public class DictService : IDictService + { + private readonly ApplicationDbContext _db; + public DictService(ApplicationDbContext db) { + _db = db; + } - private readonly ApplicationDbContext _db; - - public DictService(ApplicationDbContext db) - { - _db = db; - } - - public async Task<(bool Success, string? Error)> AddPhraseAsync(int userId,int postId, DictDto dto) { - - - var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId); - var post = await _db.Posts.AnyAsync(p => p.Id == postId && !p.IsDraft && !p.IsDeleted); - - if (user == null) return (false, "UserNotFound"); - if (post == false) return (false, "PostNotFound"); - - var exists = await _db.Dictionary.AnyAsync(d => d.UserId == userId && d.Phrase == dto.Phrase); - if (exists) return (false, "PhraseAlreadyExists"); - - var phrase = new Dictionary - { - - UserId = userId, - PostId = postId, - Phrase = dto.Phrase, - Description = dto.Description, - AnchorId = dto.AnchorId, - CreatedAt = DateTime.UtcNow - }; + public async Task<(bool Success, string? Error)> AddPhraseAsync(int userId, DictDto dto) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return (false, "UserNotFound"); - _db.Dictionary.Add(phrase); - try - { - - await _db.SaveChangesAsync(); - return (true, null); - } - catch (Exception ex) - { - Console.WriteLine($"AddPhraseAsync error: {ex.Message}"); - return (false, "ServerError"); - } + var post = await _db.Posts.AnyAsync(p => p.Id == dto.PostId && !p.IsDraft && !p.IsDeleted); + if (post == false) return (false, "PostNotFound"); + var exists = await _db.Dictionary.AnyAsync(d => d.UserId == userId && d.PostId == dto.PostId && d.AnchorId == dto.AnchorId); + if (exists) return (false, "PhraseAlreadyExists"); + var phrase = new Dictionary + { + UserId = userId, + PostId = dto.PostId, + Phrase = dto.Phrase, + Description = dto.Description, + AnchorId = dto.AnchorId, + CreatedAt = DateTime.UtcNow + }; + + _db.Dictionary.Add(phrase); + try + { + await _db.SaveChangesAsync(); + return (true, null); } - + catch (Exception ex) + { + Console.WriteLine($"AddPhraseAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } public async Task<(bool Success, string? Error)> DeletePhraseAsync(int userId, DeletePhraseDto dto) { - var phrase = await _db.Dictionary - .FirstOrDefaultAsync(p => p.UserId == userId && p.Phrase == dto.Phrase && p.PostId == dto.PostId && !p.IsDeleted); - + .FirstOrDefaultAsync(p => p.UserId == userId && p.Id == dto.Id && !p.IsDeleted); if (phrase == null) return (false, "PhraseNotFound"); @@ -70,10 +58,8 @@ public DictService(ApplicationDbContext db) try { - await _db.SaveChangesAsync(); return (true, null); - } catch (Exception ex) { @@ -82,35 +68,28 @@ public DictService(ApplicationDbContext db) } } - public async Task<(bool Success , List?, string? Error)> GetPhraseAsync(int userId) + public async Task<(bool Success, List?, string? Error)> GetDictionaryAsync(int userId) { - var userExists = await _db.Users.AnyAsync(u => u.Id == userId); if (!userExists) return (false, null, "UserNotFound"); var phrases = await _db.Dictionary - .AsNoTracking() + .AsNoTracking() .Where(d => d.UserId == userId && !d.IsDeleted) - .OrderByDescending(d => d.CreatedAt) + .OrderByDescending(d => d.CreatedAt) .Select(d => new DictResponceDto - { - Id = d.Id, - PostId = d.PostId, - Phrase = d.Phrase, - Description = d.Description, - AnchorId = d.AnchorId, - CreatedAt = d.CreatedAt - }) + { + Id = d.Id, + PostId = d.PostId, + PostTitle = d.Post != null ? d.Post.Title : string.Empty, + Phrase = d.Phrase, + Description = d.Description, + AnchorId = d.AnchorId, + CreatedAt = d.CreatedAt + }) .ToListAsync(); return (true, phrases, null); - } - - - - } - - } - +} \ No newline at end of file diff --git a/Versum/Services/IDictService.cs b/Versum/Services/IDictService.cs index 2040dcb..0d5e8e2 100644 --- a/Versum/Services/IDictService.cs +++ b/Versum/Services/IDictService.cs @@ -5,8 +5,8 @@ namespace Versum.Services public interface IDictService { - Task<(bool Success, string? Error)> AddPhraseAsync(int userId, int postId, DictDto dto); - Task<(bool Success,List?, string? Error)> GetPhraseAsync(int userId); + Task<(bool Success, string? Error)> AddPhraseAsync(int userId, DictDto dto); + Task<(bool Success,List?, string? Error)> GetDictionaryAsync(int userId); Task<(bool Success, string? Error)> DeletePhraseAsync(int userId,DeletePhraseDto dto); } diff --git "a/Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" "b/Viktoria - \320\257\321\200\320\273\320\270\320\272.lnk" deleted file mode 100644 index 671fd11e476a5a4a65599eb0df0b0febd012518f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 889 zcmah{ZAg<*6h5zeqYuFk<|L$=1r7_Qo4T=$t#>+en;9;~!eD`;w3ZuNn@Q0J^hcN( zA+hjBgvDz9D99j!KExjx{fITf0!t_&D6pJEqJBvBoQ;`3g3jf6?tRZW_c{06_jVAG zP*DUXn#wV?U8D_EhV`y-^E2PbdqHx}>{ouB6ULt_&QE`t6UdrR6!Oz5o5@5^0fiUd+oW^%1!LFv$kQ5P#$Ktlqy0IbS$J;1gbkOMMc=&JOHbvoT- zGkHDHo=DU?;EVbLMCDY6;r#~>kdLC|r$DZ$bwpz;FcW0BOfkOCmZp{j=gam%$D5hG zV&*c(x&f3N>%zm_K*1BEp@0zFYY!tI6f2HM#Xzz}l@E_sAN&tr>51Qzq2)LSR z$%eb;l)S|-vB;;PuC^x~2HWFh`4!b@FcpMf}3ANl%R!dVF!5 zXJgLWKpG4+Vp{AlV5b53`E1tWsFq9Q>>8L4qRG}g%-E~SZe@$Qhp*4yseykJXYfJL zHXXnZ>7#xMp(8(j5Ss!h<%N}L@n`PwvG!^U9NjJ&#AdNY)QM)%B5K5HlE9K^0`opr zDdB|`C_FU&-Usm6ZXZ8fYl*)ufAE&3I%hYhxxzM1;InZBzFY5_`}We`C&YPdMu5d8 sDub Date: Mon, 25 May 2026 00:05:27 +0300 Subject: [PATCH 57/67] [FIX] Notifications: Now not only realtime --- Versum/Controllers/NotificationsController.cs | 57 +++ Versum/DbContext/ApplicationDbContext.cs | 1 + Versum/Dtos/NotificationDto.cs | 12 + Versum/Extensions/NotificationExtensions.cs | 26 ++ Versum/Hubs/NotificationHub.cs | 15 - ...0524205600_Added Notifications.Designer.cs | 427 ++++++++++++++++++ .../20260524205600_Added Notifications.cs | 41 ++ .../ApplicationDbContextModelSnapshot.cs | 34 ++ Versum/Models/Notification.cs | 13 + Versum/Services/INotificationService.cs | 6 +- Versum/Services/NotificationService.cs | 61 ++- 11 files changed, 672 insertions(+), 21 deletions(-) create mode 100644 Versum/Controllers/NotificationsController.cs create mode 100644 Versum/Dtos/NotificationDto.cs create mode 100644 Versum/Extensions/NotificationExtensions.cs create mode 100644 Versum/Migrations/20260524205600_Added Notifications.Designer.cs create mode 100644 Versum/Migrations/20260524205600_Added Notifications.cs create mode 100644 Versum/Models/Notification.cs diff --git a/Versum/Controllers/NotificationsController.cs b/Versum/Controllers/NotificationsController.cs new file mode 100644 index 0000000..c1cd4f2 --- /dev/null +++ b/Versum/Controllers/NotificationsController.cs @@ -0,0 +1,57 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Versum.Context; +using Versum.Services; + +namespace Versum.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class NotificationsController : ControllerBase + { + + private readonly INotificationService _notificationService; + public NotificationsController(INotificationService notificationService) + { + _notificationService = notificationService; + } + + [HttpGet] + [Authorize] + public async Task>> GetMyNotifications() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + var notifications = await _notificationService.GetNotificationsAsync(userId); + return Ok(notifications); + } + + [HttpPut("{id}/read")] + [Authorize] + public async Task ReadNotification([FromRoute] int id) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _notificationService.ReadNotificationAsync(id, userId); + + if (!success) + { + if (error == "NotificationNotFound") + return NotFound(new { message = "Сповіщення не знайдено" }); + return BadRequest(new { message = error }); + } + + return Ok(new { message = "Сповіщення прочитано..." }); + } + } +} diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 30ebc13..d25a6ed 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -17,6 +17,7 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Genres { get; set; } public DbSet Follows { get; set; } = null!; public DbSet Dictionary { get; set; } = null!; + public DbSet Notifications { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/Versum/Dtos/NotificationDto.cs b/Versum/Dtos/NotificationDto.cs new file mode 100644 index 0000000..c010dfc --- /dev/null +++ b/Versum/Dtos/NotificationDto.cs @@ -0,0 +1,12 @@ +namespace Versum.Dtos +{ + public class NotificationDto + { + public int Id { get; set; } + public string Type { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string ActorUsername { get; set; } = string.Empty; + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/Versum/Extensions/NotificationExtensions.cs b/Versum/Extensions/NotificationExtensions.cs new file mode 100644 index 0000000..c3ec7f7 --- /dev/null +++ b/Versum/Extensions/NotificationExtensions.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Versum.Core.Enums; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Extensions; + +public static class NotificationExtensions +{ + public static IQueryable NotificationsToDto(this IQueryable query) + { + return query.Select(p => NotificationToDto(p)); + } + public static NotificationDto NotificationToDto(this Notification n) + { + return new NotificationDto + { + Id = n.Id, + Type = n.Type, + Message = n.Message ?? "щось", + ActorUsername = n.ActorUsername ?? "хтось", + IsRead = n.IsRead, + CreatedAt = n.CreatedAt + }; + } +} \ No newline at end of file diff --git a/Versum/Hubs/NotificationHub.cs b/Versum/Hubs/NotificationHub.cs index b7edcc2..b431dbe 100644 --- a/Versum/Hubs/NotificationHub.cs +++ b/Versum/Hubs/NotificationHub.cs @@ -3,24 +3,9 @@ namespace Versum.Hubs { - public class NotificationMessage - { - public string Id { get; set; } = Guid.NewGuid().ToString(); - public string Type { get; set; } = ""; - public string Message { get; set; } = ""; - public string ActorUsername { get; set; } = ""; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public bool IsRead { get; set; } = false; - } - [Authorize] public class NotificationHub : Hub { - public async Task SendNotificationToUser(string userId, NotificationMessage notification) - { - await Clients.User(userId).SendAsync("ReceiveNotification", notification); - } - public override async Task OnConnectedAsync() { Console.WriteLine($"User Connected: {Context.UserIdentifier} with ConnectionId: {Context.ConnectionId}"); diff --git a/Versum/Migrations/20260524205600_Added Notifications.Designer.cs b/Versum/Migrations/20260524205600_Added Notifications.Designer.cs new file mode 100644 index 0000000..8b315c2 --- /dev/null +++ b/Versum/Migrations/20260524205600_Added Notifications.Designer.cs @@ -0,0 +1,427 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260524205600_Added Notifications")] + partial class AddedNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260524205600_Added Notifications.cs b/Versum/Migrations/20260524205600_Added Notifications.cs new file mode 100644 index 0000000..dee17c0 --- /dev/null +++ b/Versum/Migrations/20260524205600_Added Notifications.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddedNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TargetUserId = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "text", nullable: false), + Message = table.Column(type: "text", nullable: false), + ActorUsername = table.Column(type: "text", nullable: false), + IsRead = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 69cab0b..f9911b6 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -142,6 +142,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Follows"); }); + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + modelBuilder.Entity("Versum.Post", b => { b.Property("Id") diff --git a/Versum/Models/Notification.cs b/Versum/Models/Notification.cs new file mode 100644 index 0000000..5029ce4 --- /dev/null +++ b/Versum/Models/Notification.cs @@ -0,0 +1,13 @@ +namespace Versum.Models +{ + public class Notification + { + public int Id { get; set; } + public int TargetUserId { get; set; } + public string Type { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string ActorUsername { get; set; } = string.Empty; + public bool IsRead { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Versum/Services/INotificationService.cs b/Versum/Services/INotificationService.cs index 4e72f45..2d6afc5 100644 --- a/Versum/Services/INotificationService.cs +++ b/Versum/Services/INotificationService.cs @@ -1,4 +1,8 @@ -public interface INotificationService +using Versum.Dtos; + +public interface INotificationService { Task SendFollowNotificationAsync(int targetUserId, string actorUsername); + Task?> GetNotificationsAsync(int userId); + Task<(bool Success, string? Error)> ReadNotificationAsync(int id, int userId); } \ No newline at end of file diff --git a/Versum/Services/NotificationService.cs b/Versum/Services/NotificationService.cs index c427821..ec36736 100644 --- a/Versum/Services/NotificationService.cs +++ b/Versum/Services/NotificationService.cs @@ -1,28 +1,79 @@ using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using System.Collections; +using Versum.Context; +using Versum.Dtos; using Versum.Hubs; +using Versum.Models; +using Versum.Extensions; namespace Versum.Services { public class NotificationService : INotificationService { private readonly IHubContext _hubContext; + private readonly ApplicationDbContext _db; - public NotificationService(IHubContext hubContext) + public NotificationService(IHubContext hubContext, ApplicationDbContext db) { _hubContext = hubContext; + _db = db; } public async Task SendFollowNotificationAsync(int targetUserId, string actorUsername) { - var notification = new NotificationMessage + var notification = new Notification { + TargetUserId = targetUserId, Type = "Follower", Message = $"{actorUsername} почав(ла) читати вас.", - ActorUsername = actorUsername + ActorUsername = actorUsername, + IsRead = false, + CreatedAt = DateTime.UtcNow }; + _db.Notifications.Add(notification); + await _db.SaveChangesAsync(); + await _hubContext.Clients.User(targetUserId.ToString()) - .SendAsync("ReceiveNotification", notification); + .SendAsync("ReceiveNotification", notification.NotificationToDto()); + } + + public async Task?> GetNotificationsAsync(int userId) + { + return await _db.Notifications + .AsNoTracking() + .Where(n => n.TargetUserId == userId) + .OrderByDescending(n => n.Id) + .NotificationsToDto() + .ToListAsync(); + } + public async Task<(bool Success, string? Error)> ReadNotificationAsync(int id, int userId) + { + try + { + + var notification = await _db.Notifications.FirstOrDefaultAsync(n => n.Id == id); + + if (notification == null) return (false, "NotificationNotFound"); + + if (notification.TargetUserId != userId) return (false, "It is not sent to you :("); + + if (notification.IsRead) return (false, "AlreadyRead"); + + + + notification.IsRead = true; + + await _db.SaveChangesAsync(); + + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"ReadNotificationAsync error: {ex.Message}"); + return (false, "ServerError"); + } } } -} +} \ No newline at end of file From 6b10e39f3c2918dc6a9e948c8952bfba0ab51d26 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 25 May 2026 11:39:41 +0300 Subject: [PATCH 58/67] [Feature]Like and Comment Post (#39) --- Versum/Controllers/CommentsController.cs | 79 +++ Versum/DbContext/ApplicationDbContext.cs | 31 +- Versum/Dtos/CommentDto.cs | 7 + Versum/Dtos/CommentGetDto.cs | 11 + Versum/Dtos/PostGetDto.cs | 3 + ...0524214454_AddLikesAndComments.Designer.cs | 536 ++++++++++++++++++ .../20260524214454_AddLikesAndComments.cs | 124 ++++ .../ApplicationDbContextModelSnapshot.cs | 109 ++++ Versum/Models/Comment.cs | 16 + Versum/Models/Like.cs | 11 + Versum/Models/Post.cs | 7 +- Versum/Program.cs | 1 + Versum/Services/CommentLikeService.cs | 93 +++ Versum/Services/ICommentLikeService.cs | 11 + 14 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 Versum/Controllers/CommentsController.cs create mode 100644 Versum/Dtos/CommentDto.cs create mode 100644 Versum/Dtos/CommentGetDto.cs create mode 100644 Versum/Migrations/20260524214454_AddLikesAndComments.Designer.cs create mode 100644 Versum/Migrations/20260524214454_AddLikesAndComments.cs create mode 100644 Versum/Models/Comment.cs create mode 100644 Versum/Models/Like.cs create mode 100644 Versum/Services/CommentLikeService.cs create mode 100644 Versum/Services/ICommentLikeService.cs diff --git a/Versum/Controllers/CommentsController.cs b/Versum/Controllers/CommentsController.cs new file mode 100644 index 0000000..a7c6251 --- /dev/null +++ b/Versum/Controllers/CommentsController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Versum.Dtos; +using Versum.Services; + +namespace Versum.Controllers +{ + [ApiController] + [Route("api/posts")] + public class CommentsController : ControllerBase + { + private readonly ICommentLikeService _commentLikeService; + + public CommentsController(ICommentLikeService commentLikeService) + { + _commentLikeService = commentLikeService; + } + + [HttpPost("{postId}/like")] + [Authorize] + public async Task ToggleLike(int postId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized(); + + var (success, error) = await _commentLikeService.ToggleLikeAsync(userId, postId); + if (!success) + { + if (error == "PostNotFound") return NotFound(new { message = "Твір не знайдено" }); + return BadRequest(new { message = error }); + } + return Ok(new { message = "Лайк оновлено" }); + } + + [HttpGet("{postId}/comments")] + public async Task GetComments(int postId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + int.TryParse(userIdClaim, out int userId); + + var comments = await _commentLikeService.GetCommentsAsync(postId, userId == 0 ? null : userId); + return Ok(comments); + } + + [HttpPost("{postId}/comments")] + [Authorize] + public async Task AddComment(int postId, [FromBody] CommentDto dto) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized(); + + var (success, error) = await _commentLikeService.AddCommentAsync(userId, postId, dto); + if (!success) + { + if (error == "PostNotFound") return NotFound(new { message = "Твір не знайдено" }); + return BadRequest(new { message = error }); + } + return StatusCode(201, new { message = "Коментар додано" }); + } + + [HttpPost("comments/{commentId}/delete")] + [Authorize] + public async Task DeleteComment(int commentId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized(); + + var (success, error) = await _commentLikeService.DeleteCommentAsync(userId, commentId); + if (!success) + { + if (error == "CommentNotFound") return NotFound(new { message = "Коментар не знайдено" }); + if (error == "NotOwner") return Forbid(); + return BadRequest(new { message = error }); + } + return Ok(new { message = "Коментар видалено" }); + } + } +} \ No newline at end of file diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index d25a6ed..b293026 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -18,7 +18,8 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Follows { get; set; } = null!; public DbSet Dictionary { get; set; } = null!; public DbSet Notifications { get; set; } = null!; - + public DbSet Likes { get; set; } = null!; + public DbSet Comments { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -62,6 +63,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) .IsUnique(); + + modelBuilder.Entity() + .HasIndex(l => new { l.UserId, l.PostId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(l => l.Post) + .WithMany(p => p.Likes) + .HasForeignKey(l => l.PostId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(l => l.User) + .WithMany() + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(c => c.Post) + .WithMany(p => p.Comments) + .HasForeignKey(c => c.PostId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(c => c.User) + .WithMany() + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/Versum/Dtos/CommentDto.cs b/Versum/Dtos/CommentDto.cs new file mode 100644 index 0000000..11668ff --- /dev/null +++ b/Versum/Dtos/CommentDto.cs @@ -0,0 +1,7 @@ +namespace Versum.Dtos +{ + public class CommentDto + { + public string Content { get; set; } = string.Empty; + } +} diff --git a/Versum/Dtos/CommentGetDto.cs b/Versum/Dtos/CommentGetDto.cs new file mode 100644 index 0000000..aee884d --- /dev/null +++ b/Versum/Dtos/CommentGetDto.cs @@ -0,0 +1,11 @@ +namespace Versum.Dtos +{ + public class CommentGetDto + { + public int Id { get; set; } + public string Content { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public bool IsOwner { get; set; } + } +} diff --git a/Versum/Dtos/PostGetDto.cs b/Versum/Dtos/PostGetDto.cs index 6c1bb16..cce9106 100644 --- a/Versum/Dtos/PostGetDto.cs +++ b/Versum/Dtos/PostGetDto.cs @@ -14,6 +14,9 @@ public class PostGetDto public string Username { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public ICollection Genres { get; set; } = new List(); + public int LikesCount { get; set; } + public int CommentsCount { get; set; } + public bool IsLikedByUser { get; set; } } } \ No newline at end of file diff --git a/Versum/Migrations/20260524214454_AddLikesAndComments.Designer.cs b/Versum/Migrations/20260524214454_AddLikesAndComments.Designer.cs new file mode 100644 index 0000000..3b59186 --- /dev/null +++ b/Versum/Migrations/20260524214454_AddLikesAndComments.Designer.cs @@ -0,0 +1,536 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260524214454_AddLikesAndComments")] + partial class AddLikesAndComments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("Likes"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CommentsCount") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260524214454_AddLikesAndComments.cs b/Versum/Migrations/20260524214454_AddLikesAndComments.cs new file mode 100644 index 0000000..8ce365b --- /dev/null +++ b/Versum/Migrations/20260524214454_AddLikesAndComments.cs @@ -0,0 +1,124 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class AddLikesAndComments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CommentsCount", + table: "Posts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LikesCount", + table: "Posts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Comments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + PostId = table.Column(type: "integer", nullable: false), + Content = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Comments", x => x.Id); + table.ForeignKey( + name: "FK_Comments_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Comments_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Likes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + PostId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Likes", x => x.Id); + table.ForeignKey( + name: "FK_Likes_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Likes_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Comments_PostId", + table: "Comments", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_Comments_UserId", + table: "Comments", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Likes_PostId", + table: "Likes", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_Likes_UserId_PostId", + table: "Likes", + columns: new[] { "UserId", "PostId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Comments"); + + migrationBuilder.DropTable( + name: "Likes"); + + migrationBuilder.DropColumn( + name: "CommentsCount", + table: "Posts"); + + migrationBuilder.DropColumn( + name: "LikesCount", + table: "Posts"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index f9911b6..86a7407 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -69,6 +69,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Authors"); }); + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + modelBuilder.Entity("Versum.Models.Dictionary", b => { b.Property("Id") @@ -142,6 +176,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Follows"); }); + modelBuilder.Entity("Versum.Models.Like", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("Likes"); + }); + modelBuilder.Entity("Versum.Models.Notification", b => { b.Property("Id") @@ -187,6 +245,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuthorId") .HasColumnType("integer"); + b.Property("CommentsCount") + .HasColumnType("integer"); + b.Property("Content") .IsRequired() .HasMaxLength(500000) @@ -206,6 +267,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsDraft") .HasColumnType("boolean"); + b.Property("LikesCount") + .HasColumnType("integer"); + b.Property("Title") .IsRequired() .HasMaxLength(100) @@ -336,6 +400,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Versum.Models.Dictionary", b => { b.HasOne("Versum.Post", "Post") @@ -376,6 +459,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Following"); }); + modelBuilder.Entity("Versum.Models.Like", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Versum.Post", b => { b.HasOne("Versum.Models.Author", "Author") @@ -407,6 +509,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Posts"); }); + modelBuilder.Entity("Versum.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + modelBuilder.Entity("Versum.User", b => { b.Navigation("AuthorProfile"); diff --git a/Versum/Models/Comment.cs b/Versum/Models/Comment.cs new file mode 100644 index 0000000..cdca440 --- /dev/null +++ b/Versum/Models/Comment.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Models +{ + public class Comment + { + public int Id { get; set; } + public int UserId { get; set; } + public User User { get; set; } = null!; + public int PostId { get; set; } + public Post Post { get; set; } = null!; + [MaxLength(2000)] public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsDeleted { get; set; } = false; + } +} diff --git a/Versum/Models/Like.cs b/Versum/Models/Like.cs new file mode 100644 index 0000000..8ad78e2 --- /dev/null +++ b/Versum/Models/Like.cs @@ -0,0 +1,11 @@ +namespace Versum.Models +{ + public class Like + { + public int Id { get; set; } + public int UserId { get; set; } + public User User { get; set; } = null!; + public int PostId { get; set; } + public Post Post { get; set; } = null!; + } +} diff --git a/Versum/Models/Post.cs b/Versum/Models/Post.cs index b6a4752..2b668a2 100644 --- a/Versum/Models/Post.cs +++ b/Versum/Models/Post.cs @@ -15,6 +15,11 @@ public class Post public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public bool IsDraft { get; set; } public bool IsDeleted { get; set; } = false; - + + public int LikesCount { get; set; } = 0; + public int CommentsCount { get; set; } = 0; + public ICollection Likes { get; set; } = new List(); + public ICollection Comments { get; set; } = new List(); + } } diff --git a/Versum/Program.cs b/Versum/Program.cs index 72d1317..3fba30a 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { diff --git a/Versum/Services/CommentLikeService.cs b/Versum/Services/CommentLikeService.cs new file mode 100644 index 0000000..284ebb8 --- /dev/null +++ b/Versum/Services/CommentLikeService.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Services +{ + public class CommentLikeService : ICommentLikeService + { + private readonly ApplicationDbContext _db; + + public CommentLikeService(ApplicationDbContext db) + { + _db = db; + } + + public async Task<(bool success, string? error)> ToggleLikeAsync(int userId, int postId) + { + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId && !p.IsDeleted && !p.IsDraft); + if (post == null) return (false, "PostNotFound"); + + var existing = await _db.Likes + .FirstOrDefaultAsync(l => l.UserId == userId && l.PostId == postId); + + if (existing != null) + { + _db.Likes.Remove(existing); + if (post.LikesCount > 0) post.LikesCount--; + } + else + { + _db.Likes.Add(new Like { UserId = userId, PostId = postId }); + post.LikesCount++; + } + + await _db.SaveChangesAsync(); + return (true, null); + } + + public async Task> GetCommentsAsync(int postId, int? userId) + { + return await _db.Comments + .Where(c => c.PostId == postId && !c.IsDeleted) + .OrderByDescending(c => c.CreatedAt) + .Select(c => new CommentGetDto + { + Id = c.Id, + Content = c.Content, + Username = c.User.Username, + CreatedAt = c.CreatedAt, + IsOwner = c.UserId == userId + }) + .ToListAsync(); + } + + public async Task<(bool success, string? error)> AddCommentAsync(int userId, int postId, CommentDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Content)) + return (false, "ContentRequired"); + + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId && !p.IsDeleted && !p.IsDraft); + if (post == null) return (false, "PostNotFound"); + + _db.Comments.Add(new Comment + { + UserId = userId, + PostId = postId, + Content = dto.Content, + CreatedAt = DateTime.UtcNow + }); + + post.CommentsCount++; + await _db.SaveChangesAsync(); + return (true, null); + } + + public async Task<(bool success, string? error)> DeleteCommentAsync(int userId, int commentId) + { + var comment = await _db.Comments + .Include(c => c.Post) + .FirstOrDefaultAsync(c => c.Id == commentId && !c.IsDeleted); + + if (comment == null) return (false, "CommentNotFound"); + if (comment.UserId != userId) return (false, "NotOwner"); + + comment.IsDeleted = true; + if (comment.Post.CommentsCount > 0) comment.Post.CommentsCount--; + + await _db.SaveChangesAsync(); + return (true, null); + } + } +} \ No newline at end of file diff --git a/Versum/Services/ICommentLikeService.cs b/Versum/Services/ICommentLikeService.cs new file mode 100644 index 0000000..d37035c --- /dev/null +++ b/Versum/Services/ICommentLikeService.cs @@ -0,0 +1,11 @@ +using Versum.Dtos; +namespace Versum.Services +{ + public interface ICommentLikeService + { + Task<(bool success, string? error)> ToggleLikeAsync(int userId, int postId); + Task> GetCommentsAsync(int postId, int? userId); + Task<(bool success, string? error)> AddCommentAsync(int userId, int postId, CommentDto dto); + Task<(bool success, string? error)> DeleteCommentAsync(int userId, int commentId); + } +} From 48ad85f785f30426df269cddb3848f361c126de1 Mon Sep 17 00:00:00 2001 From: ViktoriaVamp Date: Mon, 25 May 2026 11:45:22 +0300 Subject: [PATCH 59/67] [FEATURE] Implemented Savings logic (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Вікторія Свирид Co-authored-by: NotAsasha <48174753+NotAsasha@users.noreply.github.com> --- Versum/Controllers/PostsController.cs | 7 +- Versum/Controllers/SavingsController.cs | 96 ++++ Versum/DbContext/ApplicationDbContext.cs | 6 + Versum/Dtos/SavingsResponseDto.cs | 9 + ...260524192147_Add savings table.Designer.cs | 432 ++++++++++++++++++ .../20260524192147_Add savings table.cs | 56 +++ ...0525082447_ChangedSavingsTable.Designer.cs | 432 ++++++++++++++++++ .../20260525082447_ChangedSavingsTable.cs | 41 ++ .../ApplicationDbContextModelSnapshot.cs | 22 + Versum/Models/Savings.cs | 12 + Versum/Program.cs | 1 + Versum/Services/ISavingsService.cs | 11 + Versum/Services/PostService.cs | 3 - Versum/Services/SavingsService.cs | 90 ++++ 14 files changed, 1209 insertions(+), 9 deletions(-) create mode 100644 Versum/Controllers/SavingsController.cs create mode 100644 Versum/Dtos/SavingsResponseDto.cs create mode 100644 Versum/Migrations/20260524192147_Add savings table.Designer.cs create mode 100644 Versum/Migrations/20260524192147_Add savings table.cs create mode 100644 Versum/Migrations/20260525082447_ChangedSavingsTable.Designer.cs create mode 100644 Versum/Migrations/20260525082447_ChangedSavingsTable.cs create mode 100644 Versum/Models/Savings.cs create mode 100644 Versum/Services/ISavingsService.cs create mode 100644 Versum/Services/SavingsService.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index a097f8d..fc8fad2 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -1,12 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; using System.Security.Claims; -using Versum.Core.Enums; using Versum.Dtos; -using Versum.Hubs; -using Versum.Context; using Versum.Services; @@ -98,7 +93,7 @@ public async Task UpdateDraft(int postId, [FromBody] PostDto dto) if (!success) { if (error == "DraftNotFound") return NotFound(new { message = "Чернетку не знайдено" }); - if (error == "You can't edit published writings") return NotFound(new { message = "Твір уже опубліковано" }); + if (error == "You can't edit published writings") return Conflict(new { message = "Твір уже опубліковано" }); if (error == "YouAreNotAnOwnerOfDraft") return NotFound(new { message = "Ви нє автором чернетки" }); return BadRequest(new { message = error }); } diff --git a/Versum/Controllers/SavingsController.cs b/Versum/Controllers/SavingsController.cs new file mode 100644 index 0000000..2f3062c --- /dev/null +++ b/Versum/Controllers/SavingsController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Versum.Dtos; +using Versum.Services; + + +namespace Versum.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class SavingsController : ControllerBase + { + + private readonly ISavingsService _savingsService; + + + public SavingsController(ISavingsService savingsService) + { + + _savingsService = savingsService; + + } + + + [HttpPost("{postId}/save-post")] + [Authorize] + public async Task SavePost(int postId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _savingsService.SavePostAsync(postId, userId); + if (!success) + { + if (error == "PostNotFound") return NotFound(new { message = "Твір не знайдено" }); + if (error == "PostIsSaved") return Conflict(new { message = "Твір уже збережено"}); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Твір успішно збережено" + }); + + } + + [HttpPost("{postId}/unsave-post")] + [Authorize] + public async Task UnsavePost(int postId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _savingsService.UnSavePostAsync(postId, userId); + if (!success) + { + if (error == "PostNotFound") return NotFound(new { message = "Твір не є серед збережених" }); + return BadRequest(new { message = error }); + } + + return Ok( new + { message = "Твір успішно видалено зі збережених" } + ); + + } + + + [HttpGet("get-posts")] + [Authorize] + public async Task GetSavedPosts() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success,savings, error) = await _savingsService.GetSavedPostAsync(userId); + if (!success) + { + return BadRequest(new { message = error }); + } + + return Ok(savings); + + + } + } +} diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index b293026..4a06683 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -17,6 +17,7 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Genres { get; set; } public DbSet Follows { get; set; } = null!; public DbSet Dictionary { get; set; } = null!; + public DbSet Savings { get; set; } = null!; public DbSet Notifications { get; set; } = null!; public DbSet Likes { get; set; } = null!; public DbSet Comments { get; set; } = null!; @@ -64,6 +65,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) .IsUnique(); + modelBuilder.Entity() + .HasKey(s => new { s.UserId, s.PostId }); + + modelBuilder.Entity() + .HasIndex(s => new { s.UserId, s.PostId }); modelBuilder.Entity() .HasIndex(l => new { l.UserId, l.PostId }) .IsUnique(); diff --git a/Versum/Dtos/SavingsResponseDto.cs b/Versum/Dtos/SavingsResponseDto.cs new file mode 100644 index 0000000..d6b3007 --- /dev/null +++ b/Versum/Dtos/SavingsResponseDto.cs @@ -0,0 +1,9 @@ +namespace Versum.Dtos +{ + public class SavingsResponseDto + { + public int UserId { get; set; } + public int PostId { get; set; } + public DateTime SavedAt { get; set; } + } +} diff --git a/Versum/Migrations/20260524192147_Add savings table.Designer.cs b/Versum/Migrations/20260524192147_Add savings table.Designer.cs new file mode 100644 index 0000000..9f98f35 --- /dev/null +++ b/Versum/Migrations/20260524192147_Add savings table.Designer.cs @@ -0,0 +1,432 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260524192147_Add savings table")] + partial class Addsavingstable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "Phrase") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("Id") + .HasColumnType("integer"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260524192147_Add savings table.cs b/Versum/Migrations/20260524192147_Add savings table.cs new file mode 100644 index 0000000..51dca26 --- /dev/null +++ b/Versum/Migrations/20260524192147_Add savings table.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Addsavingstable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Savings", + columns: table => new + { + UserId = table.Column(type: "integer", nullable: false), + PostId = table.Column(type: "integer", nullable: false), + Id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Savings", x => new { x.UserId, x.PostId }); + table.ForeignKey( + name: "FK_Savings_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Savings_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Savings_PostId", + table: "Savings", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_Savings_UserId_PostId", + table: "Savings", + columns: new[] { "UserId", "PostId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Savings"); + } + } +} diff --git a/Versum/Migrations/20260525082447_ChangedSavingsTable.Designer.cs b/Versum/Migrations/20260525082447_ChangedSavingsTable.Designer.cs new file mode 100644 index 0000000..b5e2fe0 --- /dev/null +++ b/Versum/Migrations/20260525082447_ChangedSavingsTable.Designer.cs @@ -0,0 +1,432 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525082447_ChangedSavingsTable")] + partial class ChangedSavingsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "Phrase") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525082447_ChangedSavingsTable.cs b/Versum/Migrations/20260525082447_ChangedSavingsTable.cs new file mode 100644 index 0000000..7f1a3cc --- /dev/null +++ b/Versum/Migrations/20260525082447_ChangedSavingsTable.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class ChangedSavingsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Id", + table: "Savings"); + + migrationBuilder.AddColumn( + name: "SavedAt", + table: "Savings", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SavedAt", + table: "Savings"); + + migrationBuilder.AddColumn( + name: "Id", + table: "Savings", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 86a7407..a40f209 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -176,6 +176,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Follows"); }); + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); modelBuilder.Entity("Versum.Models.Like", b => { b.Property("Id") @@ -459,6 +477,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Following"); }); + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() modelBuilder.Entity("Versum.Models.Like", b => { b.HasOne("Versum.Post", "Post") diff --git a/Versum/Models/Savings.cs b/Versum/Models/Savings.cs new file mode 100644 index 0000000..ed6d632 --- /dev/null +++ b/Versum/Models/Savings.cs @@ -0,0 +1,12 @@ +namespace Versum.Models +{ + public class Savings + { + + public int UserId { get; set; } + public int PostId { get; set; } + public virtual User User { get; set; } = null!; + public virtual Post Post { get; set; } = null!; + public DateTime SavedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Versum/Program.cs b/Versum/Program.cs index 3fba30a..5c2836e 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication(options => diff --git a/Versum/Services/ISavingsService.cs b/Versum/Services/ISavingsService.cs new file mode 100644 index 0000000..eb76ddf --- /dev/null +++ b/Versum/Services/ISavingsService.cs @@ -0,0 +1,11 @@ +using Versum.Dtos; + +namespace Versum.Services +{ + public interface ISavingsService + { + Task<(bool Success, string? Error)> SavePostAsync(int postId, int userId); + Task<(bool Success, string? Error)> UnSavePostAsync(int postId,int userId); + Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId); + } +} diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 0a45d59..1a535a9 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -1,8 +1,5 @@ using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; using Versum.Dtos; -using Versum.Models; -using Versum.Core.Enums; using Versum.Extensions; using Versum.Context; using Ganss.Xss; diff --git a/Versum/Services/SavingsService.cs b/Versum/Services/SavingsService.cs new file mode 100644 index 0000000..36e616b --- /dev/null +++ b/Versum/Services/SavingsService.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; + + +namespace Versum.Services +{ + public class SavingsService : ISavingsService + { + + private readonly ApplicationDbContext _db; + + public SavingsService(ApplicationDbContext db) + { + _db = db; + } + + + public async Task<(bool Success, string? Error)> SavePostAsync(int postId, int userId) + { + + var post = await _db.Posts.AnyAsync(p => p.Id == postId && !p.IsDraft && !p.IsDeleted); + if (!post) return (false, "PostNotFound"); + + var alreadySaved = await _db.Savings.AnyAsync(s => s.UserId == userId && s.PostId == postId); + if (alreadySaved) return (false, "PostIsSaved"); + + var saved = new Savings + { + UserId = userId, + PostId = postId, + SavedAt = DateTime.UtcNow + + }; + + _db.Savings.Add(saved); + try + { + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"SavePostAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + public async Task<(bool Success, string? Error)> UnSavePostAsync(int postId, int userId) { + + var savings = await _db.Savings + .FirstOrDefaultAsync(s => s.UserId == userId && s.PostId == postId); + + if (savings == null) return (false,"PostNotFound"); + + _db.Savings.Remove(savings); + + try + { + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"UnSavePostAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + public async Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId) + { + var savings = await _db.Savings + .AsNoTracking() + .Where(s => s.UserId == userId).OrderByDescending(s => s.SavedAt) + .Select(s => new SavingsResponseDto + { + + UserId = s.UserId, + PostId = s.PostId, + SavedAt = s.SavedAt + + }) + .ToListAsync(); + + return (true, savings, null); + + } + + } +} From 57af278849e50dc3e271a30fbd7904cc08eebbd4 Mon Sep 17 00:00:00 2001 From: notasasha Date: Mon, 25 May 2026 11:56:25 +0300 Subject: [PATCH 60/67] [FIX] Merged migrations --- .../20260525084918_Merge .Designer.cs | 575 ++++++++++++++++++ Versum/Migrations/20260525084918_Merge .cs | 46 ++ .../ApplicationDbContextModelSnapshot.cs | 59 +- 3 files changed, 659 insertions(+), 21 deletions(-) create mode 100644 Versum/Migrations/20260525084918_Merge .Designer.cs create mode 100644 Versum/Migrations/20260525084918_Merge .cs diff --git a/Versum/Migrations/20260525084918_Merge .Designer.cs b/Versum/Migrations/20260525084918_Merge .Designer.cs new file mode 100644 index 0000000..7daacde --- /dev/null +++ b/Versum/Migrations/20260525084918_Merge .Designer.cs @@ -0,0 +1,575 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525084918_Merge ")] + partial class Merge + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.HasIndex("UserId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("Likes"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CommentsCount") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany() + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Followers") + .HasForeignKey("UserId"); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525084918_Merge .cs b/Versum/Migrations/20260525084918_Merge .cs new file mode 100644 index 0000000..06f7c8e --- /dev/null +++ b/Versum/Migrations/20260525084918_Merge .cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Merge : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Savings_Posts_PostId", + table: "Savings"); + + migrationBuilder.AddForeignKey( + name: "FK_Savings_Posts_PostId", + table: "Savings", + column: "PostId", + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Savings_Posts_PostId", + table: "Savings"); + + migrationBuilder.DropForeignKey( + name: "FK_Savings_Users_UserId", + table: "Savings"); + + migrationBuilder.AddForeignKey( + name: "FK_Savings_Posts_PostId", + table: "Savings", + column: "PostId", + principalTable: "Posts", + principalColumn: "Id"); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index a40f209..e575fe6 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -176,24 +176,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Follows"); }); - modelBuilder.Entity("Versum.Models.Savings", b => - { - b.Property("UserId") - .HasColumnType("integer"); - - b.Property("PostId") - .HasColumnType("integer"); - - b.Property("SavedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("UserId", "PostId"); - - b.HasIndex("PostId"); - - b.HasIndex("UserId", "PostId"); - - b.ToTable("Savings"); modelBuilder.Entity("Versum.Models.Like", b => { b.Property("Id") @@ -252,6 +234,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Notifications"); }); + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); + }); + modelBuilder.Entity("Versum.Post", b => { b.Property("Id") @@ -477,14 +479,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Following"); }); - modelBuilder.Entity("Versum.Models.Savings", b => + modelBuilder.Entity("Versum.Models.Like", b => { b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") .WithMany() - modelBuilder.Entity("Versum.Models.Like", b => + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => { b.HasOne("Versum.Post", "Post") - .WithMany("Likes") + .WithMany() .HasForeignKey("PostId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); From 6d6f7c6745dc6c6a3f7e0d285f9385ad21facbc3 Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 25 May 2026 20:51:30 +0300 Subject: [PATCH 61/67] [FEATURE] Implemented user feed generation (#40) * [FEATURE] Implemented user feed generation * [FIX] Megration conflicts fix * [FIX] Added includes * [REFACTOR] Post Reactions and Feed Service * [FIX] Authors don't see their own posts in feed --- Versum/Controllers/FeedController.cs | 35 + Versum/DbContext/ApplicationDbContext.cs | 22 +- Versum/Extensions/PostExtensions.cs | 31 +- .../20260525024223_PostReaction.Designer.cs | 467 +++++++++++++ .../Migrations/20260525024223_PostReaction.cs | 92 +++ .../20260525154409_Merge 2.Designer.cs | 615 ++++++++++++++++++ Versum/Migrations/20260525154409_Merge 2.cs | 20 + .../20260525163702_PostReactions2.Designer.cs | 473 ++++++++++++++ .../20260525163702_PostReactions2.cs | 80 +++ .../ApplicationDbContextModelSnapshot.cs | 66 +- Versum/Models/PostReaction.cs | 15 + Versum/Program.cs | 1 + Versum/Services/FeedService.cs | 101 +++ Versum/Services/IFeedService.cs | 9 + 14 files changed, 2002 insertions(+), 25 deletions(-) create mode 100644 Versum/Controllers/FeedController.cs create mode 100644 Versum/Migrations/20260525024223_PostReaction.Designer.cs create mode 100644 Versum/Migrations/20260525024223_PostReaction.cs create mode 100644 Versum/Migrations/20260525154409_Merge 2.Designer.cs create mode 100644 Versum/Migrations/20260525154409_Merge 2.cs create mode 100644 Versum/Migrations/20260525163702_PostReactions2.Designer.cs create mode 100644 Versum/Migrations/20260525163702_PostReactions2.cs create mode 100644 Versum/Models/PostReaction.cs create mode 100644 Versum/Services/FeedService.cs create mode 100644 Versum/Services/IFeedService.cs diff --git a/Versum/Controllers/FeedController.cs b/Versum/Controllers/FeedController.cs new file mode 100644 index 0000000..2501a97 --- /dev/null +++ b/Versum/Controllers/FeedController.cs @@ -0,0 +1,35 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Versum.Dtos; +using Versum.Services; + +namespace Versum.Controllers +{ + [Authorize] + [ApiController] + [Route("api/[controller]")] + public class FeedController : ControllerBase + { + private readonly IFeedService _feedService; + + public FeedController(IFeedService feedService) + { + _feedService = feedService; + } + + [HttpGet] + public async Task>> GetFeed([FromQuery] int limit = 20) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int currentUserId)) + { + return Unauthorized(); + } + + var feed = await _feedService.GetSmartFeedAsync(currentUserId, limit); + + return Ok(feed); + } + } +} \ No newline at end of file diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 4a06683..8c7b8fa 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -19,6 +19,8 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Dictionary { get; set; } = null!; public DbSet Savings { get; set; } = null!; public DbSet Notifications { get; set; } = null!; + public DbSet PostReactions { get; set; } = null!; + public DbSet Likes { get; set; } = null!; public DbSet Comments { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -57,10 +59,28 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(f => f.FollowerId); entity.HasOne(f => f.Following) - .WithMany() + .WithMany(u => u.Followers) .HasForeignKey(f => f.FollowingId); }); + modelBuilder.Entity(entity => + { + entity.HasKey(pr => pr.Id); + + // ЗМІНЕНО: тепер один юзер може мати лише один рядок взаємодії з одним конкретним постом + entity.HasIndex(pr => new { pr.UserId, pr.PostId }).IsUnique(); + + entity.HasOne(pr => pr.User) + .WithMany() + .HasForeignKey(pr => pr.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(pr => pr.Post) + .WithMany() + .HasForeignKey(pr => pr.PostId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity() .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) .IsUnique(); diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index 8bc4ce4..29a460a 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Linq.Expressions; using Versum.Core.Enums; using Versum.Dtos; using Versum.Models; @@ -45,22 +46,24 @@ private static IQueryable OrderByFieldDescending(this IQueryable que _ => query.OrderByDescending(p => p.Id) }; + public static Expression> AsPostGetDto => p => new PostGetDto + { + PostId = p.Id, + Title = p.Title, + Description = p.Description ?? "none", + Content = p.Content ?? "none", + CreatedAt = p.CreatedAt, + Username = p.Author.User.Username ?? "Unknown", + Name = p.Author.User.Profile.Name ?? "none", + Genres = p.Genres.Select(g => g.Name).ToList() + }; + public static IQueryable ProjectToPostDto(this IQueryable query) { - return query.Select(p => PostToPostGetDto(p)); + return query.Select(AsPostGetDto); } - public static PostGetDto PostToPostGetDto(this Post p) + public static PostGetDto PostToPostGetDto(this Post post) { - return new PostGetDto - { - PostId = p.Id, - Title = p.Title, - Description = p.Description ?? "none", - Content = p.Content ?? "none", - CreatedAt = p.CreatedAt, - Username = p.Author?.User?.Username ?? "Unknown", - Name = p.Author?.User?.Profile?.Name ?? "none", - Genres = p.Genres?.Select(g => g.Name).ToList() ?? new List() - }; + return AsPostGetDto.Compile()(post); } -} \ No newline at end of file +} diff --git a/Versum/Migrations/20260525024223_PostReaction.Designer.cs b/Versum/Migrations/20260525024223_PostReaction.Designer.cs new file mode 100644 index 0000000..c8baf10 --- /dev/null +++ b/Versum/Migrations/20260525024223_PostReaction.Designer.cs @@ -0,0 +1,467 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525024223_PostReaction")] + partial class PostReaction + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "Type") + .IsUnique(); + + b.ToTable("PostReactions"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany("Followers") + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525024223_PostReaction.cs b/Versum/Migrations/20260525024223_PostReaction.cs new file mode 100644 index 0000000..91f7ca1 --- /dev/null +++ b/Versum/Migrations/20260525024223_PostReaction.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class PostReaction : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Follows_Users_UserId", + table: "Follows"); + + migrationBuilder.DropIndex( + name: "IX_Follows_UserId", + table: "Follows"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Follows"); + + migrationBuilder.CreateTable( + name: "PostReactions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + PostId = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "integer", nullable: false), + ReactedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PostReactions", x => x.Id); + table.ForeignKey( + name: "FK_PostReactions_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PostReactions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PostReactions_PostId", + table: "PostReactions", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_PostReactions_UserId_PostId_Type", + table: "PostReactions", + columns: new[] { "UserId", "PostId", "Type" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PostReactions"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Follows", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Follows_UserId", + table: "Follows", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Follows_Users_UserId", + table: "Follows", + column: "UserId", + principalTable: "Users", + principalColumn: "Id"); + } + } +} diff --git a/Versum/Migrations/20260525154409_Merge 2.Designer.cs b/Versum/Migrations/20260525154409_Merge 2.Designer.cs new file mode 100644 index 0000000..45c6c17 --- /dev/null +++ b/Versum/Migrations/20260525154409_Merge 2.Designer.cs @@ -0,0 +1,615 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525154409_Merge 2")] + partial class Merge2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("Likes"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "Type") + .IsUnique(); + + b.ToTable("PostReactions"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId"); + + b.ToTable("Savings"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CommentsCount") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany("Followers") + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Savings", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525154409_Merge 2.cs b/Versum/Migrations/20260525154409_Merge 2.cs new file mode 100644 index 0000000..d9a4cfd --- /dev/null +++ b/Versum/Migrations/20260525154409_Merge 2.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Merge2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/Versum/Migrations/20260525163702_PostReactions2.Designer.cs b/Versum/Migrations/20260525163702_PostReactions2.Designer.cs new file mode 100644 index 0000000..f813e17 --- /dev/null +++ b/Versum/Migrations/20260525163702_PostReactions2.Designer.cs @@ -0,0 +1,473 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525163702_PostReactions2")] + partial class PostReactions2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsLiked") + .HasColumnType("boolean"); + + b.Property("LastInteractedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("PriorityScore") + .HasColumnType("real"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("PostReactions"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany("Followers") + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525163702_PostReactions2.cs b/Versum/Migrations/20260525163702_PostReactions2.cs new file mode 100644 index 0000000..183a412 --- /dev/null +++ b/Versum/Migrations/20260525163702_PostReactions2.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class PostReactions2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PostReactions_UserId_PostId_Type", + table: "PostReactions"); + + migrationBuilder.RenameColumn( + name: "Type", + table: "PostReactions", + newName: "ViewCount"); + + migrationBuilder.RenameColumn( + name: "ReactedAt", + table: "PostReactions", + newName: "LastInteractedAt"); + + migrationBuilder.AddColumn( + name: "IsLiked", + table: "PostReactions", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PriorityScore", + table: "PostReactions", + type: "real", + nullable: false, + defaultValue: 0f); + + migrationBuilder.CreateIndex( + name: "IX_PostReactions_UserId_PostId", + table: "PostReactions", + columns: new[] { "UserId", "PostId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PostReactions_UserId_PostId", + table: "PostReactions"); + + migrationBuilder.DropColumn( + name: "IsLiked", + table: "PostReactions"); + + migrationBuilder.DropColumn( + name: "PriorityScore", + table: "PostReactions"); + + migrationBuilder.RenameColumn( + name: "ViewCount", + table: "PostReactions", + newName: "Type"); + + migrationBuilder.RenameColumn( + name: "LastInteractedAt", + table: "PostReactions", + newName: "ReactedAt"); + + migrationBuilder.CreateIndex( + name: "IX_PostReactions_UserId_PostId_Type", + table: "PostReactions", + columns: new[] { "UserId", "PostId", "Type" }, + unique: true); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index e575fe6..47660d7 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -162,17 +162,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FollowingId") .HasColumnType("integer"); - b.Property("UserId") - .HasColumnType("integer"); - b.HasKey("Id"); b.HasIndex("FollowerId"); b.HasIndex("FollowingId"); - b.HasIndex("UserId"); - b.ToTable("Follows"); }); @@ -234,6 +229,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Notifications"); }); + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsLiked") + .HasColumnType("boolean"); + + b.Property("LastInteractedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("PriorityScore") + .HasColumnType("real"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("PostReactions"); + }); + modelBuilder.Entity("Versum.Models.Savings", b => { b.Property("UserId") @@ -465,15 +496,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); b.HasOne("Versum.User", "Following") - .WithMany() + .WithMany("Followers") .HasForeignKey("FollowingId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Versum.User", null) - .WithMany("Followers") - .HasForeignKey("UserId"); - b.Navigation("Follower"); b.Navigation("Following"); @@ -498,6 +525,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Versum.Models.Savings", b => { b.HasOne("Versum.Post", "Post") diff --git a/Versum/Models/PostReaction.cs b/Versum/Models/PostReaction.cs new file mode 100644 index 0000000..3cc6c66 --- /dev/null +++ b/Versum/Models/PostReaction.cs @@ -0,0 +1,15 @@ +namespace Versum.Models +{ + public class PostReaction + { + public int Id { get; set; } + public int UserId { get; set; } + public virtual User User { get; set; } = null!; + public int PostId { get; set; } + public virtual Post Post { get; set; } = null!; + public bool IsLiked { get; set; } = false; + public int ViewCount { get; set; } = 0; + public float PriorityScore { get; set; } = 0f; + public DateTime LastInteractedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Versum/Program.cs b/Versum/Program.cs index 5c2836e..7df1e68 100644 --- a/Versum/Program.cs +++ b/Versum/Program.cs @@ -45,6 +45,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { diff --git a/Versum/Services/FeedService.cs b/Versum/Services/FeedService.cs new file mode 100644 index 0000000..a9e7b67 --- /dev/null +++ b/Versum/Services/FeedService.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Extensions; +using Versum.Models; + +namespace Versum.Services +{ + public class FeedService : IFeedService + { + private readonly ApplicationDbContext _context; + + private const float BASE_PRIORITY = 100.0f; + private const float FOLLOW_BONUS = 20.0f; + private const float VIEW_PENALTY = 10.0f; + + public FeedService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetSmartFeedAsync(int currentUserId, int limit = 20, int skip = 0) + { + // Крок 1: Отримуємо тільки метадані + var metadata = await _context.Posts + .AsNoTracking() + .OnlyPublished() + .Where(p => p.AuthorId != currentUserId) // ВАЖЛИВО: Виключаємо власні твори користувача з рекомендацій + .Select(p => new + { + PostId = p.Id, + Reaction = _context.PostReactions.FirstOrDefault(pr => pr.PostId == p.Id && pr.UserId == currentUserId), + IsFollowed = _context.Follows.Any(f => f.FollowerId == currentUserId && f.FollowingId == p.AuthorId), + CreatedAt = p.CreatedAt + }) + .OrderByDescending(x => x.Reaction != null + ? x.Reaction.PriorityScore + : (BASE_PRIORITY + (x.IsFollowed ? FOLLOW_BONUS : 0.0f))) + .ThenByDescending(x => x.CreatedAt) + .Skip(skip) + .Take(limit) + .ToListAsync(); + + if (!metadata.Any()) + { + return new List(); + } + + // Крок 2: Збираємо ID відібраних постів + var postIds = metadata.Select(x => x.PostId).ToList(); + + // Крок 3: Витягуємо готові DTO прямо з бази + var dtos = await _context.Posts + .AsNoTracking() + .Where(p => postIds.Contains(p.Id)) + .ProjectToPostDto() + .ToListAsync(); + + // Сортуємо DTO + var feedDtos = postIds + .Select(id => dtos.First(d => d.PostId == id)) + .ToList(); + + // Крок 4: Динамічно оновлюємо рейтинги переглядів + var postsToUpdate = new List(); + var postsToAdd = new List(); + + foreach (var item in metadata) + { + if (item.Reaction == null) + { + float initialScore = BASE_PRIORITY + (item.IsFollowed ? FOLLOW_BONUS : 0.0f); + + postsToAdd.Add(new PostReaction + { + UserId = currentUserId, + PostId = item.PostId, + ViewCount = 1, + PriorityScore = initialScore - VIEW_PENALTY, + LastInteractedAt = DateTime.UtcNow + }); + } + else + { + item.Reaction.ViewCount += 1; + item.Reaction.PriorityScore -= VIEW_PENALTY; + item.Reaction.LastInteractedAt = DateTime.UtcNow; + + postsToUpdate.Add(item.Reaction); + } + } + + if (postsToAdd.Any()) _context.PostReactions.AddRange(postsToAdd); + if (postsToUpdate.Any()) _context.PostReactions.UpdateRange(postsToUpdate); + + await _context.SaveChangesAsync(); + + return feedDtos; + } + } +} \ No newline at end of file diff --git a/Versum/Services/IFeedService.cs b/Versum/Services/IFeedService.cs new file mode 100644 index 0000000..efcfd6f --- /dev/null +++ b/Versum/Services/IFeedService.cs @@ -0,0 +1,9 @@ +using Versum.Dtos; + +namespace Versum.Services +{ + public interface IFeedService + { + Task> GetSmartFeedAsync(int currentUserId, int limit = 20, int skip = 0); + } +} \ No newline at end of file From 4d2d90794efe6dd9d1f2c3a5a6a656c0a44e6daf Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Mon, 25 May 2026 20:56:13 +0300 Subject: [PATCH 62/67] [FEATURE] Add Genre (#42) --- Versum/Controllers/PostsController.cs | 14 ++++++++++++ Versum/Dtos/GenreCreateDto.cs | 7 ++++++ Versum/Services/IPostService.cs | 1 + Versum/Services/PostService.cs | 33 +++++++++++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 Versum/Dtos/GenreCreateDto.cs diff --git a/Versum/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs index fc8fad2..2da7084 100644 --- a/Versum/Controllers/PostsController.cs +++ b/Versum/Controllers/PostsController.cs @@ -177,6 +177,20 @@ public async Task GetGenres() var genres = await _postService.GetGenresAsync(); return Ok(genres); } + + [HttpPost("add-genre")] + [Authorize] // Залежно від логіки платформи, пізніше тут можна додати [Authorize(Roles = "Admin")] + public async Task AddGenre([FromBody] GenreCreateDto dto) + { + var (success, error) = await _postService.AddGenreAsync(dto.Name); + + if (!success) + { + return BadRequest(new { message = error }); + } + + return StatusCode(201, new { message = "Жанр успішно додано" }); + } } } diff --git a/Versum/Dtos/GenreCreateDto.cs b/Versum/Dtos/GenreCreateDto.cs new file mode 100644 index 0000000..040260f --- /dev/null +++ b/Versum/Dtos/GenreCreateDto.cs @@ -0,0 +1,7 @@ +namespace Versum.Dtos +{ + public class GenreCreateDto + { + public string Name { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Versum/Services/IPostService.cs b/Versum/Services/IPostService.cs index 38dccb3..6dc08b3 100644 --- a/Versum/Services/IPostService.cs +++ b/Versum/Services/IPostService.cs @@ -13,5 +13,6 @@ public interface IPostService Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto dto); Task<(bool Success, string? Error)> UpdateDraftAsync(int postId, int userId, PostDto dto); Task> GetGenresAsync(); + Task<(bool Success, string? Error)> AddGenreAsync(string genreName); } } \ No newline at end of file diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 1a535a9..1545781 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -189,5 +189,38 @@ public async Task> GetGenresAsync() { return await _db.Genres.Select(g => g.Name).ToListAsync(); } + public async Task<(bool Success, string? Error)> AddGenreAsync(string genreName) + { + try + { + if (string.IsNullOrWhiteSpace(genreName)) + return (false, "Назва жанру не може бути порожньою"); + + if (genreName.Length > 50) + return (false, "Назва жанру не може перевищувати 50 символів"); + + // Перевіряємо, чи вже існує такий жанр (ігноруючи регістр) + var exists = await _db.Genres + .AnyAsync(g => g.Name.ToLower() == genreName.ToLower()); + + if (exists) + return (false, "Такий жанр вже існує"); + + var newGenre = new Genre + { + Name = genreName.Trim() + }; + + _db.Genres.Add(newGenre); + await _db.SaveChangesAsync(); + + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"AddGenreAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } } } From 47e36c4feeee6d21722841f17a5159aebd98f942 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Mon, 25 May 2026 21:55:00 +0300 Subject: [PATCH 63/67] [FIX] Added stats to PostGetDto --- Versum/Extensions/PostExtensions.cs | 17 ++++++++++------- Versum/Services/FeedService.cs | 2 +- Versum/Services/PostService.cs | 27 +++++++++------------------ 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index 29a460a..555cda4 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -46,7 +46,7 @@ private static IQueryable OrderByFieldDescending(this IQueryable que _ => query.OrderByDescending(p => p.Id) }; - public static Expression> AsPostGetDto => p => new PostGetDto + public static Expression> AsPostGetDto(int? currentUserId) => p => new PostGetDto { PostId = p.Id, Title = p.Title, @@ -55,15 +55,18 @@ private static IQueryable OrderByFieldDescending(this IQueryable que CreatedAt = p.CreatedAt, Username = p.Author.User.Username ?? "Unknown", Name = p.Author.User.Profile.Name ?? "none", - Genres = p.Genres.Select(g => g.Name).ToList() + Genres = p.Genres.Select(g => g.Name).ToList(), + LikesCount = p.LikesCount, + CommentsCount = p.CommentsCount, + IsLikedByUser = currentUserId.HasValue && p.Likes.Any(l => l.UserId == currentUserId.Value) }; - - public static IQueryable ProjectToPostDto(this IQueryable query) + public static IQueryable ProjectToPostDto(this IQueryable query, int? currentUserId) { - return query.Select(AsPostGetDto); + return query.Select(AsPostGetDto(currentUserId)); } - public static PostGetDto PostToPostGetDto(this Post post) + + public static PostGetDto PostToPostGetDto(this Post post, int? currentUserId) { - return AsPostGetDto.Compile()(post); + return AsPostGetDto(currentUserId).Compile()(post); } } diff --git a/Versum/Services/FeedService.cs b/Versum/Services/FeedService.cs index a9e7b67..f9bdd58 100644 --- a/Versum/Services/FeedService.cs +++ b/Versum/Services/FeedService.cs @@ -53,7 +53,7 @@ public async Task> GetSmartFeedAsync(int currentUserId, int lim var dtos = await _context.Posts .AsNoTracking() .Where(p => postIds.Contains(p.Id)) - .ProjectToPostDto() + .ProjectToPostDto(currentUserId) .ToListAsync(); // Сортуємо DTO diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index 1545781..a91907a 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -87,12 +87,8 @@ public PostService(ApplicationDbContext db) .AsNoTracking() .Where(p => p.AuthorId == authorId) .OnlyDrafts() - .Include(p => p.Author) - .ThenInclude(a => a.User) - .ThenInclude(u => u.Profile) - .Include(p => p.Genres) .ApplySorting(query.Filter, query.Ascending) - .ProjectToPostDto() + .ProjectToPostDto(authorId) .ToListAsync(); } @@ -102,32 +98,27 @@ public PostService(ApplicationDbContext db) .AsNoTracking() .Where(p => p.AuthorId == authorId) .OnlyPublished() - .Include(p => p.Author) - .ThenInclude(a => a.User) - .ThenInclude(u => u.Profile) - .Include(p => p.Genres) .ApplySorting(query.Filter, query.Ascending) - .ProjectToPostDto() + .ProjectToPostDto(authorId) .ToListAsync(); } public async Task<(PostGetDto?, string? Error)> GetPostAsync(int postId, int? userID) { - var post = await _db.Posts + var postDto = await _db.Posts .AsNoTracking() - .Include(p => p.Author) - .ThenInclude(a => a.User) - .ThenInclude(u => u.Profile) - .Include(p => p.Genres) - .Where(p => p.Id == postId) + .Where(p => p.Id == postId + && !p.IsDeleted + && (!p.IsDraft || p.AuthorId == userID)) + .ProjectToPostDto(userID) .FirstOrDefaultAsync(); - if (post == null || post.IsDeleted || (post.IsDraft && post.AuthorId != userID)) + if (postDto == null) { return (null, "Твір не знайдено або він ще не опублікований"); } - return (post.PostToPostGetDto(), null); + return (postDto, null); } public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) From 37721ab6e84cf61f06cfd43663907b22e30a7078 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Tue, 26 May 2026 00:05:44 +0300 Subject: [PATCH 64/67] [Versum] Implemented Versum Social 2 --- Versum/Controllers/ProfileController.cs | 7 +- Versum/Controllers/SavingsController.cs | 9 +- Versum/DbContext/ApplicationDbContext.cs | 30 +- Versum/Dtos/PostGetDto.cs | 1 + Versum/Extensions/PostExtensions.cs | 3 +- .../20260525192341_Savings Update.Designer.cs | 621 ++++++++++++++++++ .../20260525192341_Savings Update.cs | 27 + .../ApplicationDbContextModelSnapshot.cs | 10 +- Versum/Models/Post.cs | 1 + Versum/Models/{Savings.cs => Saving.cs} | 2 +- Versum/Services/IProfileService.cs | 1 + Versum/Services/ISavingsService.cs | 2 +- Versum/Services/ProfileService.cs | 12 +- Versum/Services/SavingsService.cs | 22 +- 14 files changed, 709 insertions(+), 39 deletions(-) create mode 100644 Versum/Migrations/20260525192341_Savings Update.Designer.cs create mode 100644 Versum/Migrations/20260525192341_Savings Update.cs rename Versum/Models/{Savings.cs => Saving.cs} (92%) diff --git a/Versum/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs index a525778..d59de7d 100644 --- a/Versum/Controllers/ProfileController.cs +++ b/Versum/Controllers/ProfileController.cs @@ -111,6 +111,11 @@ public async Task GetFollowings(string username) var followings = await _profileService.GetFollowingsListAsync(username.ToLower()); return Ok(followings); } - + [HttpGet("{username}/followers")] + public async Task GetFollowers(string username) + { + var followings = await _profileService.GetFollowersListAsync(username.ToLower()); + return Ok(followings); + } } } diff --git a/Versum/Controllers/SavingsController.cs b/Versum/Controllers/SavingsController.cs index 2f3062c..9f1c5f3 100644 --- a/Versum/Controllers/SavingsController.cs +++ b/Versum/Controllers/SavingsController.cs @@ -45,7 +45,6 @@ public async Task SavePost(int postId) { message = "Твір успішно збережено" }); - } [HttpPost("{postId}/unsave-post")] @@ -68,13 +67,11 @@ public async Task UnsavePost(int postId) return Ok( new { message = "Твір успішно видалено зі збережених" } ); - } - [HttpGet("get-posts")] [Authorize] - public async Task GetSavedPosts() + public async Task GetSavedPosts([FromQuery] PostQueryDto query) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (!int.TryParse(userIdClaim, out int userId)) @@ -82,15 +79,13 @@ public async Task GetSavedPosts() return Unauthorized(); } - var (success,savings, error) = await _savingsService.GetSavedPostAsync(userId); + var (success,savings, error) = await _savingsService.GetSavedPostAsync(userId, query); if (!success) { return BadRequest(new { message = error }); } return Ok(savings); - - } } } diff --git a/Versum/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs index 8c7b8fa..30b3fed 100644 --- a/Versum/DbContext/ApplicationDbContext.cs +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -17,7 +17,7 @@ public ApplicationDbContext(DbContextOptions options) public DbSet Genres { get; set; } public DbSet Follows { get; set; } = null!; public DbSet Dictionary { get; set; } = null!; - public DbSet Savings { get; set; } = null!; + public DbSet Savings { get; set; } = null!; public DbSet Notifications { get; set; } = null!; public DbSet PostReactions { get; set; } = null!; @@ -82,17 +82,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); modelBuilder.Entity() - .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) - .IsUnique(); + .HasIndex(d => new { d.UserId, d.PostId, d.AnchorId }) + .IsUnique(); + + + modelBuilder.Entity() + .HasKey(s => new { s.UserId, s.PostId }); + + modelBuilder.Entity() + .HasOne(l => l.Post) + .WithMany(p => p.Savings) + .HasForeignKey(l => l.PostId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(l => l.User) + .WithMany() + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasKey(s => new { s.UserId, s.PostId }); - modelBuilder.Entity() - .HasIndex(s => new { s.UserId, s.PostId }); modelBuilder.Entity() - .HasIndex(l => new { l.UserId, l.PostId }) - .IsUnique(); + .HasIndex(l => new { l.UserId, l.PostId }) + .IsUnique(); modelBuilder.Entity() .HasOne(l => l.Post) diff --git a/Versum/Dtos/PostGetDto.cs b/Versum/Dtos/PostGetDto.cs index cce9106..d959fce 100644 --- a/Versum/Dtos/PostGetDto.cs +++ b/Versum/Dtos/PostGetDto.cs @@ -17,6 +17,7 @@ public class PostGetDto public int LikesCount { get; set; } public int CommentsCount { get; set; } public bool IsLikedByUser { get; set; } + public bool IsSavedByUser { get; set; } } } \ No newline at end of file diff --git a/Versum/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs index 555cda4..b0a410d 100644 --- a/Versum/Extensions/PostExtensions.cs +++ b/Versum/Extensions/PostExtensions.cs @@ -58,7 +58,8 @@ private static IQueryable OrderByFieldDescending(this IQueryable que Genres = p.Genres.Select(g => g.Name).ToList(), LikesCount = p.LikesCount, CommentsCount = p.CommentsCount, - IsLikedByUser = currentUserId.HasValue && p.Likes.Any(l => l.UserId == currentUserId.Value) + IsLikedByUser = currentUserId.HasValue && p.Likes.Any(l => l.UserId == currentUserId.Value), + IsSavedByUser = currentUserId.HasValue && p.Savings.Any(s => s.UserId == currentUserId.Value) }; public static IQueryable ProjectToPostDto(this IQueryable query, int? currentUserId) { diff --git a/Versum/Migrations/20260525192341_Savings Update.Designer.cs b/Versum/Migrations/20260525192341_Savings Update.Designer.cs new file mode 100644 index 0000000..3d67e03 --- /dev/null +++ b/Versum/Migrations/20260525192341_Savings Update.Designer.cs @@ -0,0 +1,621 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#nullable disable + +namespace Versum.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260525192341_Savings Update")] + partial class SavingsUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.Property("GenresId") + .HasColumnType("integer"); + + b.Property("PostsId") + .HasColumnType("integer"); + + b.HasKey("GenresId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("GenrePost"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("AuthorBio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnchorId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId", "AnchorId") + .IsUnique(); + + b.ToTable("Dictionary"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FollowerId") + .HasColumnType("integer"); + + b.Property("FollowingId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FollowerId"); + + b.HasIndex("FollowingId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("Likes"); + }); + + modelBuilder.Entity("Versum.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsLiked") + .HasColumnType("boolean"); + + b.Property("LastInteractedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("PriorityScore") + .HasColumnType("real"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId", "PostId") + .IsUnique(); + + b.ToTable("PostReactions"); + }); + + modelBuilder.Entity("Versum.Models.Saving", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("PostId") + .HasColumnType("integer"); + + b.Property("SavedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "PostId"); + + b.HasIndex("PostId"); + + b.ToTable("Savings"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CommentsCount") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500000) + .HasColumnType("character varying(500000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDraft") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("UserId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmailConfirmationTokenHash") + .HasColumnType("text"); + + b.Property("EmailTokenExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEmailConfirmed") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("ResetTokenExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailConfirmationTokenHash") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("Email", "PasswordResetToken"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Profiles"); + }); + + modelBuilder.Entity("GenrePost", b => + { + b.HasOne("Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.HasOne("Versum.User", "User") + .WithOne("AuthorProfile") + .HasForeignKey("Versum.Models.Author", "AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Comment", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Comments") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Dictionary", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId"); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Follow", b => + { + b.HasOne("Versum.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "Following") + .WithMany("Followers") + .HasForeignKey("FollowingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("Following"); + }); + + modelBuilder.Entity("Versum.Models.Like", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Likes") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.PostReaction", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Saving", b => + { + b.HasOne("Versum.Post", "Post") + .WithMany("Savings") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.HasOne("Versum.Models.Author", "Author") + .WithMany("Posts") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Versum.User", null) + .WithMany("Posts") + .HasForeignKey("UserId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Versum.UserProfile", b => + { + b.HasOne("Versum.User", "User") + .WithOne("Profile") + .HasForeignKey("Versum.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Versum.Models.Author", b => + { + b.Navigation("Posts"); + }); + + modelBuilder.Entity("Versum.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + + b.Navigation("Savings"); + }); + + modelBuilder.Entity("Versum.User", b => + { + b.Navigation("AuthorProfile"); + + b.Navigation("Followers"); + + b.Navigation("Posts"); + + b.Navigation("Profile") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Versum/Migrations/20260525192341_Savings Update.cs b/Versum/Migrations/20260525192341_Savings Update.cs new file mode 100644 index 0000000..d61350f --- /dev/null +++ b/Versum/Migrations/20260525192341_Savings Update.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class SavingsUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Savings_UserId_PostId", + table: "Savings"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Savings_UserId_PostId", + table: "Savings", + columns: new[] { "UserId", "PostId" }); + } + } +} diff --git a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs index 47660d7..79ceb4b 100644 --- a/Versum/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -265,7 +265,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PostReactions"); }); - modelBuilder.Entity("Versum.Models.Savings", b => + modelBuilder.Entity("Versum.Models.Saving", b => { b.Property("UserId") .HasColumnType("integer"); @@ -280,8 +280,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PostId"); - b.HasIndex("UserId", "PostId"); - b.ToTable("Savings"); }); @@ -544,10 +542,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Versum.Models.Savings", b => + modelBuilder.Entity("Versum.Models.Saving", b => { b.HasOne("Versum.Post", "Post") - .WithMany() + .WithMany("Savings") .HasForeignKey("PostId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -599,6 +597,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Comments"); b.Navigation("Likes"); + + b.Navigation("Savings"); }); modelBuilder.Entity("Versum.User", b => diff --git a/Versum/Models/Post.cs b/Versum/Models/Post.cs index 2b668a2..5f7ca7e 100644 --- a/Versum/Models/Post.cs +++ b/Versum/Models/Post.cs @@ -20,6 +20,7 @@ public class Post public int CommentsCount { get; set; } = 0; public ICollection Likes { get; set; } = new List(); public ICollection Comments { get; set; } = new List(); + public ICollection Savings { get; set; } = new List(); } } diff --git a/Versum/Models/Savings.cs b/Versum/Models/Saving.cs similarity index 92% rename from Versum/Models/Savings.cs rename to Versum/Models/Saving.cs index ed6d632..939779b 100644 --- a/Versum/Models/Savings.cs +++ b/Versum/Models/Saving.cs @@ -1,6 +1,6 @@ namespace Versum.Models { - public class Savings + public class Saving { public int UserId { get; set; } diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs index ea43f40..7860f9d 100644 --- a/Versum/Services/IProfileService.cs +++ b/Versum/Services/IProfileService.cs @@ -10,6 +10,7 @@ public interface IProfileService Task<(bool success, string? error)> ToggleFollowAsync(int followerId, int followingId); Task> GetFollowingsListAsync(string username); + Task> GetFollowersListAsync(string username); Task GetUserIdByUsernameAsync(string username); Task GetUsernameByUserIdAsync(int userId); } \ No newline at end of file diff --git a/Versum/Services/ISavingsService.cs b/Versum/Services/ISavingsService.cs index eb76ddf..148f47a 100644 --- a/Versum/Services/ISavingsService.cs +++ b/Versum/Services/ISavingsService.cs @@ -6,6 +6,6 @@ public interface ISavingsService { Task<(bool Success, string? Error)> SavePostAsync(int postId, int userId); Task<(bool Success, string? Error)> UnSavePostAsync(int postId,int userId); - Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId); + Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId, PostQueryDto query); } } diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 9892b7a..641bccc 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -196,7 +196,17 @@ public async Task> GetFollowingsListAsync(string username) }) .ToListAsync(); } - + public async Task> GetFollowersListAsync(string username) + { + return await _db.Follows + .Where(f => f.Following.Username == username) + .Select(f => new UserFollowDto + { + Username = f.Follower.Username, + DisplayName = f.Follower.Profile.Name + }) + .ToListAsync(); + } } } diff --git a/Versum/Services/SavingsService.cs b/Versum/Services/SavingsService.cs index 36e616b..5f0b163 100644 --- a/Versum/Services/SavingsService.cs +++ b/Versum/Services/SavingsService.cs @@ -2,6 +2,8 @@ using Versum.Context; using Versum.Dtos; using Versum.Models; +using Versum.Extensions; +using Ganss.Xss; namespace Versum.Services @@ -26,7 +28,7 @@ public SavingsService(ApplicationDbContext db) var alreadySaved = await _db.Savings.AnyAsync(s => s.UserId == userId && s.PostId == postId); if (alreadySaved) return (false, "PostIsSaved"); - var saved = new Savings + var saved = new Saving { UserId = userId, PostId = postId, @@ -67,24 +69,18 @@ public SavingsService(ApplicationDbContext db) } } - public async Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId) + public async Task<(bool Success, List?, string? Error)> GetSavedPostAsync(int userId, PostQueryDto query) { var savings = await _db.Savings .AsNoTracking() - .Where(s => s.UserId == userId).OrderByDescending(s => s.SavedAt) - .Select(s => new SavingsResponseDto - { - - UserId = s.UserId, - PostId = s.PostId, - SavedAt = s.SavedAt - - }) + .Where(s => s.UserId == userId) + .OrderByDescending(s => s.SavedAt) + .Select(s => s.Post) + .ApplySorting(query.Filter, query.Ascending) + .ProjectToPostDto(userId) .ToListAsync(); return (true, savings, null); - } - } } From 9f8884d82c34d0caeedcfaef3f8c6634218d6d4f Mon Sep 17 00:00:00 2001 From: Tachyon64 <83309505+Tachyon64@users.noreply.github.com> Date: Wed, 27 May 2026 11:23:53 +0300 Subject: [PATCH 65/67] [FIX] Implemented feed generation for unauthorized users --- Versum/Controllers/FeedController.cs | 12 ++++++++--- Versum/Services/FeedService.cs | 30 ++++++++++++++++++---------- Versum/Services/IFeedService.cs | 2 +- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Versum/Controllers/FeedController.cs b/Versum/Controllers/FeedController.cs index 2501a97..3b3b3b5 100644 --- a/Versum/Controllers/FeedController.cs +++ b/Versum/Controllers/FeedController.cs @@ -18,13 +18,19 @@ public FeedController(IFeedService feedService) _feedService = feedService; } + [AllowAnonymous] [HttpGet] public async Task>> GetFeed([FromQuery] int limit = 20) { - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!int.TryParse(userIdClaim, out int currentUserId)) + int? currentUserId = null; + + if (User.Identity != null && User.Identity.IsAuthenticated) { - return Unauthorized(); + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (int.TryParse(userIdClaim, out int parsedId)) + { + currentUserId = parsedId; + } } var feed = await _feedService.GetSmartFeedAsync(currentUserId, limit); diff --git a/Versum/Services/FeedService.cs b/Versum/Services/FeedService.cs index f9bdd58..73a5949 100644 --- a/Versum/Services/FeedService.cs +++ b/Versum/Services/FeedService.cs @@ -19,18 +19,32 @@ public FeedService(ApplicationDbContext context) _context = context; } - public async Task> GetSmartFeedAsync(int currentUserId, int limit = 20, int skip = 0) + public async Task> GetSmartFeedAsync(int? currentUserId, int limit = 20, int skip = 0) { - // Крок 1: Отримуємо тільки метадані + // ЛОГІКА ДЛЯ ГОСТЕЙ + if (!currentUserId.HasValue) + { + return await _context.Posts + .AsNoTracking() + .OnlyPublished() + .OrderByDescending(p => p.CreatedAt) + .Skip(skip) + .Take(limit) + .ProjectToPostDto(currentUserId) + .ToListAsync(); + } + // ЛОГІКА ДЛЯ ЗАРЕЄСТРОВАНИХ + int userId = currentUserId.Value; + var metadata = await _context.Posts .AsNoTracking() .OnlyPublished() - .Where(p => p.AuthorId != currentUserId) // ВАЖЛИВО: Виключаємо власні твори користувача з рекомендацій + .Where(p => p.AuthorId != userId) .Select(p => new { PostId = p.Id, - Reaction = _context.PostReactions.FirstOrDefault(pr => pr.PostId == p.Id && pr.UserId == currentUserId), - IsFollowed = _context.Follows.Any(f => f.FollowerId == currentUserId && f.FollowingId == p.AuthorId), + Reaction = _context.PostReactions.FirstOrDefault(pr => pr.PostId == p.Id && pr.UserId == userId), + IsFollowed = _context.Follows.Any(f => f.FollowerId == userId && f.FollowingId == p.AuthorId), CreatedAt = p.CreatedAt }) .OrderByDescending(x => x.Reaction != null @@ -46,22 +60,18 @@ public async Task> GetSmartFeedAsync(int currentUserId, int lim return new List(); } - // Крок 2: Збираємо ID відібраних постів var postIds = metadata.Select(x => x.PostId).ToList(); - // Крок 3: Витягуємо готові DTO прямо з бази var dtos = await _context.Posts .AsNoTracking() .Where(p => postIds.Contains(p.Id)) .ProjectToPostDto(currentUserId) .ToListAsync(); - // Сортуємо DTO var feedDtos = postIds .Select(id => dtos.First(d => d.PostId == id)) .ToList(); - // Крок 4: Динамічно оновлюємо рейтинги переглядів var postsToUpdate = new List(); var postsToAdd = new List(); @@ -73,7 +83,7 @@ public async Task> GetSmartFeedAsync(int currentUserId, int lim postsToAdd.Add(new PostReaction { - UserId = currentUserId, + UserId = userId, PostId = item.PostId, ViewCount = 1, PriorityScore = initialScore - VIEW_PENALTY, diff --git a/Versum/Services/IFeedService.cs b/Versum/Services/IFeedService.cs index efcfd6f..9c56d56 100644 --- a/Versum/Services/IFeedService.cs +++ b/Versum/Services/IFeedService.cs @@ -4,6 +4,6 @@ namespace Versum.Services { public interface IFeedService { - Task> GetSmartFeedAsync(int currentUserId, int limit = 20, int skip = 0); + Task> GetSmartFeedAsync(int? currentUserId, int limit = 20, int skip = 0); } } \ No newline at end of file From 9ae1ba42b42beab445c5e22462b56e83b6af9c10 Mon Sep 17 00:00:00 2001 From: NotAsasha <48174753+NotAsasha@users.noreply.github.com> Date: Sun, 31 May 2026 23:04:32 +0300 Subject: [PATCH 66/67] [FIX] Added other types of notifications (#44) --- Versum/Controllers/AuthController.cs | 2 +- Versum/Services/AuthService.cs | 31 ++++++----- Versum/Services/CommentLikeService.cs | 17 +++++- Versum/Services/IAuthService.cs | 2 +- Versum/Services/INotificationService.cs | 4 ++ Versum/Services/NotificationService.cs | 70 +++++++++++++++++++++++++ Versum/Services/PostService.cs | 23 ++++++-- Versum/Services/ProfileService.cs | 12 ++--- 8 files changed, 132 insertions(+), 29 deletions(-) diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs index c53af59..1196e1a 100644 --- a/Versum/Controllers/AuthController.cs +++ b/Versum/Controllers/AuthController.cs @@ -34,7 +34,7 @@ public async Task Register([FromBody] RegisterDto dto)// JSON con if (!success) return Conflict(new { field, message = error }); //checks if data for transfer does not cause conflicts(error 409) - return Ok(new { message = "Реєстрація успішна! Перевірте пошту для підтвердження." }); + return Ok(new { token = error, message = "Реєстрація успішна! Перевірте пошту для підтвердження." }); } diff --git a/Versum/Services/AuthService.cs b/Versum/Services/AuthService.cs index eb9d6a6..7179962 100644 --- a/Versum/Services/AuthService.cs +++ b/Versum/Services/AuthService.cs @@ -21,15 +21,18 @@ public AuthService(ApplicationDbContext db, IEmailService emailService, IConfigu _emailService = emailService; _configuration = configuration; } - public async Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto) - { + public async Task<(bool Success, string? tokenOrError, string? Field)> RegisterAsync(RegisterDto dto) + { + dto.Username = dto.Username.ToLower(); + dto.Email = dto.Email.ToLower(); + bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); bool emailExists = await _db.Users.AnyAsync(e => e.Email == dto.Email); if (usernameExists) return (false, "Цей нікнейм вже існує", "username"); if (emailExists) - return (false, "Цей імейл вже існує", "email"); + return (false, "Цей емейл вже існує", "email"); string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password); @@ -64,7 +67,7 @@ public AuthService(ApplicationDbContext db, IEmailService emailService, IConfigu }; - _db.Users.Add(user); + var addedUser = _db.Users.Add(user); await _db.SaveChangesAsync(); @@ -79,9 +82,10 @@ public AuthService(ApplicationDbContext db, IEmailService emailService, IConfigu await _emailService.SendEmailAsync(dto.Email, "Підтвердження реєстрації — Versum", htmlBody); - return (true, null, null); + string jwtToken = GenerateJwtToken(addedUser.Entity); + return (true, jwtToken, null); } public async Task<(bool success, string? error)> ConfirmEmailAsync(string token) @@ -112,6 +116,7 @@ public AuthService(ApplicationDbContext db, IEmailService emailService, IConfigu } public async Task<(bool success, string tokenOrError, string userGmail, string username)> LoginAsync(LoginDto dto) { + dto.UsernameOrGmail = dto.UsernameOrGmail.ToLower(); var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.UsernameOrGmail || u.Username == dto.UsernameOrGmail); @@ -142,19 +147,19 @@ public string GenerateJwtToken(User user) { var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), // currentUserId - new Claim(ClaimTypes.Name, user.Username), - new Claim(ClaimTypes.Email, user.Email) - }; + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), // currentUserId + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email) + }; - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"])); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, - expires: DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpireMinutes"])), + expires: DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpireMinutes"]!)), signingCredentials: creds ); @@ -162,6 +167,7 @@ public string GenerateJwtToken(User user) } public async Task<(bool success, string? error)> ForgotPasswordAsync(ForgotPasswordDto dto) { + dto.Email = dto.Email.ToLower(); var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.Email); if (user == null) { @@ -188,6 +194,7 @@ public string GenerateJwtToken(User user) public async Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto) { + dto.Email = dto.Email.ToLower(); var user = await _db.Users.FirstOrDefaultAsync( u => u.Email == dto.Email && u.PasswordResetToken == dto.Token @@ -222,6 +229,7 @@ public string GenerateJwtToken(User user) public async Task<(bool success, string? error)> ResetPasswordTokenCheckAsync(ResetPasswordTokenDto dto) { + dto.Email = dto.Email.ToLower(); var user = await _db.Users.FirstOrDefaultAsync( u => u.Email == dto.Email && u.PasswordResetToken == dto.Token @@ -237,6 +245,5 @@ public string GenerateJwtToken(User user) return (true, null); } - } } diff --git a/Versum/Services/CommentLikeService.cs b/Versum/Services/CommentLikeService.cs index 284ebb8..2d5cdb1 100644 --- a/Versum/Services/CommentLikeService.cs +++ b/Versum/Services/CommentLikeService.cs @@ -8,10 +8,14 @@ namespace Versum.Services public class CommentLikeService : ICommentLikeService { private readonly ApplicationDbContext _db; + private readonly INotificationService _notificationService; + private readonly IProfileService _profileService; - public CommentLikeService(ApplicationDbContext db) + public CommentLikeService(ApplicationDbContext db, INotificationService notificationService, IProfileService profileService) { _db = db; + _notificationService = notificationService; + _profileService = profileService; } public async Task<(bool success, string? error)> ToggleLikeAsync(int userId, int postId) @@ -21,7 +25,7 @@ public CommentLikeService(ApplicationDbContext db) var existing = await _db.Likes .FirstOrDefaultAsync(l => l.UserId == userId && l.PostId == postId); - + if (existing != null) { _db.Likes.Remove(existing); @@ -31,6 +35,10 @@ public CommentLikeService(ApplicationDbContext db) { _db.Likes.Add(new Like { UserId = userId, PostId = postId }); post.LikesCount++; + + //Notification + var username = await _profileService.GetUsernameByUserIdAsync(userId); + await _notificationService.SendLikeNotificationAsync(post.AuthorId, username ?? "Хтось", post.Title); } await _db.SaveChangesAsync(); @@ -70,6 +78,11 @@ public async Task> GetCommentsAsync(int postId, int? userId) }); post.CommentsCount++; + + //Notification + var username = await _profileService.GetUsernameByUserIdAsync(userId); + await _notificationService.SendCommentNotificationAsync(post.AuthorId, username ?? "Хтось", post.Title); + await _db.SaveChangesAsync(); return (true, null); } diff --git a/Versum/Services/IAuthService.cs b/Versum/Services/IAuthService.cs index 3ee096c..3f93fca 100644 --- a/Versum/Services/IAuthService.cs +++ b/Versum/Services/IAuthService.cs @@ -2,7 +2,7 @@ public interface IAuthService { - Task<(bool Success, string? Error, string? Field)> RegisterAsync(RegisterDto dto); + Task<(bool Success, string? tokenOrError, string? Field)> RegisterAsync(RegisterDto dto); // Method returns: // bool Success = successful registration // string? Error = text of error (or null if everuthing is ok) diff --git a/Versum/Services/INotificationService.cs b/Versum/Services/INotificationService.cs index 2d6afc5..5d2b1c3 100644 --- a/Versum/Services/INotificationService.cs +++ b/Versum/Services/INotificationService.cs @@ -3,6 +3,10 @@ public interface INotificationService { Task SendFollowNotificationAsync(int targetUserId, string actorUsername); + Task SendLikeNotificationAsync(int targetUserId, string actorUsername, string postName); + Task SendCommentNotificationAsync(int targetUserId, string actorUsername, string postName); + Task NotifyFollowersAboutPublishingAsync(int authorId, string actorUsername, string postName); + Task SendNewPostNotificationAsync(int targetUserId, string actorUsername, string postName); Task?> GetNotificationsAsync(int userId); Task<(bool Success, string? Error)> ReadNotificationAsync(int id, int userId); } \ No newline at end of file diff --git a/Versum/Services/NotificationService.cs b/Versum/Services/NotificationService.cs index ec36736..d6daece 100644 --- a/Versum/Services/NotificationService.cs +++ b/Versum/Services/NotificationService.cs @@ -39,6 +39,76 @@ await _hubContext.Clients.User(targetUserId.ToString()) .SendAsync("ReceiveNotification", notification.NotificationToDto()); } + public async Task SendLikeNotificationAsync(int targetUserId, string actorUsername, string postName) + { + var notification = new Notification + { + TargetUserId = targetUserId, + Type = "Like", + Message = $"{actorUsername} уподобав ваш твір: \"{postName}\".", + ActorUsername = actorUsername, + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + + _db.Notifications.Add(notification); + await _db.SaveChangesAsync(); + + await _hubContext.Clients.User(targetUserId.ToString()) + .SendAsync("ReceiveNotification", notification.NotificationToDto()); + } + public async Task SendCommentNotificationAsync(int targetUserId, string actorUsername, string postName) + { + var notification = new Notification + { + TargetUserId = targetUserId, + Type = "Comment", + Message = $"{actorUsername} прокоментував ваш твір: \"{postName}\".", + ActorUsername = actorUsername, + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + + _db.Notifications.Add(notification); + await _db.SaveChangesAsync(); + + await _hubContext.Clients.User(targetUserId.ToString()) + .SendAsync("ReceiveNotification", notification.NotificationToDto()); + } + + public async Task NotifyFollowersAboutPublishingAsync(int authorId, string actorUsername, string postName) + { + var followers = await _db.Follows + .Where(f => f.Following.Id == authorId) + .Select(f => f.Id) + .ToListAsync(); + + if (!followers.Any()) return; + foreach (var followerId in followers) + { + await SendNewPostNotificationAsync(followerId, actorUsername, postName); + } + } + + public async Task SendNewPostNotificationAsync(int targetUserId, string actorUsername, string postName) + { + var notification = new Notification + { + TargetUserId = targetUserId, + Type = "NewPost", + Message = $"{actorUsername} написав новий твір: \"{postName}\".", + ActorUsername = actorUsername, + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + + _db.Notifications.Add(notification); + await _db.SaveChangesAsync(); + + await _hubContext.Clients.User(targetUserId.ToString()) + .SendAsync("ReceiveNotification", notification.NotificationToDto()); + } + public async Task?> GetNotificationsAsync(int userId) { return await _db.Notifications diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs index a91907a..8bd1dc2 100644 --- a/Versum/Services/PostService.cs +++ b/Versum/Services/PostService.cs @@ -1,8 +1,9 @@ -using Microsoft.EntityFrameworkCore; +using Ganss.Xss; +using Microsoft.EntityFrameworkCore; +using Versum.Context; using Versum.Dtos; using Versum.Extensions; -using Versum.Context; -using Ganss.Xss; +using Versum.Models; namespace Versum.Services { @@ -10,10 +11,14 @@ public class PostService : IPostService { private readonly ApplicationDbContext _db; + private readonly IProfileService _profileService; + private readonly INotificationService _notificationService; - public PostService(ApplicationDbContext db) + public PostService(ApplicationDbContext db, IProfileService profileService, INotificationService notificationService) { _db = db; + _profileService = profileService; + _notificationService = notificationService; } @@ -42,6 +47,16 @@ public PostService(ApplicationDbContext db) await _db.SaveChangesAsync(); + try + { + var username = await _profileService.GetUsernameByUserIdAsync(userId) ?? "Хтось"; + await _notificationService.NotifyFollowersAboutPublishingAsync(userId, username, post.Title); + } + catch (Exception notifEx) + { + Console.WriteLine($"Notification failed: {notifEx.Message}"); + } + return (true, null); } catch (Exception ex) diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs index 641bccc..5431851 100644 --- a/Versum/Services/ProfileService.cs +++ b/Versum/Services/ProfileService.cs @@ -25,7 +25,7 @@ public ProfileService(ApplicationDbContext db, INotificationService notification .FirstOrDefaultAsync(u => u.Id == UserId); if (user == null) { - return (false, "Чому нас вважають за одну людину?"); + return (false, "Чому нас вважають однією людиною?"); } bool usernameExists = await _db.Users.AnyAsync(u => u.Username == dto.Username); @@ -109,7 +109,7 @@ public ProfileService(ApplicationDbContext db, INotificationService notification var shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); user.Email = $"del_{shortGuid}@anon.com"; // Близько 21 символу - user.Username = $"anon_{shortGuid}"; // 13 символів + user.Username = $"deleted_{shortGuid}"; // 13 символів user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); user.IsDeleted = true; @@ -163,7 +163,6 @@ public ProfileService(ApplicationDbContext db, INotificationService notification var existingFollow = await _db.Follows .FirstOrDefaultAsync(f => f.FollowerId == followerId && f.FollowingId == followingId); - bool isFollowed = false; if (existingFollow != null) { @@ -172,12 +171,8 @@ public ProfileService(ApplicationDbContext db, INotificationService notification else { _db.Follows.Add(new Follow { FollowerId = followerId, FollowingId = followingId }); - isFollowed = true; //sub - } - //notification - if (isFollowed) - { + //Notification var username = await GetUsernameByUserIdAsync(followerId); await _notificationService.SendFollowNotificationAsync(followingId, username ?? "Хтось"); } @@ -208,6 +203,5 @@ public async Task> GetFollowersListAsync(string username) .ToListAsync(); } } - } From d4058de8d4e3b72a224e58b073ef18b356ca0fc5 Mon Sep 17 00:00:00 2001 From: NotAsasha Date: Sun, 31 May 2026 23:43:07 +0300 Subject: [PATCH 67/67] [FIX] Test fixed --- .../ControllerTest/PostControllerTest.cs | 6 ++-- TestProject/ServicesTest/AuthServiceTests.cs | 34 ++++++++++++------- TestProject/ServicesTest/PostServiceTests.cs | 8 +++-- .../ServicesTest/ProfileServiceTests.cs | 2 +- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/TestProject/ControllerTest/PostControllerTest.cs b/TestProject/ControllerTest/PostControllerTest.cs index 933a6e1..46f0dc4 100644 --- a/TestProject/ControllerTest/PostControllerTest.cs +++ b/TestProject/ControllerTest/PostControllerTest.cs @@ -300,13 +300,13 @@ public async Task UpdateDraft_ReturnsNotFound_WhenDraftIsPublished() // Assert - var notFoundResult = Assert.IsType(result); + var conflictResult = Assert.IsType(result); - Assert.Equal(404, notFoundResult.StatusCode); + Assert.Equal(409, conflictResult.StatusCode); - var response = notFoundResult.Value; + var response = conflictResult.Value; var message = response.GetType().GetProperty("message")?.GetValue(response, null); Assert.Equal("Твір уже опубліковано", message); diff --git a/TestProject/ServicesTest/AuthServiceTests.cs b/TestProject/ServicesTest/AuthServiceTests.cs index ceeccd3..b5c2ffa 100644 --- a/TestProject/ServicesTest/AuthServiceTests.cs +++ b/TestProject/ServicesTest/AuthServiceTests.cs @@ -37,16 +37,27 @@ public AuthServiceTests() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; _emailServiceMock = new Mock(); - _configurationMock = new Mock(); - _configurationMock.Setup(c => c["AppSettings:BaseUrl"]).Returns("https://localhost:7014"); - _context = new ApplicationDbContext(options); - _authService = new AuthService(_context, _emailServiceMock.Object, _configurationMock.Object); _assertContext = new ApplicationDbContext(options); + // 1. Створюємо єдину правильну конфігурацію + var myConfiguration = new Dictionary + { + {"AppSettings:BaseUrl", "https://localhost:7014"}, + {"Jwt:Key", "SuperSecretKeyForTestingPurposesThatIsLongEnough!!"}, + {"Jwt:Issuer", "TestIssuer"}, + {"Jwt:Audience", "TestAudience"}, + {"Jwt:ExpireMinutes", "1440"} + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(myConfiguration) + .Build(); - TemporaryTemplate(); + // 2. Ініціалізуємо сервіс ТІЛЬКИ ОДИН РАЗ + _authService = new AuthService(_context, _emailServiceMock.Object, configuration); + TemporaryTemplate(); } private void TemporaryTemplate() { @@ -90,11 +101,11 @@ public async Task RegisterAsync_WhenDataIsValid_SuccessfullyRegistersUserAndSend var dto = new RegisterDto { Username = TestUsername, Email = TestEmail, Password = TestPassword }; // Act - var (success, error, field) = await _authService.RegisterAsync(dto); + var (success, token, field) = await _authService.RegisterAsync(dto); // Assert Assert.True(success); - Assert.Null(error); + Assert.NotNull(token); Assert.Null(field); // checks if user is saved in database @@ -156,7 +167,7 @@ public async Task RegisterAsync_WhenEmailAlreadyExists_ReturnsError() // Assert Assert.False(success); - Assert.Equal("Цей імейл вже існує", error); + Assert.Equal("Цей емейл вже існує", error); Assert.Equal("email", field); } @@ -197,7 +208,6 @@ public async Task ConfirmEmailAsync_WithValidAndActiveToken_ReturnsSuccessAndUpd Assert.Null(updatedUser.EmailTokenExpiryDate); } - [Fact] public async Task ConfirmEmailAsync_WithInvalidToken_ReturnsFalseAndError() { @@ -219,10 +229,8 @@ public async Task ConfirmEmailAsync_WithInvalidToken_ReturnsFalseAndError() _context.Users.Add(user); await _context.SaveChangesAsync(); - var authService = new global::Versum.Services.AuthService(_context, _emailServiceMock.Object, _configurationMock.Object); - - // Act - var (success, error) = await authService.ConfirmEmailAsync("wrong_token"); + // Act (використовуємо _authService рівня класу) + var (success, error) = await _authService.ConfirmEmailAsync("wrong_token"); // Assert Assert.False(success); diff --git a/TestProject/ServicesTest/PostServiceTests.cs b/TestProject/ServicesTest/PostServiceTests.cs index a3b1feb..5e2f6bb 100644 --- a/TestProject/ServicesTest/PostServiceTests.cs +++ b/TestProject/ServicesTest/PostServiceTests.cs @@ -13,6 +13,8 @@ public class PostServiceTests : IDisposable private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _assertContext; private readonly PostService _postService; + private readonly ProfileService _profileService; + private readonly NotificationService _notifitationService; private const string TestTitle = "title"; private const string TestDescription = "description"; @@ -28,7 +30,7 @@ public PostServiceTests() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; _context = new ApplicationDbContext(options); - _postService = new PostService(_context); + _postService = new PostService(_context, _profileService, _notifitationService); _assertContext = new ApplicationDbContext(options); } @@ -118,7 +120,7 @@ public async Task CreateDraftAsync_ReturnsServerError_WhenDatabaseThrowsExceptio mockContext.Setup(m => m.SaveChangesAsync(It.IsAny())) .ThrowsAsync(new Exception("Database connection failed")); - var serviceWithMock = new PostService(mockContext.Object); + var serviceWithMock = new PostService(mockContext.Object, _profileService, _notifitationService); var dto = new CreateDraftDto { Title = "Test Title" }; // 5. Act @@ -265,7 +267,7 @@ public async Task UpdateDraftAsync_ReturnsServerError_WhenDatabaseThrowsExceptio mockContext.Setup(m => m.SaveChangesAsync(It.IsAny())) .ThrowsAsync(new Exception("Database connection failed")); - var serviceWithMock = new PostService(mockContext.Object); + var serviceWithMock = new PostService(mockContext.Object, _profileService, _notifitationService); var dto = new PostDto { Title = TestTitle, Description = TestDescription, Content = TestContent }; diff --git a/TestProject/ServicesTest/ProfileServiceTests.cs b/TestProject/ServicesTest/ProfileServiceTests.cs index d66b7da..318e82e 100644 --- a/TestProject/ServicesTest/ProfileServiceTests.cs +++ b/TestProject/ServicesTest/ProfileServiceTests.cs @@ -157,7 +157,7 @@ public async Task UpdateProfileAsync_WhenUserDoesNotExist_ReturnsFalseAndErrorMe // Assert Assert.False(success); - Assert.Equal("Чому нас вважають за одну людину?", error); + Assert.Equal("Чому нас вважають однією людиною?", error); } [Fact]