mirror of
https://github.com/bitwarden/server.git
synced 2025-06-30 15:42:48 -05:00
[PM-10563] Notification Center API (#4852)
* 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
This commit is contained in:
@ -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);
|
||||
}
|
||||
}
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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>();
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -21,8 +22,8 @@ public class GetNotificationStatusDetailsForUserQuery : IGetNotificationStatusDe
|
||||
_notificationRepository = notificationRepository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(
|
||||
NotificationStatusFilter statusFilter)
|
||||
public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(
|
||||
NotificationStatusFilter statusFilter, PageOptions pageOptions)
|
||||
{
|
||||
if (!_currentContext.UserId.HasValue)
|
||||
{
|
||||
@ -33,6 +34,6 @@ public class GetNotificationStatusDetailsForUserQuery : IGetNotificationStatusDe
|
||||
|
||||
// Note: only returns the user's notifications - no authorization check needed
|
||||
return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType,
|
||||
statusFilter);
|
||||
statusFilter, pageOptions);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.NotificationCenter.Models.Data;
|
||||
using Bit.Core.NotificationCenter.Models.Filter;
|
||||
|
||||
@ -6,5 +7,6 @@ namespace Bit.Core.NotificationCenter.Queries.Interfaces;
|
||||
|
||||
public interface IGetNotificationStatusDetailsForUserQuery
|
||||
{
|
||||
Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter);
|
||||
Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter,
|
||||
PageOptions pageOptions);
|
||||
}
|
||||
|
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
@ -24,16 +25,35 @@ public class NotificationRepository : Repository<Notification, Guid>, INotificat
|
||||
{
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
@ -36,28 +37,41 @@ public class NotificationRepository : Repository<Core.NotificationCenter.Entitie
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ using Bit.Core.HostedServices;
|
||||
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;
|
||||
@ -122,6 +123,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddVaultServices();
|
||||
services.AddReportingServices();
|
||||
services.AddKeyManagementServices();
|
||||
services.AddNotificationCenterServices();
|
||||
}
|
||||
|
||||
public static void AddTokenizers(this IServiceCollection services)
|
||||
|
@ -2,7 +2,9 @@ CREATE PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus]
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@ClientType TINYINT,
|
||||
@Read BIT,
|
||||
@Deleted BIT
|
||||
@Deleted BIT,
|
||||
@PageNumber INT = 1,
|
||||
@PageSize INT = 10
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@ -21,13 +23,14 @@ BEGIN
|
||||
AND ou.[OrganizationId] IS NOT NULL))
|
||||
AND ((@Read IS NULL AND @Deleted IS NULL)
|
||||
OR (n.[NotificationStatusUserId] IS NOT NULL
|
||||
AND ((@Read IS NULL
|
||||
AND (@Read IS NULL
|
||||
OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR
|
||||
(@Read = 0 AND n.[ReadDate] IS NULL),
|
||||
1, 0) = 1)
|
||||
OR (@Deleted IS NULL
|
||||
OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
|
||||
(@Deleted = 0 AND n.[DeletedDate] IS NULL),
|
||||
1, 0) = 1))))
|
||||
AND (@Deleted IS NULL
|
||||
OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
|
||||
(@Deleted = 0 AND n.[DeletedDate] IS NULL),
|
||||
1, 0) = 1)))
|
||||
ORDER BY [Priority] DESC, n.[CreationDate] DESC
|
||||
OFFSET @PageSize * (@PageNumber - 1) ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||
END
|
||||
|
Reference in New Issue
Block a user