Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 17 additions & 1 deletion src/Api/Dirt/Controllers/OrganizationReportsController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ο»Ώusing Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Exceptions;
Expand Down Expand Up @@ -130,7 +131,22 @@ public async Task<IActionResult> UpdateOrganizationReportAsync(Guid organization

# region SummaryData Field Endpoints

/// <summary>
/// Gets summary data for organization reports within a specified date range.
/// The response is optimized for widget display by returning up to 6 entries that are
/// evenly spaced across the date range, including the most recent entry.
/// This allows the widget to show trends over time while ensuring the latest data point is always included.
/// </summary>
/// <param name="organizationId"></param>
/// <param name="startDate"></param>
/// <param name="endDate"></param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
/// <exception cref="BadRequestException"></exception>
[HttpGet("{organizationId}/data/summary")]
[ProducesResponseType<IEnumerable<OrganizationReportSummaryDataResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsync(
Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
{
Expand All @@ -139,7 +155,7 @@ public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsyn
throw new NotFoundException();
}

if (organizationId.Equals(null))
if (organizationId == Guid.Empty)
{
throw new BadRequestException("Organization ID is required.");
}
Expand Down
2 changes: 1 addition & 1 deletion src/Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices();
services.AddBillingOperations();
services.AddReportingServices();
services.AddReportingServices(globalSettings);
services.AddImportServices();

services.AddSendServices();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
ο»Ώnamespace Bit.Core.Dirt.Models.Data;
ο»Ώusing System.Text.Json.Serialization;

namespace Bit.Core.Dirt.Models.Data;

public class OrganizationReportSummaryDataResponse
{
public string? SummaryData { get; set; }
public required Guid OrganizationId { get; set; }
[JsonPropertyName("encryptedData")]
public required string SummaryData { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SummaryData and RevisionDate (below) properties are required here, but both columns in the DB allow nulls. Not sure if it's an issue or not but I just wanted to point it out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkincaid-bw you are right - the procedures are required - if there is no summary data, than this object is not relevant. And I tested it in the database

image

[JsonPropertyName("encryptionKey")]
public required string ContentEncryptionKey { get; set; }
[JsonPropertyName("date")]
public required DateTime RevisionDate { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,29 @@
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Bit.Core.Dirt.Reports.ReportFeatures;

public class AddOrganizationReportCommand : IAddOrganizationReportCommand
{
private readonly IOrganizationRepository _organizationRepo;
private readonly IOrganizationReportRepository _organizationReportRepo;
private readonly IFusionCache _cache;
private ILogger<AddOrganizationReportCommand> _logger;

public AddOrganizationReportCommand(
IOrganizationRepository organizationRepository,
IOrganizationReportRepository organizationReportRepository,
[FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache,
ILogger<AddOrganizationReportCommand> logger)
{
_organizationRepo = organizationRepository;
_organizationReportRepo = organizationReportRepository;
_cache = cache;
_logger = logger;
}

Expand Down Expand Up @@ -64,6 +70,8 @@ public async Task<OrganizationReport> AddOrganizationReportAsync(AddOrganization

var data = await _organizationReportRepo.CreateAsync(organizationReport);

await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId));

_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}",
request.OrganizationId, data.Id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,35 @@
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Bit.Core.Dirt.Reports.ReportFeatures;

public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery
{
private const int MaxRecordsForWidget = 6;
private readonly IOrganizationReportRepository _organizationReportRepo;
private readonly ILogger<GetOrganizationReportSummaryDataByDateRangeQuery> _logger;
private readonly IFusionCache _cache;

public GetOrganizationReportSummaryDataByDateRangeQuery(
IOrganizationReportRepository organizationReportRepo,
[FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache,
ILogger<GetOrganizationReportSummaryDataByDateRangeQuery> logger)
{
_organizationReportRepo = organizationReportRepo;
_cache = cache;
_logger = logger;
}

public async Task<IEnumerable<OrganizationReportSummaryDataResponse>> GetOrganizationReportSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate)
{
try
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}",
_logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}",
organizationId, startDate, endDate);

var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate);
Expand All @@ -33,30 +40,35 @@ public async Task<IEnumerable<OrganizationReportSummaryDataResponse>> GetOrganiz
throw new BadRequestException(errorMessage);
}

IEnumerable<OrganizationReportSummaryDataResponse> summaryDataList = (await _organizationReportRepo
.GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ??
Enumerable.Empty<OrganizationReportSummaryDataResponse>();
// update start and end date to include the entire day
startDate = startDate.Date;
endDate = endDate.Date.AddDays(1).AddTicks(-1);

var resultList = summaryDataList.ToList();
// cache key and tag
var cacheKey = OrganizationReportCacheConstants.BuildCacheKeyForSummaryDataByDateRange(organizationId, startDate, endDate);
var cacheTag = OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId);

if (!resultList.Any())
{
_logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}",
organizationId, startDate, endDate);
return Enumerable.Empty<OrganizationReportSummaryDataResponse>();
}
else
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}",
resultList.Count, organizationId, startDate, endDate);
var summaryDataList = await _cache.GetOrSetAsync(
key: cacheKey,
factory: async _ =>
{
var data = await _organizationReportRepo.GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate);
return GetMostRecentEntries(data);
},
options: new FusionCacheEntryOptions(duration: OrganizationReportCacheConstants.DurationForSummaryData),
tags: [cacheTag]
);

}
var resultList = summaryDataList?.ToList() ?? Enumerable.Empty<OrganizationReportSummaryDataResponse>().ToList();

