diff --git a/src/Api/NotificationCenter/Controllers/NotificationsController.cs b/src/Api/NotificationCenter/Controllers/NotificationsController.cs new file mode 100644 index 000000000..ab27b943b --- /dev/null +++ b/src/Api/NotificationCenter/Controllers/NotificationsController.cs @@ -0,0 +1,62 @@ +#nullable enable +using Bit.Api.Models.Response; +using Bit.Api.NotificationCenter.Models.Request; +using Bit.Api.NotificationCenter.Models.Response; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Models.Filter; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.NotificationCenter.Controllers; + +[Route("notifications")] +[Authorize("Application")] +public class NotificationsController : Controller +{ + private readonly IGetNotificationsForUserQuery _getNotificationsForUserQuery; + private readonly IMarkNotificationDeletedCommand _markNotificationDeletedCommand; + private readonly IMarkNotificationReadCommand _markNotificationReadCommand; + + public NotificationsController( + IGetNotificationsForUserQuery getNotificationsForUserQuery, + IMarkNotificationDeletedCommand markNotificationDeletedCommand, + IMarkNotificationReadCommand markNotificationReadCommand) + { + _getNotificationsForUserQuery = getNotificationsForUserQuery; + _markNotificationDeletedCommand = markNotificationDeletedCommand; + _markNotificationReadCommand = markNotificationReadCommand; + } + + [HttpGet("")] + public async Task> List( + [FromQuery] NotificationFilterRequestModel filter) + { + var notificationStatusFilter = new NotificationStatusFilter + { + Read = filter.ReadStatusFilter, + Deleted = filter.DeletedStatusFilter + }; + + var notifications = await _getNotificationsForUserQuery.GetByUserIdStatusFilterAsync(notificationStatusFilter); + + var filteredNotifications = notifications + .Where(n => n.RevisionDate >= filter.Start && n.RevisionDate < filter.End) + .Take(filter.PageSize); + + var responses = filteredNotifications.Select(n => new NotificationResponseModel(n)); + return new ListResponseModel(responses); + } + + [HttpPatch("{id}/delete")] + public async Task MarkAsDeleted([FromRoute] Guid id) + { + await _markNotificationDeletedCommand.MarkDeletedAsync(id); + } + + [HttpPatch("{id}/read")] + public async Task MarkAsRead([FromRoute] Guid id) + { + await _markNotificationReadCommand.MarkReadAsync(id); + } +} diff --git a/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs b/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs new file mode 100644 index 000000000..a6a46f63a --- /dev/null +++ b/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs @@ -0,0 +1,29 @@ +namespace Bit.Api.NotificationCenter.Models.Request; + +public class NotificationFilterRequestModel +{ + /// + /// Filters notifications by read status. When not set, includes notifications without a status. + /// + public bool? ReadStatusFilter { get; set; } + + /// + /// Filters notifications by deleted status. When not set, includes notifications without a status. + /// + public bool? DeletedStatusFilter { get; set; } + + /// + /// The start date. Must be less than the end date. Inclusive. + /// + public DateTime Start { get; set; } = DateTime.MinValue; + + /// + /// The end date. Must be greater than the start date. Not inclusive. + /// + public DateTime End { get; set; } = DateTime.MaxValue; + + /// + /// Number of items to return. Defaults to 10. + /// + public int PageSize { get; set; } = 10; +} diff --git a/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs b/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs new file mode 100644 index 000000000..296918c36 --- /dev/null +++ b/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs @@ -0,0 +1,40 @@ +#nullable enable +using Bit.Core.Models.Api; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; + +namespace Bit.Api.NotificationCenter.Models.Response; + +public class NotificationResponseModel : ResponseModel +{ + private const string _objectName = "notification"; + + public NotificationResponseModel(Notification notification, string obj = _objectName) + : base(obj) + { + if (notification == null) + { + throw new ArgumentNullException(nameof(notification)); + } + + Id = notification.Id; + Priority = notification.Priority; + Title = notification.Title; + Body = notification.Body; + Date = notification.RevisionDate; + } + + public NotificationResponseModel() : base(_objectName) + { + } + + public Guid Id { get; set; } + + public Priority Priority { get; set; } + + public string? Title { get; set; } + + public string? Body { get; set; } + + public DateTime Date { get; set; } +} diff --git a/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs b/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs new file mode 100644 index 000000000..304acea52 --- /dev/null +++ b/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Bit.Core.NotificationCenter.Authorization; +using Bit.Core.NotificationCenter.Commands; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Queries; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.NotificationCenter; + +public static class NotificationCenterServiceCollectionExtensions +{ + public static void AddNotificationCenterServices(this IServiceCollection services) + { + // Authorization Handlers + services.AddScoped(); + services.AddScoped(); + // Commands + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Queries + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bd3aecf2f..5c66a3d42 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using Bit.Core.Enums; using Bit.Core.HostedServices; using Bit.Core.Identity; using Bit.Core.IdentityServer; +using Bit.Core.NotificationCenter; using Bit.Core.OrganizationFeatures; using Bit.Core.Repositories; using Bit.Core.Resources; @@ -114,6 +115,7 @@ public static class ServiceCollectionExtensions services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); + services.AddNotificationCenterServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTest.cs b/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTest.cs new file mode 100644 index 000000000..8358f2b6c --- /dev/null +++ b/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTest.cs @@ -0,0 +1,52 @@ +#nullable enable +using Bit.Api.NotificationCenter.Controllers; +using Bit.Api.NotificationCenter.Models.Request; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Models.Filter; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.NotificationCenter.Controllers; + +[ControllerCustomize(typeof(NotificationsController))] +[SutProviderCustomize] +public class NotificationsControllerTest +{ + [Theory] + [BitAutoData] + [NotificationListCustomize(20)] + public async Task List_DefaultFilter_ReturnedMatchingNotifications(SutProvider sutProvider, + List notifications) + { + sutProvider.GetDependency() + .GetByUserIdStatusFilterAsync(Arg.Any()) + .Returns(notifications); + + var expectedNotificationGroupedById = notifications + .Take(10) + .ToDictionary(n => n.Id); + + var filter = new NotificationFilterRequestModel(); + + var listResponse = await sutProvider.Sut.List(filter); + + Assert.Equal(10, listResponse.Data.Count()); + Assert.All(listResponse.Data, notificationResponseModel => + { + var expectedNotification = expectedNotificationGroupedById[notificationResponseModel.Id]; + Assert.NotNull(expectedNotification); + Assert.Equal(expectedNotification.Id, notificationResponseModel.Id); + Assert.Equal(expectedNotification.Priority, notificationResponseModel.Priority); + Assert.Equal(expectedNotification.Title, notificationResponseModel.Title); + Assert.Equal(expectedNotification.Body, notificationResponseModel.Body); + Assert.Equal(expectedNotification.RevisionDate, notificationResponseModel.Date); + Assert.Equal("notification", notificationResponseModel.Object); + }); + Assert.Null(listResponse.ContinuationToken); + Assert.Equal("list", listResponse.Object); + } +} diff --git a/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs b/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs index 4cdee8de9..387a2c11b 100644 --- a/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs +++ b/test/Core.Test/NotificationCenter/AutoFixture/NotificationFixtures.cs @@ -1,4 +1,6 @@ using AutoFixture; +using AutoFixture.Dsl; +using AutoFixture.Kernel; using Bit.Core.NotificationCenter.Entities; using Bit.Test.Common.AutoFixture.Attributes; @@ -8,19 +10,44 @@ public class NotificationCustomization(bool global) : ICustomization { public void Customize(IFixture fixture) { - fixture.Customize(composer => + fixture.Customize(GetSpecimenBuilder); + } + + public ISpecimenBuilder GetSpecimenBuilder(ICustomizationComposer customizationComposer) + { + var postprocessComposer = customizationComposer.With(n => n.Id, Guid.NewGuid()) + .With(n => n.Global, global); + + postprocessComposer = global + ? postprocessComposer.Without(n => n.UserId) + : postprocessComposer.With(n => n.UserId, Guid.NewGuid()); + + return global + ? postprocessComposer.Without(n => n.OrganizationId) + : postprocessComposer.With(n => n.OrganizationId, Guid.NewGuid()); + } +} + +public class NotificationListCustomization(int count) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize>(composer => composer.FromFactory(() => { - var postprocessComposer = composer.With(n => n.Id, Guid.NewGuid()) - .With(n => n.Global, global); + var notificationCustomization = new NotificationCustomization(true); - postprocessComposer = global - ? postprocessComposer.Without(n => n.UserId) - : postprocessComposer.With(n => n.UserId, Guid.NewGuid()); + var notifications = new List(); + for (var i = 0; i < count; i++) + { + var customizationComposer = fixture.Build(); + var postprocessComposer = + customizationComposer.FromFactory( + notificationCustomization.GetSpecimenBuilder(customizationComposer)); + notifications.Add(postprocessComposer.Create()); + } - return global - ? postprocessComposer.Without(n => n.OrganizationId) - : postprocessComposer.With(n => n.OrganizationId, Guid.NewGuid()); - }); + return notifications; + })); } } @@ -29,3 +56,9 @@ public class NotificationCustomizeAttribute(bool global = true) { public override ICustomization GetCustomization() => new NotificationCustomization(global); } + +public class NotificationListCustomizeAttribute(int count) + : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new NotificationListCustomization(count); +}