diff --git a/Govor.API.Tests/UnitTests/Infrastructure/Validators/FriendshipValidatorTests.cs b/Govor.API.Tests/UnitTests/Infrastructure/Validators/FriendshipValidatorTests.cs index cb42ea2..2b49311 100644 --- a/Govor.API.Tests/UnitTests/Infrastructure/Validators/FriendshipValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Infrastructure/Validators/FriendshipValidatorTests.cs @@ -28,6 +28,7 @@ public class FriendshipValidatorTests [Test] public void Given_ValidFriendship_When_Validate_Then_Returns_True() { + // Arrange var friendship = _fixture.Create(); // Act & Assert @@ -38,6 +39,7 @@ public class FriendshipValidatorTests [Test] public void Given_EmptyFriendshipId_When_Validate_Then_Returns_False() { + // Arrange var friendship = _fixture.Create(); friendship.Id = Guid.Empty; @@ -49,6 +51,7 @@ public class FriendshipValidatorTests [Test] public void Given_EmptyRequesterId_When_Validate_Then_Returns_False() { + // Arrange var friendship = _fixture.Create(); friendship.RequesterId = Guid.Empty; @@ -56,10 +59,23 @@ public class FriendshipValidatorTests Assert.Throws>( () => _friendshipValidator.Validate(friendship)); Assert.That(_friendshipValidator.TryValidate(friendship), Is.False); } + + [Test] + public void GivenSameRequesterId_When_Validate_Then_Returns_False() + { + // Arrange + var friendship = _fixture.Create(); + friendship.RequesterId = friendship.AddresseeId; + + // Act & Assert + Assert.Throws>( () => _friendshipValidator.Validate(friendship)); + Assert.That(_friendshipValidator.TryValidate(friendship), Is.False); + } [Test] public void Given_AddresseeId_When_Validate_Then_Returns_False() { + // Arrange var friendship = _fixture.Create(); friendship.AddresseeId = Guid.Empty; diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/FriendsController.cs index a221c8e..c9cb8b4 100644 --- a/Govor.API/Controllers/FriendsController.cs +++ b/Govor.API/Controllers/FriendsController.cs @@ -1,3 +1,7 @@ +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces; +using Govor.Contracts.DTOs; +using Govor.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,48 +13,151 @@ namespace Govor.API.Controllers; public class FriendsController : Controller { private readonly ILogger _logger; + private readonly IFriendsService _friendsService; - [HttpGet("search")] - public async Task Search([FromBody] string query) + public FriendsController(ILogger logger, IFriendsService friendsService) { - return BadRequest("Not a valid request"); + _logger = logger; + _friendsService = friendsService; + } + + [HttpGet("search")] + public async Task Search(string query) + { + try + { + var result = await _friendsService.SearchUsersAsync(query, GetCurrentUserId()); + + return Ok(BuildUserDtos(result)); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest("Something happened during the search... try again"); + } } [HttpPost("request")] - public async Task SendRequest([FromBody] Guid targetUserId) + public async Task SendRequest(Guid targetUserId) { - return BadRequest("Not a valid request"); + try + { + await _friendsService.SendFriendRequestAsync(targetUserId, GetCurrentUserId()); + return Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("The user tried to send a friend request to themselves"); + return BadRequest("You can't send a friend request to themselves"); + } + catch (RequestAlreadySentException ex) + { + _logger.LogWarning("The user tried to send a friend request but the request is already sent"); + return BadRequest("You already sent a friend request"); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest("Something happened during the request... try again"); + } } [HttpGet("requests")] public async Task GetIncomingRequests() { - return BadRequest("Not a valid request"); + try + { + _logger.LogInformation($"Getting incoming requests for user {GetCurrentUserId()}..."); + var result = await _friendsService.GetIncomingRequestsAsync(GetCurrentUserId()); + return Ok(BuildFriendshipDtos(result)); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest("You can't get real friend requests because we can't find your user data! Try again"); + } } [HttpPost("accept")] - public async Task AcceptFriend([FromBody] Guid requesterId) + public async Task AcceptFriend(Guid requesterId) { - return BadRequest("Not a valid request"); + try + { + await _friendsService.AcceptFriendRequestAsync(requesterId, GetCurrentUserId()); + return Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning("The user tried to accept a friend request but the request not exists"); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning("User tried to accept a friend request but addresseeId not equal userId"); + return BadRequest("You can't accept a friend request"); + } } [HttpGet] public async Task GetFriends() { - return BadRequest("Not a valid request"); + try + { + _logger.LogInformation($"Getting friends by user {GetCurrentUserId()}..."); + var result = await _friendsService.GetFriendsAsync(GetCurrentUserId()); + + return Ok(BuildUserDtos(result)); + } + catch(UnauthorizedAccessException ex) + { + _logger.LogError(ex, ex.Message); + return BadRequest("Something happened during the request... try again"); + } } private Guid GetCurrentUserId() { var userIdClaim = HttpContext.User?.FindFirst("userID")?.Value; - _logger.LogInformation("Claims: {Claims}", string.Join(", ", HttpContext.User?.Claims.Select(c => $"{c.Type}: {c.Value}") ?? new string[0])); - - if (string.IsNullOrEmpty(userIdClaim)) + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { - _logger.LogError("No userID claim found"); - return Guid.Empty; + throw new UnauthorizedAccessException("userID claim is missing or invalid"); + } + return userId; + } + + private List BuildUserDtos(IEnumerable users) + { + List userDtos = new List(); + + foreach (var user in users) + { + userDtos.Add(new UserDto() + { + Id = user.Id, + Description = user.Description, + Username = user.Username, + WasOnline = user.WasOnline, + IconId = user.IconId, + }); + } + return userDtos; + } + + private List BuildFriendshipDtos(List result) + { + List dtos = new List(); + + foreach (var friendship in result) + { + dtos.Add(new FriendshipDto() + { + Id = friendship.Id, + Status = friendship.Status, + AddresseeId = friendship.AddresseeId, + RequesterId = friendship.RequesterId, + }); } - return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty; + return dtos; } } \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 1435914..3658265 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -9,6 +9,7 @@ using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; using Govor.Core.Repositories.Admins; +using Govor.Core.Repositories.Friendships; using Govor.Core.Repositories.Invaites; using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; @@ -30,6 +31,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => { @@ -45,6 +47,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public static void AddValidators(this IServiceCollection services) @@ -54,6 +57,7 @@ public static class ConfigurationProgramExtensions services.AddScoped, MediaAttachmentsValidator>(); services.AddScoped, AdminValidator>(); services.AddScoped, InvitationValidator>(); + services.AddScoped, FriendshipValidator>(); } public static void AddGovorDbContext(this IServiceCollection services, IConfiguration configuration) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index d8a9cee..b54a5cb 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -98,14 +98,10 @@ public class ChatsHub : Hub private Guid GetUserId() { var userIdClaim = Context.User?.FindFirst("userID")?.Value; - _logger.LogInformation("Claims: {Claims}", string.Join(", ", Context.User?.Claims.Select(c => $"{c.Type}: {c.Value}") ?? new string[0])); - - if (string.IsNullOrEmpty(userIdClaim)) + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { - _logger.LogError("No userID claim found"); - return Guid.Empty; + throw new UnauthorizedAccessException("userID claim is missing or invalid"); } - - return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty; + return userId; } } \ No newline at end of file diff --git a/Govor.Application/Exceptions/FriendsService/RequestAlreadySentException.cs b/Govor.Application/Exceptions/FriendsService/RequestAlreadySentException.cs new file mode 100644 index 0000000..97f85d0 --- /dev/null +++ b/Govor.Application/Exceptions/FriendsService/RequestAlreadySentException.cs @@ -0,0 +1,6 @@ +using Govor.Core; + +namespace Govor.Application.Exceptions.FriendsService; + +public class RequestAlreadySentException(Guid fromId, Guid userId) + : GovorCoreException($"Request was already sent from {fromId} to {userId}") {} diff --git a/Govor.Application/Exceptions/FriendsService/SearchUsersException.cs b/Govor.Application/Exceptions/FriendsService/SearchUsersException.cs new file mode 100644 index 0000000..f51ebce --- /dev/null +++ b/Govor.Application/Exceptions/FriendsService/SearchUsersException.cs @@ -0,0 +1,13 @@ +using Govor.Core; + +namespace Govor.Application.Exceptions.FriendsService; + +public class SearchUsersException : GovorCoreException +{ + public SearchUsersException(string message, Exception innerException) + : base(message, innerException){} + + public SearchUsersException(string message) + : base(message){} + +} \ No newline at end of file diff --git a/Govor.Application/Exceptions/FriendsService/SendFriendRequestException.cs b/Govor.Application/Exceptions/FriendsService/SendFriendRequestException.cs new file mode 100644 index 0000000..0e0b78f --- /dev/null +++ b/Govor.Application/Exceptions/FriendsService/SendFriendRequestException.cs @@ -0,0 +1,6 @@ +using Govor.Core; + +namespace Govor.Application.Exceptions.FriendsService; + +public class SendFriendRequestException(Guid fromUserId, Guid toUserId, Exception exception) + : GovorCoreException($"Something happened when we try to send request from {fromUserId} to {toUserId}", exception); \ No newline at end of file diff --git a/Govor.Application/Interfaces/IFriendsService.cs b/Govor.Application/Interfaces/IFriendsService.cs new file mode 100644 index 0000000..0bcc233 --- /dev/null +++ b/Govor.Application/Interfaces/IFriendsService.cs @@ -0,0 +1,12 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces; + +public interface IFriendsService +{ + Task> SearchUsersAsync(string searchTerm, Guid currentId); + Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId); + Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId); + Task> GetFriendsAsync(Guid userId); + Task> GetIncomingRequestsAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/FriendsService.cs b/Govor.Application/Services/FriendsService.cs new file mode 100644 index 0000000..01d9068 --- /dev/null +++ b/Govor.Application/Services/FriendsService.cs @@ -0,0 +1,119 @@ +using Govor.Application.Exceptions; +using Govor.Application.Exceptions.FriendsService; +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services; + +public class FriendsService : IFriendsService +{ + private IUsersRepository _usersRepository; + private IFriendshipsRepository _friendshipsRepository; + + public FriendsService(IUsersRepository usersRepository, IFriendshipsRepository relationshipsRepository) + { + _usersRepository = usersRepository; + _friendshipsRepository = relationshipsRepository; + } + + public async Task> SearchUsersAsync(string searchTerm, Guid currentId) + { + var all = await _usersRepository.SearchPotentialFriendsAsync(currentId, searchTerm); + + try + { + var friends = await _friendshipsRepository.FindByUserIdAsync(currentId); + + friends = friends.Where(f => f.Status == FriendshipStatus.Accepted).ToList(); + + return all + .Where(u => u.Id != currentId && !friends.Select(f => f.RequesterId).Contains(u.Id)) + .ToList(); + } + catch (NotFoundByKeyException ex) + { + return all.Where(u => u.Id != currentId).ToList(); + } + catch (Exception ex) + { + throw new SearchUsersException($"When we try find friends by pattern {searchTerm} something wrong", ex); + } + } + + public async Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId) + { + if (fromUserId == toUserId) + throw new InvalidOperationException("Cannot send a request to self user"); + + if (_friendshipsRepository.Exist(fromUserId, toUserId)) + throw new RequestAlreadySentException(fromUserId, toUserId); + + await _friendshipsRepository.AddAsync(new Friendship() + { + Id = Guid.NewGuid(), + RequesterId = fromUserId, + AddresseeId = toUserId, + Status = FriendshipStatus.Pending + }); + } + + public async Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId) + { + try + { + var friendship = await _friendshipsRepository.GetByIdAsync(requestId); + + if (friendship.AddresseeId != currentUserId) + throw new UnauthorizedAccessException("You cannot accept this request"); + + if (friendship.Status != FriendshipStatus.Pending) + throw new InvalidOperationException("Request is already accepted"); + + friendship.Status = FriendshipStatus.Accepted; + await _friendshipsRepository.UpdateAsync(friendship); + } + catch (NotFoundByKeyException e) + { + throw new InvalidOperationException("Friendship not found! You cant accept request!", e); + } + } + + public async Task> GetFriendsAsync(Guid userId) + { + try + { + var user = await _usersRepository.FindByIdAsync(userId); + + var sent = user.SentFriendRequests + .Where(f => f.Status == FriendshipStatus.Accepted) + .Select(f => f.Addressee); + + var received = user.ReceivedFriendRequests + .Where(f => f.Status == FriendshipStatus.Accepted) + .Select(f => f.Requester); + + return sent.Concat(received).ToList(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } + + public async Task> GetIncomingRequestsAsync(Guid userId) + { + try + { + var user = await _usersRepository.FindByIdAsync(userId); + return user.ReceivedFriendRequests; + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } +} + diff --git a/Govor.Contracts/DTOs/FriendshipDto.cs b/Govor.Contracts/DTOs/FriendshipDto.cs new file mode 100644 index 0000000..85065e1 --- /dev/null +++ b/Govor.Contracts/DTOs/FriendshipDto.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; + +namespace Govor.Contracts.DTOs; + +public class FriendshipDto +{ + public Guid Id { get; set; } + public Guid RequesterId { get; set; } + public Guid AddresseeId { get; set; } + public FriendshipStatus Status { get; set; } +} \ No newline at end of file diff --git a/Govor.Contracts/DTOs/UserDto.cs b/Govor.Contracts/DTOs/UserDto.cs new file mode 100644 index 0000000..06b00cf --- /dev/null +++ b/Govor.Contracts/DTOs/UserDto.cs @@ -0,0 +1,10 @@ +namespace Govor.Contracts.DTOs; + +public class UserDto +{ + public Guid Id { get; set; } + public string Username { get; set; } + public string Description { get; set; } + public DateTime WasOnline { get; set; } + public Guid IconId {get; set;} +} \ No newline at end of file diff --git a/Govor.Core/Infrastructure/Validators/FriendshipValidator.cs b/Govor.Core/Infrastructure/Validators/FriendshipValidator.cs index 3b11e00..96c0c40 100644 --- a/Govor.Core/Infrastructure/Validators/FriendshipValidator.cs +++ b/Govor.Core/Infrastructure/Validators/FriendshipValidator.cs @@ -16,6 +16,8 @@ public class FriendshipValidator : IObjectValidator throw new ArgumentException("Addresses cannot be empty", nameof(friendship.AddresseeId)); if(friendship.RequesterId == Guid.Empty) throw new ArgumentException("Requester cannot be empty", nameof(friendship.RequesterId)); + if(friendship.AddresseeId == friendship.RequesterId) + throw new ArgumentException("Addresses cannot be same", nameof(friendship.AddresseeId)); } catch (Exception e) { diff --git a/Govor.Core/Repositories/Users/IUsersReader.cs b/Govor.Core/Repositories/Users/IUsersReader.cs index 46326fe..277c906 100644 --- a/Govor.Core/Repositories/Users/IUsersReader.cs +++ b/Govor.Core/Repositories/Users/IUsersReader.cs @@ -9,5 +9,6 @@ public interface IUsersReader public Task> FindByRangeIdAsync(IEnumerable ids); public Task FindByUsernameAsync(string username); public Task> FindByRangeUsernamesAsync(IEnumerable usernames); + public Task> SearchPotentialFriendsAsync(Guid currentUserId, string query); public Task> FindUsersByCreatedDateAsync(DateOnly createdDate); } \ No newline at end of file diff --git a/Govor.Data/Migrations/20250627101322_friendships.Designer.cs b/Govor.Data/Migrations/20250627101322_friendships.Designer.cs new file mode 100644 index 0000000..e3350fc --- /dev/null +++ b/Govor.Data/Migrations/20250627101322_friendships.Designer.cs @@ -0,0 +1,467 @@ +// +using System; +using System.Collections.Generic; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250627101322_friendships")] + partial class friendships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.Admin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Admins") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("InviteCode") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("IsChannel") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("MaxParticipants") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedKey") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("PrivateChatId") + .HasColumnType("uuid"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("RecipientType") + .HasColumnType("integer"); + + b.Property("ReplyToMessageId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PrivateChatId"); + + b.HasIndex("ReplyToMessageId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserAId") + .HasColumnType("uuid"); + + b.Property("UserBId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IconId") + .HasColumnType("uuid"); + + b.Property("InviteId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.Property("WasOnline") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Admin", b => + { + b.HasOne("Govor.Core.Models.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Message", b => + { + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + + b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") + .WithMany() + .HasForeignKey("ReplyToMessageId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ReplyToMessage"); + }); + + modelBuilder.Entity("Govor.Core.Models.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.MessageView", b => + { + b.HasOne("Govor.Core.Models.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250627101322_friendships.cs b/Govor.Data/Migrations/20250627101322_friendships.cs new file mode 100644 index 0000000..37cfa43 --- /dev/null +++ b/Govor.Data/Migrations/20250627101322_friendships.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class friendships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PrivateChatId", + table: "Messages", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "Friendships", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RequesterId = table.Column(type: "uuid", nullable: false), + AddresseeId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Friendships", x => x.Id); + table.ForeignKey( + name: "FK_Friendships_Users_AddresseeId", + column: x => x.AddresseeId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Friendships_Users_RequesterId", + column: x => x.RequesterId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "PrivateChats", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserAId = table.Column(type: "uuid", nullable: false), + UserBId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PrivateChats", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_PrivateChatId", + table: "Messages", + column: "PrivateChatId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_AddresseeId", + table: "Friendships", + column: "AddresseeId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_RequesterId", + table: "Friendships", + column: "RequesterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Messages_PrivateChats_PrivateChatId", + table: "Messages", + column: "PrivateChatId", + principalTable: "PrivateChats", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Messages_PrivateChats_PrivateChatId", + table: "Messages"); + + migrationBuilder.DropTable( + name: "Friendships"); + + migrationBuilder.DropTable( + name: "PrivateChats"); + + migrationBuilder.DropIndex( + name: "IX_Messages_PrivateChatId", + table: "Messages"); + + migrationBuilder.DropColumn( + name: "PrivateChatId", + table: "Messages"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index cc407e7..703662e 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -62,6 +62,30 @@ namespace Govor.Data.Migrations b.ToTable("ChatGroups"); }); + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => { b.Property("Id") @@ -181,6 +205,9 @@ namespace Govor.Data.Migrations b.Property("IsEdited") .HasColumnType("boolean"); + b.Property("PrivateChatId") + .HasColumnType("uuid"); + b.Property("RecipientId") .HasColumnType("uuid"); @@ -198,6 +225,8 @@ namespace Govor.Data.Migrations b.HasKey("Id"); + b.HasIndex("PrivateChatId"); + b.HasIndex("ReplyToMessageId"); b.ToTable("Messages"); @@ -256,6 +285,23 @@ namespace Govor.Data.Migrations b.ToTable("MessageViews"); }); + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserAId") + .HasColumnType("uuid"); + + b.Property("UserBId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + modelBuilder.Entity("Govor.Core.Models.User", b => { b.Property("Id") @@ -304,6 +350,25 @@ namespace Govor.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + modelBuilder.Entity("Govor.Core.Models.MediaAttachments", b => { b.HasOne("Govor.Core.Models.Message", "Message") @@ -317,6 +382,10 @@ namespace Govor.Data.Migrations modelBuilder.Entity("Govor.Core.Models.Message", b => { + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + b.HasOne("Govor.Core.Models.Message", "ReplyToMessage") .WithMany() .HasForeignKey("ReplyToMessageId") @@ -377,6 +446,18 @@ namespace Govor.Data.Migrations b.Navigation("Reactions"); }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); #pragma warning restore 612, 618 } } diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index e346d0a..5ef3c9e 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -22,6 +22,9 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() + .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .ToListOrThrowIfEmpty(new NotFoundException("Users in Database not exists")); } @@ -33,6 +36,8 @@ public class UsersRepository : IUsersRepository return await _context.Users .AsNoTracking() .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .FirstOrDefaultAsync(x => x.Id == id) ?? throw new NotFoundByKeyException(id, "User with given id does not exist"); } @@ -46,6 +51,8 @@ public class UsersRepository : IUsersRepository .AsNoTracking() .Where(x => ids.Contains(x.Id)) .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .ToListOrThrowIfEmpty(new NotFoundByKeyException>(ids,"Users with given ids not found")); } @@ -57,10 +64,30 @@ public class UsersRepository : IUsersRepository return await _context.Users .AsNoTracking() .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .FirstOrDefaultAsync(x => x.Username == username) ?? throw new NotFoundByKeyException(username, "User with given username does not exist"); } + public async Task> SearchPotentialFriendsAsync(Guid currentUserId, string query) + { + return await _context.Users + .AsNoTracking() + .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) + .Where(u => u.Id != currentUserId && + u.Username.ToLower().Contains(query.ToLower()) && + !_context.Friendships.Any(f => + (f.RequesterId == currentUserId && f.AddresseeId == u.Id) || + (f.RequesterId == u.Id && f.AddresseeId == currentUserId))) + .Take(20) + .OrderBy(u => u.Username) + .ToListAsync(); + } + + public async Task> FindByRangeUsernamesAsync(IEnumerable usernames) { if (usernames is null || !usernames.Any()) @@ -70,6 +97,8 @@ public class UsersRepository : IUsersRepository .AsNoTracking() .Where(x => usernames.Contains(x.Username)) .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .ToListOrThrowIfEmpty(new NotFoundByKeyException>(usernames, "Users with given usernames not found")); } @@ -82,6 +111,8 @@ public class UsersRepository : IUsersRepository .AsNoTracking() .Where(u => createdDate == u.CreatedOn) .Include(u => u.Invite) + .Include(u => u.ReceivedFriendRequests) + .Include(u => u.SentFriendRequests) .ToListOrThrowIfEmpty(new NotFoundByKeyException(createdDate, "Users with given created date do not exist")); }