Skip to content

Commit

Permalink
[PM-10563] Notification Center API (#4852)
Browse files Browse the repository at this point in the history
* PM-10563: Notification Center API

* PM-10563: continuation token hack

* PM-10563: Resolving merge conflicts

* PM-10563: Unit Tests

* PM-10563: Paging simplification by page number and size in database

* PM-10563: Request validation

* PM-10563: Read, Deleted status filters change

* PM-10563: Plural name for tests

* PM-10563: Request validation to always for int type

* PM-10563: Continuation Token returns null on response when no more records available

* PM-10563: Integration tests for GET

* PM-10563: Mark notification read, deleted commands date typos fix

* PM-10563: Integration tests for PATCH read, deleted

* PM-10563: Request, Response models tests

* PM-10563: EditorConfig compliance

* PM-10563: Extracting to const

* PM-10563: Update db migration script date

* PM-10563: Update migration script date
  • Loading branch information
mzieniukbw authored Dec 18, 2024
1 parent de2dc24 commit 21fcfcd
Show file tree
Hide file tree
Showing 18 changed files with 1,272 additions and 39 deletions.
71 changes: 71 additions & 0 deletions src/Api/NotificationCenter/Controllers/NotificationsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#nullable enable
using Bit.Api.Models.Response;
using Bit.Api.NotificationCenter.Models.Request;
using Bit.Api.NotificationCenter.Models.Response;
using Bit.Core.Models.Data;
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 IGetNotificationStatusDetailsForUserQuery _getNotificationStatusDetailsForUserQuery;
private readonly IMarkNotificationDeletedCommand _markNotificationDeletedCommand;
private readonly IMarkNotificationReadCommand _markNotificationReadCommand;

public NotificationsController(
IGetNotificationStatusDetailsForUserQuery getNotificationStatusDetailsForUserQuery,
IMarkNotificationDeletedCommand markNotificationDeletedCommand,
IMarkNotificationReadCommand markNotificationReadCommand)
{
_getNotificationStatusDetailsForUserQuery = getNotificationStatusDetailsForUserQuery;
_markNotificationDeletedCommand = markNotificationDeletedCommand;
_markNotificationReadCommand = markNotificationReadCommand;
}

[HttpGet("")]
public async Task<ListResponseModel<NotificationResponseModel>> ListAsync(
[FromQuery] NotificationFilterRequestModel filter)
{
var pageOptions = new PageOptions
{
ContinuationToken = filter.ContinuationToken,
PageSize = filter.PageSize
};

var notificationStatusFilter = new NotificationStatusFilter
{
Read = filter.ReadStatusFilter,
Deleted = filter.DeletedStatusFilter
};

var notificationStatusDetailsPagedResult =
await _getNotificationStatusDetailsForUserQuery.GetByUserIdStatusFilterAsync(notificationStatusFilter,
pageOptions);

var responses = notificationStatusDetailsPagedResult.Data
.Select(n => new NotificationResponseModel(n))
.ToList();

return new ListResponseModel<NotificationResponseModel>(responses,
notificationStatusDetailsPagedResult.ContinuationToken);
}

[HttpPatch("{id}/delete")]
public async Task MarkAsDeletedAsync([FromRoute] Guid id)
{
await _markNotificationDeletedCommand.MarkDeletedAsync(id);
}

[HttpPatch("{id}/read")]
public async Task MarkAsReadAsync([FromRoute] Guid id)
{
await _markNotificationReadCommand.MarkReadAsync(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#nullable enable
using System.ComponentModel.DataAnnotations;

namespace Bit.Api.NotificationCenter.Models.Request;

public class NotificationFilterRequestModel : IValidatableObject
{
/// <summary>
/// Filters notifications by read status. When not set, includes notifications without a status.
/// </summary>
public bool? ReadStatusFilter { get; set; }

/// <summary>
/// Filters notifications by deleted status. When not set, includes notifications without a status.
/// </summary>
public bool? DeletedStatusFilter { get; set; }

/// <summary>
/// A cursor for use in pagination.
/// </summary>
[StringLength(9)]
public string? ContinuationToken { get; set; }

/// <summary>
/// The number of items to return in a single page.
/// Default 10. Minimum 10, maximum 1000.
/// </summary>
[Range(10, 1000)]
public int PageSize { get; set; } = 10;

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!string.IsNullOrWhiteSpace(ContinuationToken) &&
(!int.TryParse(ContinuationToken, out var pageNumber) || pageNumber <= 0))
{
yield return new ValidationResult(
"Continuation token must be a positive, non zero integer.",
[nameof(ContinuationToken)]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#nullable enable
using Bit.Core.Models.Api;
using Bit.Core.NotificationCenter.Enums;
using Bit.Core.NotificationCenter.Models.Data;

namespace Bit.Api.NotificationCenter.Models.Response;

public class NotificationResponseModel : ResponseModel
{
private const string _objectName = "notification";

public NotificationResponseModel(NotificationStatusDetails notificationStatusDetails, string obj = _objectName)
: base(obj)
{
if (notificationStatusDetails == null)
{
throw new ArgumentNullException(nameof(notificationStatusDetails));
}

Id = notificationStatusDetails.Id;
Priority = notificationStatusDetails.Priority;
Title = notificationStatusDetails.Title;
Body = notificationStatusDetails.Body;
Date = notificationStatusDetails.RevisionDate;
ReadDate = notificationStatusDetails.ReadDate;
DeletedDate = notificationStatusDetails.DeletedDate;
}

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; }

public DateTime? ReadDate { get; set; }

public DateTime? DeletedDate { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#nullable enable
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<IAuthorizationHandler, NotificationAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, NotificationStatusAuthorizationHandler>();
// Commands
services.AddScoped<ICreateNotificationCommand, CreateNotificationCommand>();
services.AddScoped<ICreateNotificationStatusCommand, CreateNotificationStatusCommand>();
services.AddScoped<IMarkNotificationDeletedCommand, MarkNotificationDeletedCommand>();
services.AddScoped<IMarkNotificationReadCommand, MarkNotificationReadCommand>();
services.AddScoped<IUpdateNotificationCommand, UpdateNotificationCommand>();
// Queries
services.AddScoped<IGetNotificationStatusDetailsForUserQuery, GetNotificationStatusDetailsForUserQuery>();
services.AddScoped<IGetNotificationStatusForUserQuery, GetNotificationStatusForUserQuery>();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries.Interfaces;
Expand All @@ -21,8 +22,8 @@ public GetNotificationStatusDetailsForUserQuery(ICurrentContext currentContext,
_notificationRepository = notificationRepository;
}

public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(
NotificationStatusFilter statusFilter)
public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(
NotificationStatusFilter statusFilter, PageOptions pageOptions)
{
if (!_currentContext.UserId.HasValue)
{
Expand All @@ -33,6 +34,6 @@ public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilte

// Note: only returns the user's notifications - no authorization check needed
return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType,
statusFilter);
statusFilter, pageOptions);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#nullable enable
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;

namespace Bit.Core.NotificationCenter.Queries.Interfaces;

public interface IGetNotificationStatusDetailsForUserQuery
{
Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter);
Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter,
PageOptions pageOptions);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#nullable enable
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
Expand All @@ -22,10 +23,13 @@ public interface INotificationRepository : IRepository<Notification, Guid>
/// If both <see cref="NotificationStatusFilter.Read"/> and <see cref="NotificationStatusFilter.Deleted"/>
/// are not set, includes notifications without a status.
/// </param>
/// <param name="pageOptions">
/// Pagination options.
/// </param>
/// <returns>
/// Ordered by priority (highest to lowest) and creation date (descending).
/// Paged results ordered by priority (descending, highest to lowest) and creation date (descending).
/// Includes all fields from <see cref="Notification"/> and <see cref="NotificationStatus"/>
/// </returns>
Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType,
NotificationStatusFilter? statusFilter);
Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType,
NotificationStatusFilter? statusFilter, PageOptions pageOptions);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
using System.Data;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
Expand All @@ -24,16 +25,35 @@ public NotificationRepository(string connectionString, string readOnlyConnection
{
}

public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter)
public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions)
{
await using var connection = new SqlConnection(ConnectionString);

if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber))
{
pageNumber = 1;
}

var results = await connection.QueryAsync<NotificationStatusDetails>(
"[dbo].[Notification_ReadByUserIdAndStatus]",
new { UserId = userId, ClientType = clientType, statusFilter?.Read, statusFilter?.Deleted },
new
{
UserId = userId,
ClientType = clientType,
statusFilter?.Read,
statusFilter?.Deleted,
PageNumber = pageNumber,
pageOptions.PageSize
},
commandType: CommandType.StoredProcedure);

return results.ToList();
var data = results.ToList();

return new PagedResult<NotificationStatusDetails>
{
Data = data,
ContinuationToken = data.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString()
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Repositories;
Expand Down Expand Up @@ -36,28 +37,41 @@ public NotificationRepository(IServiceScopeFactory serviceScopeFactory, IMapper
return Mapper.Map<List<Core.NotificationCenter.Entities.Notification>>(notifications);
}

public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter)
public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);

if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber))
{
pageNumber = 1;
}

var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType);

var query = notificationStatusDetailsViewQuery.Run(dbContext);
if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null))
{
query = from n in query
where statusFilter.Read == null ||
(statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null) ||
statusFilter.Deleted == null ||
(statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null)
where (statusFilter.Read == null ||
(statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null)) &&
(statusFilter.Deleted == null ||
(statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null))
select n;
}

return await query
var results = await query
.OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate)
.Skip(pageOptions.PageSize * (pageNumber - 1))
.Take(pageOptions.PageSize)
.ToListAsync();

return new PagedResult<NotificationStatusDetails>
{
Data = results,
ContinuationToken = results.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString()
};
}
}
2 changes: 2 additions & 0 deletions src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.KeyManagement;
using Bit.Core.NotificationCenter;
using Bit.Core.NotificationHub;
using Bit.Core.OrganizationFeatures;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -122,6 +123,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett
services.AddVaultServices();
services.AddReportingServices();
services.AddKeyManagementServices();
services.AddNotificationCenterServices();
}

public static void AddTokenizers(this IServiceCollection services)
Expand Down
Loading

0 comments on commit 21fcfcd

Please sign in to comment.