Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-10563] Notification Center API #4852

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ef32d33
PM-10563: Notification Center API
mzieniukbw Oct 1, 2024
8bd88a5
PM-10563: continuation token hack
mzieniukbw Oct 3, 2024
9ed89e3
Merge branch 'main' into km/pm-10563
mzieniukbw Oct 3, 2024
0ee4bc1
PM-10563: Resolving merge conflicts
mzieniukbw Oct 4, 2024
aa7a62d
PM-10563: Unit Tests
mzieniukbw Oct 4, 2024
5b3a09d
PM-10563: Paging simplification by page number and size in database
mzieniukbw Oct 7, 2024
9fbd6c7
Merge branch 'main' into km/pm-10563
mzieniukbw Oct 7, 2024
ddd8192
PM-10563: Request validation
mzieniukbw Oct 8, 2024
892d9a2
PM-10563: Read, Deleted status filters change
mzieniukbw Oct 8, 2024
ca872fa
PM-10563: Plural name for tests
mzieniukbw Oct 9, 2024
86da346
PM-10563: Request validation to always for int type
mzieniukbw Oct 17, 2024
b7d6009
PM-10563: Continuation Token returns null on response when no more reโ€ฆ
mzieniukbw Oct 17, 2024
9621a1c
PM-10563: Integration tests for GET
mzieniukbw Oct 17, 2024
a07e51f
Merge branch 'main' into km/pm-10563
mzieniukbw Oct 17, 2024
4775247
PM-10563: Mark notification read, deleted commands date typos fix
mzieniukbw Oct 17, 2024
ce6c88f
PM-10563: Integration tests for PATCH read, deleted
mzieniukbw Oct 17, 2024
3204bd6
PM-10563: Request, Response models tests
mzieniukbw Oct 17, 2024
c426b4d
PM-10563: EditorConfig compliance
mzieniukbw Oct 18, 2024
5548171
PM-10563: Extracting to const
mzieniukbw Oct 18, 2024
e321c8a
Merge branch 'main' into km/pm-10563
mzieniukbw Oct 22, 2024
59ae407
Merge remote-tracking branch 'origin/main' into km/pm-10563
mzieniukbw Nov 19, 2024
458a1a1
PM-10563: Update db migration script date
mzieniukbw Nov 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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;
}

Check warning on line 36 in src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs

View check run for this annotation

Codecov / codecov/patch

src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs#L34-L36

Added lines #L34 - L36 were not covered by tests

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

Check warning on line 48 in src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs

View check run for this annotation

Codecov / codecov/patch

src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs#L40-L48

Added lines #L40 - L48 were not covered by tests
commandType: CommandType.StoredProcedure);

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

Check warning on line 51 in src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs

View check run for this annotation

Codecov / codecov/patch

src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs#L51

Added line #L51 was not covered by tests

return new PagedResult<NotificationStatusDetails>
{
Data = data,
ContinuationToken = data.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString()
};

Check warning on line 57 in src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs

View check run for this annotation

Codecov / codecov/patch

src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs#L54-L57

Added lines #L54 - L57 were not covered by tests
}
}
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 @@ -26,6 +26,7 @@
using Bit.Core.HostedServices;
using Bit.Core.Identity;
using Bit.Core.IdentityServer;
using Bit.Core.NotificationCenter;
using Bit.Core.NotificationHub;
using Bit.Core.OrganizationFeatures;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -120,6 +121,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
services.AddVaultServices();
services.AddReportingServices();
services.AddNotificationCenterServices();
}

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