diff --git a/AskFm/AskFm.API/AskFm.API.csproj b/AskFm/AskFm.API/AskFm.API.csproj index 59c3c20..60af690 100644 --- a/AskFm/AskFm.API/AskFm.API.csproj +++ b/AskFm/AskFm.API/AskFm.API.csproj @@ -21,7 +21,10 @@ - + + + + @@ -29,4 +32,28 @@ + + net9.0 + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/AskFm/AskFm.API/Controllers/NotificationController.cs b/AskFm/AskFm.API/Controllers/NotificationController.cs new file mode 100644 index 0000000..a7855da --- /dev/null +++ b/AskFm/AskFm.API/Controllers/NotificationController.cs @@ -0,0 +1,120 @@ +using System.Security.Claims; +using AskFm.BLL.DTO; +using AskFm.BLL.Services; +using AskFm.DAL.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AskFm.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [Authorize] + public class NotificationController : ControllerBase + { + private readonly INotificationService _notificationService; + + public NotificationController(INotificationService notificationService) + { + _notificationService = notificationService; + } + + [HttpGet] + public async Task GetUserNotifications([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) + { + try + { + var userId = GetCurrentUserId(); + var notifications = await _notificationService.GetUserNotifications(userId, pageNumber, pageSize); + return Ok(notifications); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("type/{category}")] + public async Task GetNotificationsByType(string category, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10) + { + try + { + var userId = GetCurrentUserId(); + var response = await _notificationService.GetNotificationsByType(userId, category, pageNumber, pageSize); + return Ok(response); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode(500, ex.Message); + } + } + + [HttpPut("{notificationId}/read")] + public async Task MarkNotificationAsRead(int notificationId) + { + try + { + var userId = GetCurrentUserId(); + var result = await _notificationService.MarkNotificationAsRead(notificationId, userId); + return Ok(new { message = result }); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + return Forbid(ex.Message); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPut("read-all")] + public async Task MarkAllNotificationsAsRead() + { + try + { + var userId = GetCurrentUserId(); + var result = await _notificationService.MarkAllNotificationsAsRead(userId); + return Ok(new { message = result }); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task CreateNotification([FromBody] CreateNotificationRequest request) + { + try + { + await _notificationService.CreateNotification(request.UserId, request.Type, request.ResourceId, request.Message); + return Ok(new { message = "Notification created successfully" }); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + private int GetCurrentUserId() + { + // Use the standard NameIdentifier claim + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out int userId)) + { + throw new UnauthorizedAccessException("Invalid user token"); + } + return userId; + } + } +} \ No newline at end of file diff --git a/AskFm/AskFm.API/Program.cs b/AskFm/AskFm.API/Program.cs index efe32e8..06e84f6 100644 --- a/AskFm/AskFm.API/Program.cs +++ b/AskFm/AskFm.API/Program.cs @@ -1,5 +1,4 @@ using System.Text; -using AskFm.BLL.Services; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using AskFm.DAL; @@ -9,10 +8,13 @@ using DotNetEnv; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore.Proxies; +using AskFm.BLL.Hub; +using AskFm.BLL.Services; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using AskFm.BLL.Services.UserIdentityService; +using Swashbuckle.AspNetCore.SwaggerGen; using Castle.Components.DictionaryAdapter.Xml; namespace AskFm.API; @@ -43,6 +45,9 @@ public static void Main(string[] args) .UseSqlServer(ConnectionString)); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -91,6 +96,39 @@ public static void Main(string[] args) throw new Exception("jwtOptions is null"); } + // Enhanced SignalR Configuration + builder.Services.AddSignalR(options => + { + options.EnableDetailedErrors = true; + options.KeepAliveInterval = TimeSpan.FromSeconds(15); + options.ClientTimeoutInterval = TimeSpan.FromSeconds(30); + options.HandshakeTimeout = TimeSpan.FromSeconds(15); + }); + + // CORS Configuration for SignalR + builder.Services.AddCors(options => + { + options.AddPolicy("SignalRPolicy", policy => + { + // Option 1: Allow any origin (for development only) + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + + // Option 2: Specific origins (uncomment and modify when you know frontend URLs) + // policy.WithOrigins( + // "http://localhost:3000", // React default + // "http://localhost:4200", // Angular default + // "http://localhost:8080", // Vue default + // "http://localhost:5173", // Vite default + // "https://yourdomain.com" // Production domain + // ) + // .AllowAnyMethod() + // .AllowAnyHeader() + // .AllowCredentials(); + }); + }); + builder.Services.Configure(Options => { Options.Issuer = Environment.GetEnvironmentVariable("ISSUER"); @@ -120,6 +158,21 @@ public static void Main(string[] args) ClockSkew = TimeSpan.FromMinutes(0) }; + // Enable JWT authentication for SignalR + Options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/notificationHub")) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; }); builder.Services.AddAuthorization(); builder.Services.AddIdentity>(options => @@ -167,17 +220,26 @@ public static void Main(string[] args) if (app.Environment.IsDevelopment()) { app.UseSwagger(); - app.UseSwaggerUI(); app.MapOpenApi(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/openapi/v1.json", "api"); + }); } app.UseHttpsRedirection(); + + // Apply CORS before authentication + app.UseCors("SignalRPolicy"); + app.UseAuthentication(); app.UseAuthorization(); - app.MapControllers(); + // Map SignalR Hub + app.MapHub("/notificationHub"); + app.Run(); } } \ No newline at end of file diff --git a/AskFm/AskFm.API/Properties/launchSettings.json b/AskFm/AskFm.API/Properties/launchSettings.json index 280f933..75f6fa6 100644 --- a/AskFm/AskFm.API/Properties/launchSettings.json +++ b/AskFm/AskFm.API/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "swagger", "applicationUrl": "http://localhost:5180", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/AskFm/AskFm.BLL/AskFm.BLL.csproj b/AskFm/AskFm.BLL/AskFm.BLL.csproj index 09d480a..b92a98e 100644 --- a/AskFm/AskFm.BLL/AskFm.BLL.csproj +++ b/AskFm/AskFm.BLL/AskFm.BLL.csproj @@ -1,19 +1,19 @@  - - - net9.0 - enable - enable - - - - - - - - - + + net9.0 + enable + enable + + + + + + + + + - + + diff --git a/AskFm/AskFm.BLL/DTO/ActorDto.cs b/AskFm/AskFm.BLL/DTO/ActorDto.cs new file mode 100644 index 0000000..029e938 --- /dev/null +++ b/AskFm/AskFm.BLL/DTO/ActorDto.cs @@ -0,0 +1,8 @@ +namespace AskFm.BLL.DTO; + +public class ActorDto +{ + public int Id { get; set; } + public string Username { get; set; } + public string AvatarPath { get; set; } +} \ No newline at end of file diff --git a/AskFm/AskFm.BLL/DTO/CreateNotificationRequest.cs b/AskFm/AskFm.BLL/DTO/CreateNotificationRequest.cs new file mode 100644 index 0000000..1cd7eb2 --- /dev/null +++ b/AskFm/AskFm.BLL/DTO/CreateNotificationRequest.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AskFm.DAL.Enums; + +namespace AskFm.BLL.DTO +{ + public class CreateNotificationRequest + { + public int UserId { get; set; } + public NotificationStatus Type { get; set; } + public int ResourceId { get; set; } + public string Message { get; set; } + } +} \ No newline at end of file diff --git a/AskFm/AskFm.BLL/DTO/NotificationDto.cs b/AskFm/AskFm.BLL/DTO/NotificationDto.cs new file mode 100644 index 0000000..91c1fc3 --- /dev/null +++ b/AskFm/AskFm.BLL/DTO/NotificationDto.cs @@ -0,0 +1,19 @@ +using AskFm.BLL.DTO; + +public class NotificationDto +{ + public int Id { get; set; } + public string Type { get; set; } + + public string Message { get; set; } + + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } + + public int ResourceId { get; set; } + public int UserId { get; set; } + public ActorDto? Actor { get; set; } + public PaginationDto Pagination { get; set; } + + +} diff --git a/AskFm/AskFm.BLL/DTO/PaginationDto.cs b/AskFm/AskFm.BLL/DTO/PaginationDto.cs new file mode 100644 index 0000000..98c9a6b --- /dev/null +++ b/AskFm/AskFm.BLL/DTO/PaginationDto.cs @@ -0,0 +1,10 @@ +namespace AskFm.BLL.DTO; + +public class PaginationDto +{ + public int CurrentPage { get; set; } + public int TotalPages { get; set; } + public int TotalCount { get; set; } + public bool HasNext { get; set; } + public bool HasPrevious { get; set; } +} \ No newline at end of file diff --git a/AskFm/AskFm.BLL/Hub/NotificationHub.cs b/AskFm/AskFm.BLL/Hub/NotificationHub.cs new file mode 100644 index 0000000..500526f --- /dev/null +++ b/AskFm/AskFm.BLL/Hub/NotificationHub.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.Authorization; + +namespace AskFm.BLL.Hub +{ + [Authorize] + public class NotificationHub : Microsoft.AspNetCore.SignalR.Hub + { + public async Task JoinUserGroup(string userId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}"); + } + + public async Task LeaveUserGroup(string userId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId}"); + } + + public override async Task OnConnectedAsync() + { + // Auto-join user to their group based on their ID from JWT token + var userId = Context.UserIdentifier; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}"); + } + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId}"); + } + await base.OnDisconnectedAsync(exception); + } + } +} \ No newline at end of file diff --git a/AskFm/AskFm.BLL/Services/INotificationService.cs b/AskFm/AskFm.BLL/Services/INotificationService.cs new file mode 100644 index 0000000..afa49ad --- /dev/null +++ b/AskFm/AskFm.BLL/Services/INotificationService.cs @@ -0,0 +1,13 @@ +using AskFm.BLL.DTO; +using AskFm.DAL.Enums; + +namespace AskFm.BLL.Services; + +public interface INotificationService +{ + Task>> GetUserNotifications(int userId, int pageNumber = 1, int pageSize = 10); + Task>> GetNotificationsByType(int userId, string category, int pageNumber = 1, int pageSize = 10); + Task> MarkNotificationAsRead(int notificationId, int userId); + Task> MarkAllNotificationsAsRead(int userId); + Task> CreateNotification(int userId, NotificationStatus type, int resourceId, string message); +} \ No newline at end of file diff --git a/AskFm/AskFm.BLL/Services/NotificationService.cs b/AskFm/AskFm.BLL/Services/NotificationService.cs new file mode 100644 index 0000000..cd3c90b --- /dev/null +++ b/AskFm/AskFm.BLL/Services/NotificationService.cs @@ -0,0 +1,210 @@ +using AskFm.BLL.DTO; +using AskFm.BLL.Hub; +using AskFm.DAL.Enums; +using AskFm.DAL.Interfaces; +using AskFm.DAL.Models; +using Microsoft.AspNetCore.SignalR; + +namespace AskFm.BLL.Services; + +public class NotificationService : INotificationService +{ + private readonly INotificationRepository _notificationRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _hubContext; + + public NotificationService(INotificationRepository notificationRepository, IUnitOfWork unitOfWork, IHubContext hubContext) + { + _notificationRepository = notificationRepository; + _unitOfWork = unitOfWork; + _hubContext = hubContext; + } + + public async Task>> GetUserNotifications(int userId, int pageNumber = 1, int pageSize = 10) + { + try + { + var (notifications, totalCount) = await _notificationRepository.GetAllNotifications(userId, pageNumber, pageSize); + + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + + var notificationDtos = new List(); + + foreach (var notification in notifications) + { + var actorUser = await _notificationRepository.GetActorUserByResourceId(notification.ResourceId, notification.Type); + notificationDtos.Add(new NotificationDto + { + Id = notification.Id, + UserId = notification.UserId, + Type = notification.Type.ToString(), + ResourceId = notification.ResourceId, + Message = notification.Message, + IsRead = notification.IsRead, + CreatedAt = notification.CreatedAt, + Actor = actorUser == null ? null : new ActorDto + { + Id = actorUser.Id, + Username = actorUser?.UserName ?? "Unknown", + AvatarPath = actorUser?.AvatarPath ?? String.Empty + }, + Pagination = new PaginationDto + { + CurrentPage = pageNumber, + TotalPages = totalPages, + TotalCount = totalCount, + HasNext = pageNumber < totalPages, + HasPrevious = pageNumber > 1 + } + }); + } + return await ServiceResult>.Success(notificationDtos); + } + catch (Exception ex) + { + return await ServiceResult>.Failure(new List { ex.Message }); + } + } + + public async Task>> GetNotificationsByType(int userId, string category, int pageNumber = 1, int pageSize = 10) + { + try + { + // Convert category to uppercase and match with enum + if (!Enum.TryParse(category.ToUpper(), out var notificationType)) + return await ServiceResult>.Failure(new List { $"Invalid notification category: {category}" }); + + var (notifications, totalCount) = await _notificationRepository.GetNotificationsByType(userId, notificationType, pageNumber, pageSize); + + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + + var notificationDtos = new List(); + + foreach (var notification in notifications) + { + var actorUser = await _notificationRepository.GetActorUserByResourceId(notification.ResourceId, notification.Type); + notificationDtos.Add(new NotificationDto + { + Id = notification.Id, + UserId = notification.UserId, + Type = notification.Type.ToString(), + ResourceId = notification.ResourceId, + Message = notification.Message, + IsRead = notification.IsRead, + CreatedAt = notification.CreatedAt, + Actor = actorUser == null ? null : new ActorDto + { + Id = actorUser.Id, + Username = actorUser?.UserName ?? "Unknown", + AvatarPath = actorUser?.AvatarPath ?? String.Empty + }, + Pagination = new PaginationDto + { + CurrentPage = pageNumber, + TotalPages = totalPages, + TotalCount = totalCount, + HasNext = pageNumber < totalPages, + HasPrevious = pageNumber > 1 + } + }); + } + + return await ServiceResult>.Success(notificationDtos); + } + catch (Exception ex) + { + return await ServiceResult>.Failure(new List { ex.Message }); + } + } + + public async Task> MarkNotificationAsRead(int notificationId, int userId) + { + try + { + var notification = await _notificationRepository.GetUserNotificationById(notificationId, userId); + if (notification == null) + return await ServiceResult.Failure(new List { "Notification not found or access denied." }); + + notification.IsRead = true; + _unitOfWork.Notifications.Update(notification); + await _unitOfWork.SaveAsync(); + + return await ServiceResult.Success("notification has been read"); + } + catch (Exception ex) + { + return await ServiceResult.Failure(new List { ex.Message }); + } + } + + public async Task> MarkAllNotificationsAsRead(int userId) + { + try + { + var unreadNotifications = await _unitOfWork.Notifications.FindAllAsync(n => n.UserId == userId && !n.IsRead); + + foreach (var notification in unreadNotifications) + { + notification.IsRead = true; + _unitOfWork.Notifications.Update(notification); + } + + await _unitOfWork.SaveAsync(); + return await ServiceResult.Success("All notifications marked as read"); + } + catch (Exception ex) + { + return await ServiceResult.Failure(new List { ex.Message }); + } + } + + public async Task> CreateNotification(int userId, NotificationStatus type, int resourceId, string message) + { + try + { + var notification = new Notification + { + UserId = userId, + Type = type, + ResourceId = resourceId, + Message = message, + IsRead = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await _unitOfWork.Notifications.AddAsync(notification); + await _unitOfWork.SaveAsync(); + + // Get actor information for the notification + var actorUser = await _notificationRepository.GetActorUserByResourceId(resourceId, type); + + var notificationDto = new NotificationDto + { + Id = notification.Id, + UserId = notification.UserId, + Type = notification.Type.ToString(), + ResourceId = notification.ResourceId, + Message = notification.Message, + IsRead = notification.IsRead, + CreatedAt = notification.CreatedAt, + Actor = actorUser == null ? null : new ActorDto + { + Id = actorUser.Id, + Username = actorUser.UserName ?? "Unknown", + AvatarPath = actorUser.AvatarPath ?? string.Empty + } + }; + + // Send real-time notification to the specific user + await _hubContext.Clients.Group($"user_{userId}") + .SendAsync("ReceiveNotification", notificationDto); + + return await ServiceResult.Success(notificationDto); + } + catch (Exception ex) + { + return await ServiceResult.Failure(new List { ex.Message }); + } + } +} \ No newline at end of file diff --git a/AskFm/AskFm.DAL/AskFm.DAL.csproj b/AskFm/AskFm.DAL/AskFm.DAL.csproj index c77d955..e33b6a5 100644 --- a/AskFm/AskFm.DAL/AskFm.DAL.csproj +++ b/AskFm/AskFm.DAL/AskFm.DAL.csproj @@ -1,36 +1,32 @@ - - - - net9.0 - enable - enable - AskFm.DAL - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore.relational\9.0.7\lib\net8.0\Microsoft.EntityFrameworkCore.Relational.dll - - - - - - - - + + + net9.0 + enable + enable + AskFm.DAL + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore.relational\9.0.7\lib\net8.0\Microsoft.EntityFrameworkCore.Relational.dll + + + + + + \ No newline at end of file diff --git a/AskFm/AskFm.DAL/Interfaces/INotificationRepository.cs b/AskFm/AskFm.DAL/Interfaces/INotificationRepository.cs index d2883fd..310ec78 100644 --- a/AskFm/AskFm.DAL/Interfaces/INotificationRepository.cs +++ b/AskFm/AskFm.DAL/Interfaces/INotificationRepository.cs @@ -1,6 +1,12 @@ +using AskFm.DAL.Enums; +using AskFm.DAL.Models; + namespace AskFm.DAL.Interfaces; public interface INotificationRepository { - + Task<(IEnumerable notifications, int totalCount)> GetAllNotifications(int userId, int pageNumber, int pageSize); + Task<(IEnumerable notifications, int totalCount)> GetNotificationsByType(int userId, NotificationStatus status, int pageNumber, int pageSize); + Task GetActorUserByResourceId(int resourceId, NotificationStatus type); + Task GetUserNotificationById(int notificationId, int userId); } \ No newline at end of file diff --git a/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.Designer.cs b/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.Designer.cs new file mode 100644 index 0000000..ddd28f5 --- /dev/null +++ b/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.Designer.cs @@ -0,0 +1,763 @@ +// +using System; +using AskFm.DAL; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AskFm.DAL.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250824204202_NotificationModelNaming")] + partial class NotificationModelNaming + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("AskFm.DAL.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("AvatarPath") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FollowersCount") + .HasColumnType("int"); + + b.Property("FollowingCount") + .HasColumnType("int"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("LastSeen") + .HasColumnType("datetime2"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("LikeCount") + .HasColumnType("int"); + + b.Property("ParentCommentId") + .HasColumnType("int"); + + b.Property("ThreadId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.CommentLike", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CommentId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.HasKey("UserId", "CommentId"); + + b.HasIndex("CommentId"); + + b.ToTable("CommentLikes"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Follow", b => + { + b.Property("FollowerId") + .HasColumnType("int"); + + b.Property("FollowedId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.HasKey("FollowerId", "FollowedId"); + + b.HasIndex("FollowedId"); + + b.ToTable("Follows"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasColumnType("NVARCHAR"); + + b.Property("ResourceId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.SavedThreads", b => + { + b.Property("SavedThreadId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.HasKey("SavedThreadId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("SavedThreads"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Thread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AnswerContent") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AskedId") + .HasColumnType("int"); + + b.Property("AskerId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("QuestionContent") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.Property("isAnonymous") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AskedId"); + + b.HasIndex("AskerId"); + + b.ToTable("Thread", (string)null); + }); + + modelBuilder.Entity("AskFm.DAL.Models.ThreadLike", b => + { + b.Property("ThreadId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("DATETIME"); + + b.Property("DeletedAt") + .HasColumnType("DATETIME"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("BIT") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .HasColumnType("DATETIME"); + + b.HasKey("ThreadId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("ThreadLikes"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Comment", b => + { + b.HasOne("AskFm.DAL.Models.Comment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("AskFm.DAL.Models.Thread", "Thread") + .WithMany("Comments") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "User") + .WithMany("Comments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("ParentComment"); + + b.Navigation("Thread"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.CommentLike", b => + { + b.HasOne("AskFm.DAL.Models.Comment", "Comment") + .WithMany("CommentLikes") + .HasForeignKey("CommentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Comment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Follow", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", "Followed") + .WithMany("Followers") + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Notification", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.SavedThreads", b => + { + b.HasOne("AskFm.DAL.Models.Thread", "Thread") + .WithMany("SavedThreads") + .HasForeignKey("SavedThreadId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "User") + .WithMany("SavedThreads") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Thread"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Thread", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", "Asked") + .WithMany("ReceivedThreads") + .HasForeignKey("AskedId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "Asker") + .WithMany("AskedThreads") + .HasForeignKey("AskerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Asked"); + + b.Navigation("Asker"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.ThreadLike", b => + { + b.HasOne("AskFm.DAL.Models.Thread", "Thread") + .WithMany("ThreadLikes") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", "User") + .WithMany("ThreadLikes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Thread"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AskFm.DAL.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AskFm.DAL.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AskFm.DAL.Models.ApplicationUser", b => + { + b.Navigation("AskedThreads"); + + b.Navigation("CommentLikes"); + + b.Navigation("Comments"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("Notifications"); + + b.Navigation("ReceivedThreads"); + + b.Navigation("SavedThreads"); + + b.Navigation("ThreadLikes"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Comment", b => + { + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("AskFm.DAL.Models.Thread", b => + { + b.Navigation("Comments"); + + b.Navigation("SavedThreads"); + + b.Navigation("ThreadLikes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.cs b/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.cs new file mode 100644 index 0000000..38fee4d --- /dev/null +++ b/AskFm/AskFm.DAL/Migrations/20250824204202_NotificationModelNaming.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AskFm.DAL.Migrations +{ + /// + public partial class NotificationModelNaming : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "isRead", + table: "Notifications", + newName: "IsRead"); + + migrationBuilder.RenameColumn( + name: "jsonContent", + table: "Notifications", + newName: "Message"); + + migrationBuilder.AddColumn( + name: "Type", + table: "Notifications", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "Notifications"); + + migrationBuilder.RenameColumn( + name: "IsRead", + table: "Notifications", + newName: "isRead"); + + migrationBuilder.RenameColumn( + name: "Message", + table: "Notifications", + newName: "jsonContent"); + } + } +} diff --git a/AskFm/AskFm.DAL/Migrations/AppDbContextModelSnapshot.cs b/AskFm/AskFm.DAL/Migrations/AppDbContextModelSnapshot.cs index 832c22c..dfccc1a 100644 --- a/AskFm/AskFm.DAL/Migrations/AppDbContextModelSnapshot.cs +++ b/AskFm/AskFm.DAL/Migrations/AppDbContextModelSnapshot.cs @@ -296,9 +296,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("BIT") .HasDefaultValue(false); + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasColumnType("NVARCHAR"); + b.Property("ResourceId") .HasColumnType("int"); + b.Property("Type") + .HasColumnType("int"); + b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("DATETIME") @@ -307,13 +317,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UserId") .HasColumnType("int"); - b.Property("isRead") - .HasColumnType("bit"); - - b.Property("jsonContent") - .IsRequired() - .HasColumnType("NVARCHAR"); - b.HasKey("Id"); b.HasIndex("UserId"); diff --git a/AskFm/AskFm.DAL/Models/Notification.cs b/AskFm/AskFm.DAL/Models/Notification.cs index 0ea01ef..2807d59 100644 --- a/AskFm/AskFm.DAL/Models/Notification.cs +++ b/AskFm/AskFm.DAL/Models/Notification.cs @@ -1,17 +1,18 @@ using System.Runtime.InteropServices.JavaScript; +using AskFm.DAL.Enums; namespace AskFm.DAL.Models; public class Notification : ITrackable { - public GCNotificationStatus Type; + public NotificationStatus Type { get; set; } public int Id { get; set; } public int UserId { get; set; } public virtual ApplicationUser? User { get; set; } - public bool isRead { get; set; } + public bool IsRead { get; set; } public int ResourceId { get; set; } - public string jsonContent { get; set; } + public string Message { get; set; } public bool IsDeleted { get; set; } public DateTime DeletedAt { get; set; } diff --git a/AskFm/AskFm.DAL/ModelsConfigrations/NotificationConfigration.cs b/AskFm/AskFm.DAL/ModelsConfigrations/NotificationConfigration.cs index afeda9b..0423d4f 100644 --- a/AskFm/AskFm.DAL/ModelsConfigrations/NotificationConfigration.cs +++ b/AskFm/AskFm.DAL/ModelsConfigrations/NotificationConfigration.cs @@ -10,10 +10,10 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(n => n.Id); - builder.Property(n => n.jsonContent) + builder.Property(n => n.Message) .HasColumnType("NVARCHAR"); - builder.Property(n => n.isRead) + builder.Property(n => n.IsRead) .IsRequired(); diff --git a/AskFm/AskFm.DAL/Repositories/NotificationRepository.cs b/AskFm/AskFm.DAL/Repositories/NotificationRepository.cs index 7fa79f1..56189c6 100644 --- a/AskFm/AskFm.DAL/Repositories/NotificationRepository.cs +++ b/AskFm/AskFm.DAL/Repositories/NotificationRepository.cs @@ -1,6 +1,86 @@ +using AskFm.DAL.Enums; +using AskFm.DAL.Interfaces; +using AskFm.DAL.Models; +using Microsoft.EntityFrameworkCore; + namespace AskFm.DAL.Repositories; -public class NotificationRepository +public class NotificationRepository : INotificationRepository { - + private readonly IUnitOfWork _unitOfWork; + + public NotificationRepository(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task<(IEnumerable notifications, int totalCount)> GetAllNotifications(int userId, int pageNumber, int pageSize) + { + var query = _unitOfWork.Notifications.FindAll(n => n.UserId == userId); + + var totalCount = await query.CountAsync(); + + var notifications = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (notifications, totalCount); + } + + public async Task<(IEnumerable notifications, int totalCount)> GetNotificationsByType(int userId, NotificationStatus status, int pageNumber, int pageSize) + { + var query = _unitOfWork.Notifications.FindAll(n => n.UserId == userId && n.Type == status); + + var totalCount = await query.CountAsync(); + + var notifications = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (notifications, totalCount); + } + + public async Task GetActorUserByResourceId(int resourceId, NotificationStatus type) + { + if (type == NotificationStatus.FOLLOW) + { + var follow = await _unitOfWork.Follows.FindAsync(f => f.FollowedId == resourceId, new[] { "Follower" }); + return follow?.Follower; + } + else if (type == NotificationStatus.QUESTION) + { + var thread = await _unitOfWork.Threads.FindAsync(t => t.Id == resourceId, new[] { "Asker" }); + return thread?.Asker; + } + else if (type == NotificationStatus.ANSWER) + { + var thread = await _unitOfWork.Threads.FindAsync(t => t.Id == resourceId, new[] { "Asked" }); + return thread?.Asked; + } + else if (type == NotificationStatus.COMMENT_LIKE) + { + var commentLike = await _unitOfWork.CommentLikes.FindAsync(cl => cl.CommentId == resourceId, new[] { "User" }); + return commentLike?.User; + } + else if (type == NotificationStatus.QUESTION_LIKE) + { + var threadLike = await _unitOfWork.ThreadLikes.FindAsync(tl => tl.ThreadId == resourceId, new[] { "User" }); + return threadLike?.User; + } + else if (type == NotificationStatus.REPLAY) + { + var comment = await _unitOfWork.Comments.FindAsync(c => c.Id == resourceId, new[] { "User" }); + return comment?.User; + } + return null; + } + + public async Task GetUserNotificationById(int notificationId, int userId) + { + return await _unitOfWork.Notifications.FindAsync(n => n.Id == notificationId && n.UserId == userId); + } } \ No newline at end of file diff --git a/AskFm/Tests/NotificationServiceTests.cs b/AskFm/Tests/NotificationServiceTests.cs new file mode 100644 index 0000000..9a701ef --- /dev/null +++ b/AskFm/Tests/NotificationServiceTests.cs @@ -0,0 +1,335 @@ +using AskFm.BLL.DTO; +using AskFm.BLL.Hub; +using AskFm.BLL.Services; +using AskFm.DAL.Enums; +using AskFm.DAL.Interfaces; +using AskFm.DAL.Models; +using Microsoft.AspNetCore.SignalR; +using Moq; +using Xunit; + +namespace AskFm.BLL.Tests.Services +{ + public class NotificationServiceTests + { + private readonly Mock _notificationRepositoryMock; + private readonly Mock _unitOfWorkMock; + private readonly Mock> _hubContextMock; + private readonly Mock _mockClients; + private readonly Mock _mockClientProxy; + private readonly Mock _mockGroups; + private readonly NotificationService _notificationService; + + public NotificationServiceTests() + { + _notificationRepositoryMock = new Mock(); + _unitOfWorkMock = new Mock(); + _hubContextMock = new Mock>(); + _mockClients = new Mock(); + _mockClientProxy = new Mock(); + _mockGroups = new Mock(); + + // Setup hub context relationships + _hubContextMock.Setup(p => p.Clients).Returns(_mockClients.Object); + _hubContextMock.Setup(p => p.Groups).Returns(_mockGroups.Object); + _mockClients.Setup(p => p.Group(It.IsAny())).Returns(_mockClientProxy.Object); + + // Setup unit of work notifications repository mock + var notificationRepoMock = new Mock>(); + _unitOfWorkMock.Setup(p => p.Notifications).Returns(notificationRepoMock.Object); + + _notificationService = new NotificationService( + _notificationRepositoryMock.Object, + _unitOfWorkMock.Object, + _hubContextMock.Object + ); + } + + [Fact] + public async Task GetUserNotifications_ReturnsCorrectDtoWithPagination() + { + // Arrange + int userId = 1; + int pageNumber = 1; + int pageSize = 10; + var notifications = new List + { + new Notification + { + Id = 1, + UserId = userId, + Type = NotificationStatus.QUESTION, + ResourceId = 100, + Message = "Test notification", + IsRead = false, + CreatedAt = DateTime.UtcNow + } + }; + + var totalCount = 1; + var actorUser = new ApplicationUser { Id = 2, UserName = "test_user", AvatarPath = "test.jpg" }; + + _notificationRepositoryMock.Setup(p => p.GetAllNotifications(userId, pageNumber, pageSize)).ReturnsAsync((notifications, totalCount)); + _notificationRepositoryMock.Setup(p => p.GetActorUserByResourceId(100, NotificationStatus.QUESTION)).ReturnsAsync(actorUser); + + // Act + var result = await _notificationService.GetUserNotifications(userId, pageNumber, pageSize); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal(1, result.Data[0].Id); + Assert.Equal(NotificationStatus.QUESTION.ToString(), result.Data[0].Type); + Assert.Equal("Test notification", result.Data[0].Message); + Assert.False(result.Data[0].IsRead); + Assert.Equal("test_user", result.Data[0].Actor.Username); + Assert.Equal("test.jpg", result.Data[0].Actor.AvatarPath); + Assert.Equal(2, result.Data[0].Actor.Id); + Assert.Equal(1, result.Data[0].Pagination.TotalCount); + } + + [Fact] + public async Task GetUserNotifications_WithNullActor_ReturnsCorrectDtoWithPagination() + { + // Arrange + int userId = 1; + int pageNumber = 1; + int pageSize = 10; + var notifications = new List + { + new Notification + { + Id = 1, + UserId = userId, + Type = NotificationStatus.QUESTION, + ResourceId = 100, + Message = "Test notification", + IsRead = false, + CreatedAt = DateTime.UtcNow + } + }; + + var totalCount = 1; + + _notificationRepositoryMock.Setup(p => p.GetAllNotifications(userId, pageNumber, pageSize)).ReturnsAsync((notifications, totalCount)); + _notificationRepositoryMock.Setup(p => p.GetActorUserByResourceId(100, NotificationStatus.QUESTION)).ReturnsAsync((ApplicationUser)null); + + // Act + var result = await _notificationService.GetUserNotifications(userId, pageNumber, pageSize); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal(1, result.Data[0].Id); + Assert.Equal(NotificationStatus.QUESTION.ToString(), result.Data[0].Type); + Assert.Equal("Test notification", result.Data[0].Message); + Assert.False(result.Data[0].IsRead); + Assert.Null(result.Data[0].Actor); + Assert.Equal(1, result.Data[0].Pagination.TotalCount); + } + + [Fact] + public async Task GetNotificationsByType_WithValidCategory_ReturnsFilteredNotifications() + { + // Arrange + int userId = 1; + string category = "answer"; + int pageNumber = 1; + int pageSize = 10; + var notifications = new List + { + new Notification + { + Id = 1, + UserId = userId, + Type = NotificationStatus.ANSWER, + ResourceId = 100, + Message = "Test notification", + IsRead = false, + CreatedAt = DateTime.UtcNow + } + }; + + var totalCount = 1; + var actorUser = new ApplicationUser { Id = 2, UserName = "test_user", AvatarPath = "test.jpg" }; + + _notificationRepositoryMock.Setup(p => p.GetNotificationsByType(userId, NotificationStatus.ANSWER, pageNumber, pageSize)).ReturnsAsync((notifications, totalCount)); + _notificationRepositoryMock.Setup(p => p.GetActorUserByResourceId(100, NotificationStatus.ANSWER)).ReturnsAsync(actorUser); + + // Act + var result = await _notificationService.GetNotificationsByType(userId, category, pageNumber, pageSize); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.NotNull(result.Data); + Assert.Single(result.Data); + Assert.Equal(1, result.Data[0].Id); + Assert.Equal(category.ToUpper(), result.Data[0].Type); + Assert.Equal("Test notification", result.Data[0].Message); + Assert.False(result.Data[0].IsRead); + Assert.Equal("test_user", result.Data[0].Actor.Username); + Assert.Equal("test.jpg", result.Data[0].Actor.AvatarPath); + Assert.Equal(2, result.Data[0].Actor.Id); + Assert.Equal(1, result.Data[0].Pagination.TotalCount); + } + + [Fact] + public async Task GetNotificationsByType_WithInvalidCategory_ReturnsFailureResult() + { + // Arrange + int userId = 1; + string category = "InvalidCategory"; + int pageNumber = 1; + int pageSize = 10; + + // Act + var result = await _notificationService.GetNotificationsByType(userId, category, pageNumber, pageSize); + + // Assert + Assert.False(result.success); + Assert.NotNull(result.Errors); + Assert.Contains("Invalid notification category", result.Errors[0]); + Assert.Null(result.Data); + } + + [Fact] + public async Task MarkNotificationAsRead_WithValidId_UpdatesNotification() + { + // Arrange + int notificationId = 1; + int userId = 1; + Notification notification = new Notification + { + Id = notificationId, + UserId = userId, + IsRead = false, + Type = NotificationStatus.QUESTION, + ResourceId = 100, + Message = "Test notification", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _notificationRepositoryMock.Setup(p => p.GetUserNotificationById(notificationId, userId)) + .ReturnsAsync(notification); + _unitOfWorkMock.Setup(p => p.SaveAsync()).ReturnsAsync(1); + + // Act + var result = await _notificationService.MarkNotificationAsRead(notificationId, userId); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.Equal("notification has been read", result.Data); + Assert.True(notification.IsRead); + _unitOfWorkMock.Verify(p => p.Notifications.Update(notification), Times.Once); + _unitOfWorkMock.Verify(p => p.SaveAsync(), Times.Once); + } + + [Fact] + public async Task MarkNotificationAsRead_WithInvalidId_ReturnsFailureResult() + { + // Arrange + var notificationId = 999; + var userId = 1; + _notificationRepositoryMock.Setup(p => p.GetUserNotificationById(notificationId, userId)) + .ReturnsAsync((Notification)null); + + // Act + var result = await _notificationService.MarkNotificationAsRead(notificationId, userId); + + // Assert + Assert.False(result.success); + Assert.NotNull(result.Errors); + Assert.Contains("Notification not found or access denied.", result.Errors[0]); + Assert.Null(result.Data); + } + + [Fact] + public async Task MarkAllNotificationsAsRead_UpdatesAllUnreadNotifications() + { + // Arrange + int userId = 1; + var unreadNotifications = new List + { + new Notification + { + Id = 1, + UserId = userId, + IsRead = false, + Type = NotificationStatus.QUESTION, + ResourceId = 100, + Message = "Test notification 1", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }, + new Notification + { + Id = 2, + UserId = userId, + IsRead = false, + Type = NotificationStatus.ANSWER, + ResourceId = 200, + Message = "Test notification 2", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + } + }; + + _unitOfWorkMock.Setup(p => p.Notifications.FindAllAsync(It.IsAny>>(), null)) + .ReturnsAsync(unreadNotifications); + _unitOfWorkMock.Setup(p => p.SaveAsync()).ReturnsAsync(1); + + // Act + var result = await _notificationService.MarkAllNotificationsAsRead(userId); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.Equal("All notifications marked as read", result.Data); + Assert.All(unreadNotifications, n => Assert.True(n.IsRead)); + _unitOfWorkMock.Verify(p => p.Notifications.Update(It.IsAny()), Times.Exactly(2)); + _unitOfWorkMock.Verify(p => p.SaveAsync(), Times.Once); + } + + [Fact] + public async Task CreateNotification_CreatesAndSendsNotification() + { + // Arrange + int userId = 1; + var type = NotificationStatus.FOLLOW; + int resourceId = 100; + string message = "Test notification"; + var actorUser = new ApplicationUser { Id = 2, UserName = "test_user", AvatarPath = "test.jpg" }; + + _unitOfWorkMock.Setup(p => p.Notifications.AddAsync(It.IsAny())); + _unitOfWorkMock.Setup(p => p.SaveAsync()).ReturnsAsync(1); + _notificationRepositoryMock.Setup(p => p.GetActorUserByResourceId(resourceId, type)) + .ReturnsAsync(actorUser); + + // Act + var result = await _notificationService.CreateNotification(userId, type, resourceId, message); + + // Assert + Assert.True(result.success); + Assert.Null(result.Errors); + Assert.NotNull(result.Data); + Assert.Equal(userId, result.Data.UserId); + Assert.Equal(type.ToString(), result.Data.Type); + Assert.Equal(resourceId, result.Data.ResourceId); + Assert.Equal(message, result.Data.Message); + Assert.False(result.Data.IsRead); + Assert.Equal("test_user", result.Data.Actor.Username); + Assert.Equal("test.jpg", result.Data.Actor.AvatarPath); + Assert.Equal(2, result.Data.Actor.Id); + _unitOfWorkMock.Verify(p => p.Notifications.AddAsync(It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(p => p.SaveAsync(), Times.Once); + _mockClientProxy.Verify(p => p.SendCoreAsync("ReceiveNotification", It.IsAny(), default), Times.Once); + } + } +} \ No newline at end of file diff --git a/AskFm/Tests/Tests.csproj b/AskFm/Tests/Tests.csproj index 4ac7b72..aab5fe3 100644 --- a/AskFm/Tests/Tests.csproj +++ b/AskFm/Tests/Tests.csproj @@ -21,9 +21,9 @@ - - - + + +