_logger.LogInformation(Constants.BypassFiltersEventId, "Fetched {Count} organization report summary data entries for organization {OrganizationId}, from {StartDate} to {EndDate}",
resultList.Count, organizationId, startDate, endDate);

return resultList;
}
catch (Exception ex) when (!(ex is BadRequestException))
{
_logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}",
_logger.LogError(ex, "Error fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}",
organizationId, startDate, endDate);
throw;
}
Expand Down Expand Up @@ -86,4 +98,27 @@ private static (bool IsValid, string errorMessage) ValidateRequest(Guid organiza

return (true, string.Empty);
}

private static IEnumerable<OrganizationReportSummaryDataResponse> GetMostRecentEntries(IEnumerable<OrganizationReportSummaryDataResponse> data, int maxEntries = MaxRecordsForWidget)
{
if (data.Count() <= maxEntries)
{
return data;
}

// here we need to take 10 records, evenly spaced by RevisionDate,
// to cover the entire date range,
// and ensure we include the most recent record as well
var sortedData = data.OrderByDescending(d => d.RevisionDate).ToList();
var totalRecords = sortedData.Count;
var interval = (double)(totalRecords - 1) / (maxEntries - 1); // -1 the most recent record will be included by default
var result = new List<OrganizationReportSummaryDataResponse>();

for (int i = 0; i <= maxEntries - 1; i++)
{
result.Add(sortedData[(int)Math.Round(i * interval)]);
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
ο»Ώusing Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Dirt.Reports.ReportFeatures;

public static class ReportingServiceCollectionExtensions
{
public static void AddReportingServices(this IServiceCollection services)
public static void AddReportingServices(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddExtendedCache(OrganizationReportCacheConstants.CacheName, (GlobalSettings)globalSettings);

services.AddScoped<IRiskInsightsReportQuery, RiskInsightsReportQuery>();
services.AddScoped<IMemberAccessReportQuery, MemberAccessReportQuery>();
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Bit.Core.Dirt.Reports.ReportFeatures;

Expand All @@ -13,15 +16,17 @@ public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand
private readonly IOrganizationRepository _organizationRepo;
private readonly IOrganizationReportRepository _organizationReportRepo;
private readonly ILogger<UpdateOrganizationReportCommand> _logger;

private readonly IFusionCache _cache;
public UpdateOrganizationReportCommand(
IOrganizationRepository organizationRepository,
IOrganizationReportRepository organizationReportRepository,
ILogger<UpdateOrganizationReportCommand> logger)
ILogger<UpdateOrganizationReportCommand> logger,
[FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache)
{
_organizationRepo = organizationRepository;
_organizationReportRepo = organizationReportRepository;
_logger = logger;
_cache = cache;
}

public async Task<OrganizationReport> UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request)
Expand Down Expand Up @@ -61,6 +66,9 @@ public async Task<OrganizationReport> UpdateOrganizationReportAsync(UpdateOrgani

await _organizationReportRepo.UpsertAsync(existingReport);

// Invalidate cache
await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId));

_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}",
request.ReportId, request.OrganizationId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Bit.Core.Dirt.Reports.ReportFeatures;

Expand All @@ -14,15 +17,17 @@ public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportS
private readonly IOrganizationRepository _organizationRepo;
private readonly IOrganizationReportRepository _organizationReportRepo;
private readonly ILogger<UpdateOrganizationReportSummaryCommand> _logger;

private readonly IFusionCache _cache;
public UpdateOrganizationReportSummaryCommand(
IOrganizationRepository organizationRepository,
IOrganizationReportRepository organizationReportRepository,
ILogger<UpdateOrganizationReportSummaryCommand> logger)
ILogger<UpdateOrganizationReportSummaryCommand> logger,
[FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache)
{
_organizationRepo = organizationRepository;
_organizationReportRepo = organizationReportRepository;
_logger = logger;
_cache = cache;
}

public async Task<OrganizationReport> UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request)
Expand Down Expand Up @@ -57,6 +62,9 @@ public async Task<OrganizationReport> UpdateOrganizationReportSummaryAsync(Updat
await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics));
var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty);

