diff --git a/.github/workflows/develop_versum.yml b/.github/workflows/develop_versum.yml new file mode 100644 index 0000000..ade19b5 --- /dev/null +++ b/.github/workflows/develop_versum.yml @@ -0,0 +1,68 @@ +# 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: Test + run: dotnet test TestProject/VersumTestProject.csproj --configuration Release --no-restore + + - 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_E1A151645519484388D8610703C3F319 }} + tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_7EA0EA099A634F608D8A7349EBE6DAFE }} + subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_89B30947B5B34F7B8B916AF82746C262 }} + + - name: Deploy to Azure Web App + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'Versum' + slot-name: 'Production' + package: . + diff --git a/.gitignore b/.gitignore index ce89292..e2947c0 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 @@ -416,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/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/TestProject/ControllerTest/PostControllerTest.cs b/TestProject/ControllerTest/PostControllerTest.cs new file mode 100644 index 0000000..46f0dc4 --- /dev/null +++ b/TestProject/ControllerTest/PostControllerTest.cs @@ -0,0 +1,400 @@ +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 Mock _profileServiceMock; + private readonly PostsController _controller; + + private const string TestTitle = "title"; + private const string TestDescription = "description"; + private const string TestContent = "SecurePassword123"; + public PostControllerTests() + { + _postServiceMock = new Mock(); + _profileServiceMock = new Mock(); + _controller = new PostsController( _postServiceMock.Object, _profileServiceMock.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 conflictResult = Assert.IsType(result); + + + Assert.Equal(409, conflictResult.StatusCode); + + + var response = conflictResult.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/AuthServiceTests.cs b/TestProject/ServicesTest/AuthServiceTests.cs new file mode 100644 index 0000000..b5c2ffa --- /dev/null +++ b/TestProject/ServicesTest/AuthServiceTests.cs @@ -0,0 +1,281 @@ +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(); + _context = new ApplicationDbContext(options); + _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(); + + // 2. Ініціалізуємо сервіс ТІЛЬКИ ОДИН РАЗ + _authService = new AuthService(_context, _emailServiceMock.Object, configuration); + + 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, token, field) = await _authService.RegisterAsync(dto); + + // Assert + Assert.True(success); + Assert.NotNull(token); + 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(); + + // Act (використовуємо _authService рівня класу) + 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..5e2f6bb --- /dev/null +++ b/TestProject/ServicesTest/PostServiceTests.cs @@ -0,0 +1,452 @@ +using Microsoft.EntityFrameworkCore; +using Moq; +using Versum; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; +using Versum.Services; + +namespace VersumTestProject.ServicesTest +{ + 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"; + 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() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()).Options; + + _context = new ApplicationDbContext(options); + _postService = new PostService(_context, _profileService, _notifitationService); + _assertContext = new ApplicationDbContext(options); + } + + // 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, _profileService, _notifitationService); + 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, _profileService, _notifitationService); + 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" } }; + + // 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); + + } + + + + [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); + } + + + + + } +} + diff --git a/TestProject/ServicesTest/ProfileServiceTests.cs b/TestProject/ServicesTest/ProfileServiceTests.cs new file mode 100644 index 0000000..318e82e --- /dev/null +++ b/TestProject/ServicesTest/ProfileServiceTests.cs @@ -0,0 +1,288 @@ +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 readonly NotificationService _notificationsService; + + 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, _notificationsService); + _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..4e8a872 --- /dev/null +++ b/TestProject/VersumTestProject.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Versum.slnx b/Versum.slnx new file mode 100644 index 0000000..f4631b6 --- /dev/null +++ b/Versum.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/Versum/Controllers/AuthController.cs b/Versum/Controllers/AuthController.cs new file mode 100644 index 0000000..1196e1a --- /dev/null +++ b/Versum/Controllers/AuthController.cs @@ -0,0 +1,132 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Versum.Context; + +namespace Versum.Controllers +{ + + + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly IAuthService _authService; + private readonly IEmailService _emailService; + + public AuthController(IAuthService authService, IEmailService emailService) + { + _authService = authService; + _emailService = emailService; + } + + + + [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 { token = error, 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 + { + 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 + + return Ok(new { token = resultMessage, userGmail, username, 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 ResetPassword([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 = "Пароль успішно змінено" }); + } + + [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( + [FromServices] ApplicationDbContext db) + { + return await db.Users.OrderByDescending(u => u.CreatedAt).ToListAsync(); + } + + + + } +} 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/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/Controllers/DictController.cs b/Versum/Controllers/DictController.cs new file mode 100644 index 0000000..5d4bb93 --- /dev/null +++ b/Versum/Controllers/DictController.cs @@ -0,0 +1,101 @@ +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("add-phrase")] + [Authorize] + public async Task AddPhrase([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, 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 GetDictionary() + { + + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success,phrases,error) = await _dictService.GetDictionaryAsync(userId); + + if (!success) + { + if (error == "UserNotFound") return NotFound(new { message = "Користувача не знайдено" }); + + return BadRequest(new { message = error }); + } + + return Ok(phrases); + } + } +} \ No newline at end of file diff --git a/Versum/Controllers/FeedController.cs b/Versum/Controllers/FeedController.cs new file mode 100644 index 0000000..3b3b3b5 --- /dev/null +++ b/Versum/Controllers/FeedController.cs @@ -0,0 +1,41 @@ +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; + } + + [AllowAnonymous] + [HttpGet] + public async Task>> GetFeed([FromQuery] int limit = 20) + { + int? currentUserId = null; + + if (User.Identity != null && User.Identity.IsAuthenticated) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (int.TryParse(userIdClaim, out int parsedId)) + { + currentUserId = parsedId; + } + } + + var feed = await _feedService.GetSmartFeedAsync(currentUserId, limit); + + return Ok(feed); + } + } +} \ No newline at end of file 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/Controllers/PostsController.cs b/Versum/Controllers/PostsController.cs new file mode 100644 index 0000000..2da7084 --- /dev/null +++ b/Versum/Controllers/PostsController.cs @@ -0,0 +1,196 @@ +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 PostsController : ControllerBase + { + + private readonly IPostService _postService; + private readonly IProfileService _profileService; + + public PostsController(IPostService postService, IProfileService profileService) + { + + _postService = postService; + _profileService = profileService; + } + + + + + [HttpPost("{postId}/publish-draft")] + [Authorize] + public async Task PublishDraft(int postId) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success, error) = await _postService.PublishDraftAsync(postId, userId); + if (!success) + { + if (error == "AuthorNotFound") return NotFound(new { message = "Профіль автора не знайдено" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Твір успішно опубліковано" + }); + + } + + + [HttpPost("create-draft")] + [Authorize] + public async Task CreateDraft([FromBody] CreateDraftDto dto) + { + + 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 + }); + } + + [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 = "Чернетку не знайдено" }); + if (error == "You can't edit published writings") return Conflict(new { message = "Твір уже опубліковано" }); + if (error == "YouAreNotAnOwnerOfDraft") return NotFound(new { message = "Ви нє автором чернетки" }); + return BadRequest(new { message = error }); + } + + return StatusCode(201, new + { + message = "Чернетку збережено", + + }); + } + + //---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] PostQueryDto query) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int authorId)) + { + return Unauthorized(); + } + var drafts = await _postService.GetUserDraftsAsync(authorId, query); + return Ok(drafts); + } + + //---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 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}")] + 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) + { + + 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 = "Твір успішно видалено" }); + } + + [HttpGet("get-genres")] + 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/Controllers/ProfileController.cs b/Versum/Controllers/ProfileController.cs new file mode 100644 index 0000000..d59de7d --- /dev/null +++ b/Versum/Controllers/ProfileController.cs @@ -0,0 +1,121 @@ +using global::Versum.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +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 = "Профіль успішно оновлено" }); + } + + [HttpGet("{username}")] + public async Task GetProfile(string username) + { + 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) + { + return NotFound(new { message = "Користувача не знайдено" }); + } + + 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 = "Акаунт успішно видалено та анонімізовано" }); + } + + [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 = "Статус підписки змінено" }); + } + + [HttpGet("{username}/followings")] + 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 new file mode 100644 index 0000000..9f1c5f3 --- /dev/null +++ b/Versum/Controllers/SavingsController.cs @@ -0,0 +1,91 @@ +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([FromQuery] PostQueryDto query) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(userIdClaim, out int userId)) + { + return Unauthorized(); + } + + var (success,savings, error) = await _savingsService.GetSavedPostAsync(userId, query); + if (!success) + { + return BadRequest(new { message = error }); + } + + return Ok(savings); + } + } +} 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/DbContext/ApplicationDbContext.cs b/Versum/DbContext/ApplicationDbContext.cs new file mode 100644 index 0000000..30b3fed --- /dev/null +++ b/Versum/DbContext/ApplicationDbContext.cs @@ -0,0 +1,134 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Models; + +namespace Versum.Context +{ + public class ApplicationDbContext : DbContext + { + public ApplicationDbContext(DbContextOptions options) + : base(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; } + 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 PostReactions { get; set; } = null!; + + public DbSet Likes { get; set; } = null!; + public DbSet Comments { get; set; } = null!; + 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); + + modelBuilder.Entity() + .HasOne(p => p.Author) + .WithMany(a => a.Posts) + .HasForeignKey(p => p.AuthorId); + + modelBuilder.Entity() + .HasMany(p => p.Posts) + .WithMany(g => g.Genres); + + modelBuilder.Entity(entity => + { + + entity.HasKey(f => f.Id); + + + entity.HasOne(f => f.Follower) + .WithMany() + .HasForeignKey(f => f.FollowerId); + + entity.HasOne(f => f.Following) + .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(); + + + 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() + .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/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/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/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/DeleteAccountDto.cs b/Versum/Dtos/DeleteAccountDto.cs new file mode 100644 index 0000000..69ef486 --- /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/Dtos/DeletePhraseDto.cs b/Versum/Dtos/DeletePhraseDto.cs new file mode 100644 index 0000000..e0db9fc --- /dev/null +++ b/Versum/Dtos/DeletePhraseDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class DeletePhraseDto + { + [Required] + public int Id { get; set; } + } +} diff --git a/Versum/Dtos/DictDto.cs b/Versum/Dtos/DictDto.cs new file mode 100644 index 0000000..8a368ad --- /dev/null +++ b/Versum/Dtos/DictDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Versum.Dtos +{ + public class DictDto + { + [Required] + public int PostId { get; set; } + + [Required] + [MaxLength(200)] public string Phrase { get; set; } = string.Empty; + + [Required] + [MaxLength(600)] public string Description { get; set; } = string.Empty; + + [Required] + [MaxLength(60)] 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..5a13196 --- /dev/null +++ b/Versum/Dtos/DictResponceDto.cs @@ -0,0 +1,13 @@ +namespace Versum.Dtos +{ + 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; + public DateTime CreatedAt { get; set; } + } +} 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/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/Dtos/LoginDto.cs b/Versum/Dtos/LoginDto.cs new file mode 100644 index 0000000..f8497fa --- /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/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/Dtos/PostDto.cs b/Versum/Dtos/PostDto.cs new file mode 100644 index 0000000..8a825a6 --- /dev/null +++ b/Versum/Dtos/PostDto.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + + +namespace Versum.Dtos +{ + public class PostDto + { + [Required(ErrorMessage = "Введіть назву твору")] + [MaxLength(100, ErrorMessage = "Максимум 100 символів")] + public string Title { get; set; } = ""; + + + [MaxLength(600, ErrorMessage = "Максимум 600 символів")] + public string Description { get; set; } = ""; + + + [MaxLength(500000, ErrorMessage = "Максимум 500000 символів")] + public string Content { get; set; } = ""; + public List Genres { get; set; } = []; + } +} \ No newline at end of file diff --git a/Versum/Dtos/PostGetDto.cs b/Versum/Dtos/PostGetDto.cs new file mode 100644 index 0000000..d959fce --- /dev/null +++ b/Versum/Dtos/PostGetDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; + + +namespace Versum.Dtos +{ + public class PostGetDto + { + 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(); + 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/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/RegisterDto.cs b/Versum/Dtos/RegisterDto.cs new file mode 100644 index 0000000..1c8bc12 --- /dev/null +++ b/Versum/Dtos/RegisterDto.cs @@ -0,0 +1,31 @@ + +using System.ComponentModel.DataAnnotations; + + +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-ти символів")] + public string Username { get; set; } = ""; + + [Required(ErrorMessage = "Введіть свій пароль")] + [StringLength(20, MinimumLength = 8, + ErrorMessage = "Пароль має містити від 8 до 20 символів")] + [RegularExpression(@"^\S+$", + ErrorMessage = "Пароль не може містити пробіли")] + + public string Password { get; set; } = ""; + + + [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/ResetPasswordDto.cs b/Versum/Dtos/ResetPasswordDto.cs new file mode 100644 index 0000000..afc431d --- /dev/null +++ b/Versum/Dtos/ResetPasswordDto.cs @@ -0,0 +1,24 @@ +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+$", + 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..5844831 --- /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")] + [MaxLength(50, ErrorMessage = "Поле email не може містити більше 50-ти символів")] + public string Email { get; set; } = string.Empty; + } +} 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/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/UserProfileDto.cs b/Versum/Dtos/UserProfileDto.cs new file mode 100644 index 0000000..5f35310 --- /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/Dtos/UserProfileResponseDto.cs b/Versum/Dtos/UserProfileResponseDto.cs new file mode 100644 index 0000000..1fdf74a --- /dev/null +++ b/Versum/Dtos/UserProfileResponseDto.cs @@ -0,0 +1,16 @@ +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; } + 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/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/Extensions/PostExtensions.cs b/Versum/Extensions/PostExtensions.cs new file mode 100644 index 0000000..b0a410d --- /dev/null +++ b/Versum/Extensions/PostExtensions.cs @@ -0,0 +1,73 @@ +using System.Linq; +using System.Linq.Expressions; +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 && !p.IsDeleted); + } + + public static IQueryable OnlyDrafts(this IQueryable query) + { + return query.Where(p => p.IsDraft && !p.IsDeleted); + } + + 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 Expression> AsPostGetDto(int? currentUserId) => 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(), + LikesCount = p.LikesCount, + CommentsCount = p.CommentsCount, + 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) + { + return query.Select(AsPostGetDto(currentUserId)); + } + + public static PostGetDto PostToPostGetDto(this Post post, int? currentUserId) + { + return AsPostGetDto(currentUserId).Compile()(post); + } +} diff --git a/Versum/Hubs/NotificationHub.cs b/Versum/Hubs/NotificationHub.cs new file mode 100644 index 0000000..b431dbe --- /dev/null +++ b/Versum/Hubs/NotificationHub.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Versum.Hubs +{ + [Authorize] + public class NotificationHub : Hub + { + public override async Task OnConnectedAsync() + { + Console.WriteLine($"User Connected: {Context.UserIdentifier} with ConnectionId: {Context.ConnectionId}"); + await base.OnConnectedAsync(); + } + } +} \ No newline at end of file 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/20260513211501_Added-follows.Designer.cs b/Versum/Migrations/20260513211501_Added-follows.Designer.cs new file mode 100644 index 0000000..88ab7cb --- /dev/null +++ b/Versum/Migrations/20260513211501_Added-follows.Designer.cs @@ -0,0 +1,334 @@ +// +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("20260513211501_Added-follows")] + partial class Addedfollows + { + /// + 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("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.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/20260513211501_Added-follows.cs b/Versum/Migrations/20260513211501_Added-follows.cs new file mode 100644 index 0000000..c1f7cc8 --- /dev/null +++ b/Versum/Migrations/20260513211501_Added-follows.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Versum.Migrations +{ + /// + public partial class Addedfollows : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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 + { + 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.Cascade); + table.ForeignKey( + name: "FK_Follows_Users_FollowingId", + column: x => x.FollowingId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Follows_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Posts_UserId", + table: "Posts", + column: "UserId"); + + 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"); + + 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: "FollowingCount", + table: "Users"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Posts"); + } + } +} 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/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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..79ceb4b --- /dev/null +++ b/Versum/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,618 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Versum.Context; + +#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("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/Models/Author.cs b/Versum/Models/Author.cs new file mode 100644 index 0000000..01ba267 --- /dev/null +++ b/Versum/Models/Author.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Versum.Models +{ + public class Author + { + + + [Key, ForeignKey("User")] + public int AuthorId { get; set; } + + [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/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/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/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/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/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/Models/Post.cs b/Versum/Models/Post.cs new file mode 100644 index 0000000..5f7ca7e --- /dev/null +++ b/Versum/Models/Post.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Versum.Models; + +namespace Versum +{ + public class Post + { + public int Id { get; set; } + [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 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; } + 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(); + public ICollection Savings { get; set; } = new List(); + + } +} 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/Models/Saving.cs b/Versum/Models/Saving.cs new file mode 100644 index 0000000..939779b --- /dev/null +++ b/Versum/Models/Saving.cs @@ -0,0 +1,12 @@ +namespace Versum.Models +{ + public class Saving + { + + 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/Models/User.cs b/Versum/Models/User.cs new file mode 100644 index 0000000..82142ce --- /dev/null +++ b/Versum/Models/User.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using Versum.Models; +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 Email { get; set; } = ""; + + + public bool IsEmailConfirmed { get; set; } = false; // Did user confirm email + + 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; } + public DateTime? ResetTokenExpires { get; set; } + + 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 Followers { get; set; } = new List(); + + } +} diff --git a/Versum/Models/UserProfile.cs b/Versum/Models/UserProfile.cs new file mode 100644 index 0000000..f52e4b3 --- /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/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/Program.cs b/Versum/Program.cs new file mode 100644 index 0000000..7df1e68 --- /dev/null +++ b/Versum/Program.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +using Microsoft.OpenApi; + +using System.Text; +using Versum; +using Versum.Hubs; +using Versum.Services; + +var builder = WebApplication.CreateBuilder(args); + + +builder.Services.AddAuthorization(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSignalR(); + +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(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +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()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +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/Services/AuthService.cs b/Versum/Services/AuthService.cs new file mode 100644 index 0000000..7179962 --- /dev/null +++ b/Versum/Services/AuthService.cs @@ -0,0 +1,249 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Versum.Dtos; +using Versum.Context; + +namespace Versum.Services +{ + public class AuthService : IAuthService + { + + private readonly ApplicationDbContext _db; + private readonly IEmailService _emailService; + private readonly IConfiguration _configuration; + public AuthService(ApplicationDbContext db, IEmailService emailService, IConfiguration configuration) + { + _db = db; + _emailService = emailService; + _configuration = configuration; + } + 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"); + + + string passwordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password); + // Hashing password before saving by algorythm + // BCrypt from NuGet packet BCrypt.Net-Next; + + + 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(registerToken)); + RegisterTokenHash = Convert.ToBase64String(bytes); + } + + var confLimit = DateTime.UtcNow.AddHours(24); // email confirmation could be valid only during 24 h + + + + var user = new User + { + Username = dto.Username, + + PasswordHash = passwordHash, + + Email = dto.Email, + + EmailConfirmationTokenHash = RegisterTokenHash, + EmailTokenExpiryDate = confLimit + + }; + + var addedUser = _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); + + + string jwtToken = GenerateJwtToken(addedUser.Entity); + + + return (true, jwtToken, 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) + { + dto.UsernameOrGmail = dto.UsernameOrGmail.ToLower(); + + 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); + } + + + 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) + { + dto.Email = dto.Email.ToLower(); + 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 + await _emailService.SendResetCodeEmailAsync(user.Email, user.Username, ResetToken); + + return (true, null); + } + catch (Exception ex) + { + return (false, "Помилка на сервері при обробці запиту"); + } + } + + 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 + ); + 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); + user.PasswordHash = passwordHash; + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (DbUpdateException ex) + { + return (false, "Сталася помилка при зверненні до бази даних."); + } + catch (Exception ex) + { + return (false, "Сталася непередбачувана помилка на сервері."); + } + } + + 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 + ); + if (user == null) + { + return (false, "Недійсний токен."); + } + if (user.ResetTokenExpires < DateTime.UtcNow) + { + return (false, "Термін дії токена вичерпано. Запитуйте відновлення знову."); + } + return (true, null); + + } + } +} diff --git a/Versum/Services/BCAuthorService.cs b/Versum/Services/BCAuthorService.cs new file mode 100644 index 0000000..1915300 --- /dev/null +++ b/Versum/Services/BCAuthorService.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Dtos; +using Versum.Models; +using Versum.Context; + +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/CommentLikeService.cs b/Versum/Services/CommentLikeService.cs new file mode 100644 index 0000000..2d5cdb1 --- /dev/null +++ b/Versum/Services/CommentLikeService.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; + +namespace Versum.Services +{ + public class CommentLikeService : ICommentLikeService + { + private readonly ApplicationDbContext _db; + private readonly INotificationService _notificationService; + private readonly IProfileService _profileService; + + 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) + { + 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++; + + //Notification + var username = await _profileService.GetUsernameByUserIdAsync(userId); + await _notificationService.SendLikeNotificationAsync(post.AuthorId, username ?? "Хтось", post.Title); + } + + 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++; + + //Notification + var username = await _profileService.GetUsernameByUserIdAsync(userId); + await _notificationService.SendCommentNotificationAsync(post.AuthorId, username ?? "Хтось", post.Title); + + 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/DictService.cs b/Versum/Services/DictService.cs new file mode 100644 index 0000000..7a6ef8a --- /dev/null +++ b/Versum/Services/DictService.cs @@ -0,0 +1,95 @@ +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, DictDto dto) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return (false, "UserNotFound"); + + 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.Id == dto.Id && !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)> 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() + .Where(d => d.UserId == userId && !d.IsDeleted) + .OrderByDescending(d => d.CreatedAt) + .Select(d => new DictResponceDto + { + 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/EmailService.cs b/Versum/Services/EmailService.cs new file mode 100644 index 0000000..d670608 --- /dev/null +++ b/Versum/Services/EmailService.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Hosting; +using MailKit.Net.Smtp; +using MimeKit; +using Microsoft.Extensions.Options; +using Versum.Context; +public class EmailService : IEmailService +{ + private readonly IConfiguration _config; + private readonly IWebHostEnvironment _env; + + 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) + { + 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; + + // Email body + emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Html) + { + Text = htmlMessage + }; + + // Mailtrap + using (var client = new SmtpClient()) + { + // Connecting to Mailtrap server + try + { + 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); + } + 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/FeedService.cs b/Versum/Services/FeedService.cs new file mode 100644 index 0000000..73a5949 --- /dev/null +++ b/Versum/Services/FeedService.cs @@ -0,0 +1,111 @@ +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) + { + // ЛОГІКА ДЛЯ ГОСТЕЙ + 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 != userId) + .Select(p => new + { + PostId = p.Id, + 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 + ? 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(); + } + + var postIds = metadata.Select(x => x.PostId).ToList(); + + var dtos = await _context.Posts + .AsNoTracking() + .Where(p => postIds.Contains(p.Id)) + .ProjectToPostDto(currentUserId) + .ToListAsync(); + + var feedDtos = postIds + .Select(id => dtos.First(d => d.PostId == id)) + .ToList(); + + 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 = userId, + 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/IAuthService.cs b/Versum/Services/IAuthService.cs new file mode 100644 index 0000000..3f93fca --- /dev/null +++ b/Versum/Services/IAuthService.cs @@ -0,0 +1,17 @@ +using Versum.Dtos; + +public interface IAuthService +{ + 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) + // 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); + Task<(bool success, string? error)> ResetPasswordAsync(ResetPasswordDto dto); + Task<(bool success, string? error)> ResetPasswordTokenCheckAsync(ResetPasswordTokenDto dto); +} \ No newline at end of file diff --git a/Versum/Services/IBCAuthorService.cs b/Versum/Services/IBCAuthorService.cs new file mode 100644 index 0000000..46c9227 --- /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/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); + } +} diff --git a/Versum/Services/IDictService.cs b/Versum/Services/IDictService.cs new file mode 100644 index 0000000..0d5e8e2 --- /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, DictDto dto); + Task<(bool Success,List?, string? Error)> GetDictionaryAsync(int userId); + + Task<(bool Success, string? Error)> DeletePhraseAsync(int userId,DeletePhraseDto dto); + } + } + diff --git a/Versum/Services/IEmailService.cs b/Versum/Services/IEmailService.cs new file mode 100644 index 0000000..2258b1b --- /dev/null +++ b/Versum/Services/IEmailService.cs @@ -0,0 +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/Services/IFeedService.cs b/Versum/Services/IFeedService.cs new file mode 100644 index 0000000..9c56d56 --- /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 diff --git a/Versum/Services/INotificationService.cs b/Versum/Services/INotificationService.cs new file mode 100644 index 0000000..5d2b1c3 --- /dev/null +++ b/Versum/Services/INotificationService.cs @@ -0,0 +1,12 @@ +using Versum.Dtos; + +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/IPostService.cs b/Versum/Services/IPostService.cs new file mode 100644 index 0000000..6dc08b3 --- /dev/null +++ b/Versum/Services/IPostService.cs @@ -0,0 +1,18 @@ +using Versum.Core.Enums; +using Versum.Dtos; + +namespace Versum.Services +{ + public interface IPostService + { + 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); + Task> GetGenresAsync(); + Task<(bool Success, string? Error)> AddGenreAsync(string genreName); + } +} \ No newline at end of file diff --git a/Versum/Services/IProfileService.cs b/Versum/Services/IProfileService.cs new file mode 100644 index 0000000..7860f9d --- /dev/null +++ b/Versum/Services/IProfileService.cs @@ -0,0 +1,16 @@ +using Versum.Dtos; + +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); + + 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 new file mode 100644 index 0000000..148f47a --- /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, PostQueryDto query); + } +} diff --git a/Versum/Services/NotificationService.cs b/Versum/Services/NotificationService.cs new file mode 100644 index 0000000..d6daece --- /dev/null +++ b/Versum/Services/NotificationService.cs @@ -0,0 +1,149 @@ +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, ApplicationDbContext db) + { + _hubContext = hubContext; + _db = db; + } + + public async Task SendFollowNotificationAsync(int targetUserId, string actorUsername) + { + var notification = new Notification + { + TargetUserId = targetUserId, + Type = "Follower", + Message = $"{actorUsername} почав(ла) читати вас.", + 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 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 + .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 diff --git a/Versum/Services/PostService.cs b/Versum/Services/PostService.cs new file mode 100644 index 0000000..8bd1dc2 --- /dev/null +++ b/Versum/Services/PostService.cs @@ -0,0 +1,232 @@ +using Ganss.Xss; +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Extensions; +using Versum.Models; + +namespace Versum.Services +{ + public class PostService : IPostService + { + + private readonly ApplicationDbContext _db; + private readonly IProfileService _profileService; + private readonly INotificationService _notificationService; + + public PostService(ApplicationDbContext db, IProfileService profileService, INotificationService notificationService) + { + _db = db; + _profileService = profileService; + _notificationService = notificationService; + } + + + public async Task<(bool Success, string? Error)> PublishDraftAsync(int postId, int userId) + { + try + { + + var post = await _db.Posts.FirstOrDefaultAsync(p => p.Id == postId); + + if (post == null) return (false, "PostNotFound"); + + 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(); + + 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) + { + Console.WriteLine($"PublishDraftAsync error: {ex.Message}"); + return (false, "ServerError"); + } + } + + public async Task<(bool Success, string? Error, int? PostId)> CreateDraftAsync(int authorId, CreateDraftDto 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, + AuthorId = authorId, + IsDraft = true, + CreatedAt = DateTime.UtcNow + }; + + _db.Posts.Add(draftPost); + await _db.SaveChangesAsync(); + + return (true, null, draftPost.Id); + } + catch (Exception ex) + { + Console.WriteLine($"CreateDraftAsync error: {ex.Message}"); + return (false, "ServerError", null); + } + } + + + //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(query.Filter, query.Ascending) + .ProjectToPostDto(authorId) + .ToListAsync(); + } + + public async Task?> GetUserPostsAsync(int authorId, PostQueryDto query) + { + return await _db.Posts + .AsNoTracking() + .Where(p => p.AuthorId == authorId) + .OnlyPublished() + .ApplySorting(query.Filter, query.Ascending) + .ProjectToPostDto(authorId) + .ToListAsync(); + } + + public async Task<(PostGetDto?, string? Error)> GetPostAsync(int postId, int? userID) + { + var postDto = await _db.Posts + .AsNoTracking() + .Where(p => p.Id == postId + && !p.IsDeleted + && (!p.IsDraft || p.AuthorId == userID)) + .ProjectToPostDto(userID) + .FirstOrDefaultAsync(); + + if (postDto == null) + { + return (null, "Твір не знайдено або він ще не опублікований"); + } + + return (postDto, null); + } + + public async Task<(bool Success, string? Error)> UpdateDraftAsync(int postId,int userId, PostDto dto) + { + try + { + var draft = await _db.Posts + .Include(p => p.Genres) + .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"); + + 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; + draft.Content = sanitizer.Sanitize(dto.Content); + draft.Genres = _db.Genres.Where(g => dto.Genres.Contains(g.Name)).ToList(); + + await _db.SaveChangesAsync(); + return (true, null); + } + catch (Exception ex) + { + Console.WriteLine($"UpdateDraftAsync error: {ex.Message}"); + return (false, "ServerError"); + } + + } + + 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"); + } + } + 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"); + } + } + } +} diff --git a/Versum/Services/ProfileService.cs b/Versum/Services/ProfileService.cs new file mode 100644 index 0000000..5431851 --- /dev/null +++ b/Versum/Services/ProfileService.cs @@ -0,0 +1,207 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; +using Versum.Dtos; +using Versum.Models; +using Versum.Context; + +namespace Versum.Services +{ + public class ProfileService : IProfileService + { + + private readonly 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) + { + 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 && user.Username != dto.Username) + 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, "Сталася непередбачувана помилка на сервері."); + } + } + + 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 + { + Username = u.Username, + Name = u.Profile.Name ?? "none", + Bio = u.Profile.Bio ?? "none", + CreatedAt = u.CreatedAt, + IsAuthor = (u.AuthorProfile != null), + IsOwner = u.Id == claimedUserID, + 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 = claimedUserID != null && _db.Follows.Any(f => f.FollowerId == claimedUserID && f.FollowingId == u.Id) + }) + .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, "Неправильний пароль, введіть ще раз або вийдіть."); + + var shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); + + user.Email = $"del_{shortGuid}@anon.com"; // Близько 21 символу + user.Username = $"deleted_{shortGuid}"; // 13 символів + 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 ex) + { + var realError = ex.InnerException?.Message ?? ex.Message; + Console.WriteLine($"DB_ERROR при видаленні акаунта: {realError}"); + + return (false, "Сталася помилка при зверненні до бази даних. Перевірте консоль сервера."); + } + catch (Exception ex) + { + 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 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) + { + if (followerId == followingId) return (false, "Ви не можете підписатися на себе."); + + var existingFollow = await _db.Follows + .FirstOrDefaultAsync(f => f.FollowerId == followerId && f.FollowingId == followingId); + + + if (existingFollow != null) + { + _db.Follows.Remove(existingFollow); //unsub + } + else + { + _db.Follows.Add(new Follow { FollowerId = followerId, FollowingId = followingId }); + + //Notification + var username = await GetUsernameByUserIdAsync(followerId); + await _notificationService.SendFollowNotificationAsync(followingId, username ?? "Хтось"); + } + + 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(); + } + 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 new file mode 100644 index 0000000..5f0b163 --- /dev/null +++ b/Versum/Services/SavingsService.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using Versum.Context; +using Versum.Dtos; +using Versum.Models; +using Versum.Extensions; +using Ganss.Xss; + + +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 Saving + { + 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, PostQueryDto query) + { + var savings = await _db.Savings + .AsNoTracking() + .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); + } + } +} 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/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 new file mode 100644 index 0000000..7f50c78 --- /dev/null +++ b/Versum/Versum.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + PreserveNewest + + + + 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..d8e11cf --- /dev/null +++ b/Versum/appsettings.json @@ -0,0 +1,13 @@ +{ + "AppSettings": { + "BaseUrl": "https://backend.versum.social" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AllowedOrigins": [ "https://versum.social", "https://www.versum.social" ] +}