// Invalidate cache
await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId));

_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}",
request.ReportId, request.OrganizationId);

Expand Down
44 changes: 44 additions & 0 deletions src/Core/Utilities/OrganizationReportCacheConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
ο»Ώnamespace Bit.Core.Utilities;

/// <summary>
/// Provides cache key generation helpers and cache name constants for organization report–related entities.
/// </summary>
public static class OrganizationReportCacheConstants
{
/// <summary>
/// The cache name used for storing organization report data.
/// </summary>
public const string CacheName = "OrganizationReports";

/// <summary>
/// Duration TimeSpan for caching organization report summary data.
/// Consider: Reports might be regenerated daily, so cache for shorter periods.
/// </summary>
public static readonly TimeSpan DurationForSummaryData = TimeSpan.FromHours(6);

/// <summary>
/// Builds a deterministic cache key for organization report summary data by date range.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="startDate">The start date of the date range.</param>
/// <param name="endDate">The end date of the date range.</param>
/// <returns>
/// A cache key for the organization report summary data.
/// </returns>
public static string BuildCacheKeyForSummaryDataByDateRange(
Guid organizationId,
DateTime startDate,
DateTime endDate)
=> $"OrganizationReportSummaryData:{organizationId:N}:{startDate:yyyy-MM-dd}:{endDate:yyyy-MM-dd}";

/// <summary>
/// Builds a cache tag for an organization's report data.
/// Used for bulk invalidation when organization reports are updated.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <returns>
/// A cache tag for the organization's reports.
/// </returns>
public static string BuildCacheTagForOrganizationReports(Guid organizationId)
=> $"OrganizationReports:{organizationId:N}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ public async Task<IEnumerable<OrganizationReportSummaryDataResponse>> GetSummary
var parameters = new
{
OrganizationId = organizationId,
StartDate = startDate,
EndDate = endDate
StartDate = startDate.ToUniversalTime(),
EndDate = endDate.ToUniversalTime()
};

var results = await connection.QueryAsync<OrganizationReportSummaryDataResponse>(
$"[{Schema}].[OrganizationReport_GetSummariesByDateRange]",
$"[{Schema}].[OrganizationReport_ReadByOrganizationIdAndRevisionDate]",
parameters,
commandType: CommandType.StoredProcedure);

Expand Down
Loading
Loading