Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/Orbit.Api/Authentication/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

Expand Down Expand Up @@ -32,6 +34,7 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
var apiKeyRepository = scope.ServiceProvider.GetRequiredService<IGenericRepository<ApiKey>>();
var payGate = scope.ServiceProvider.GetRequiredService<IPayGateService>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
var cache = scope.ServiceProvider.GetRequiredService<IMemoryCache>();

var candidates = await apiKeyRepository.FindTrackedAsync(
k => k.KeyPrefix == keyPrefix && !k.IsRevoked);
Expand All @@ -52,6 +55,8 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
candidate.MarkUsed();
await unitOfWork.SaveChangesAsync();

cache.Remove(ReferenceCacheKeys.ApiKeys(candidate.UserId));

var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, candidate.UserId.ToString()),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand Down Expand Up @@ -26,7 +27,8 @@ public record CreateApiKeyCommand(
public class CreateApiKeyCommandHandler(
IGenericRepository<ApiKey> apiKeyRepository,
IPayGateService payGate,
IUnitOfWork unitOfWork) : IRequestHandler<CreateApiKeyCommand, Result<CreateApiKeyResponse>>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<CreateApiKeyCommand, Result<CreateApiKeyResponse>>
{
private const int MaxActiveKeys = 5;

Expand Down Expand Up @@ -57,6 +59,8 @@ public async Task<Result<CreateApiKeyResponse>> Handle(CreateApiKeyCommand reque
await apiKeyRepository.AddAsync(apiKey, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

cache.Remove(ReferenceCacheKeys.ApiKeys(request.UserId));

return Result.Success(new CreateApiKeyResponse(
apiKey.Id,
apiKey.Name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand All @@ -13,7 +14,8 @@ public record RevokeApiKeyCommand(
public class RevokeApiKeyCommandHandler(
IGenericRepository<ApiKey> apiKeyRepository,
IPayGateService payGate,
IUnitOfWork unitOfWork) : IRequestHandler<RevokeApiKeyCommand, Result>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<RevokeApiKeyCommand, Result>
{
public async Task<Result> Handle(RevokeApiKeyCommand request, CancellationToken cancellationToken)
{
Expand All @@ -32,6 +34,8 @@ public async Task<Result> Handle(RevokeApiKeyCommand request, CancellationToken
apiKey.Revoke();
await unitOfWork.SaveChangesAsync(cancellationToken);

cache.Remove(ReferenceCacheKeys.ApiKeys(request.UserId));

return Result.Success();
}
}
13 changes: 12 additions & 1 deletion src/Orbit.Application/ApiKeys/Queries/GetApiKeysQuery.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand All @@ -21,14 +22,19 @@ public record GetApiKeysQuery(Guid UserId) : IRequest<Result<IReadOnlyList<ApiKe

public class GetApiKeysQueryHandler(
IGenericRepository<ApiKey> apiKeyRepository,
IPayGateService payGate) : IRequestHandler<GetApiKeysQuery, Result<IReadOnlyList<ApiKeyResponse>>>
IPayGateService payGate,
IMemoryCache cache) : IRequestHandler<GetApiKeysQuery, Result<IReadOnlyList<ApiKeyResponse>>>
{
public async Task<Result<IReadOnlyList<ApiKeyResponse>>> Handle(GetApiKeysQuery request, CancellationToken cancellationToken)
{
var gateCheck = await payGate.CanReadApiKeys(request.UserId, cancellationToken);
if (gateCheck.IsFailure)
return gateCheck.PropagateError<IReadOnlyList<ApiKeyResponse>>();

var cacheKey = ReferenceCacheKeys.ApiKeys(request.UserId);
if (cache.TryGetValue(cacheKey, out IReadOnlyList<ApiKeyResponse>? cached) && cached is not null)
return Result.Success(cached);

var keys = await apiKeyRepository.FindAsync(
k => k.UserId == request.UserId,
cancellationToken);
Expand All @@ -47,6 +53,11 @@ public async Task<Result<IReadOnlyList<ApiKeyResponse>>> Handle(GetApiKeysQuery
k.IsRevoked))
.ToList();

cache.Set(cacheKey, (IReadOnlyList<ApiKeyResponse>)result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl
});

return Result.Success<IReadOnlyList<ApiKeyResponse>>(result);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand All @@ -13,7 +14,8 @@ public record CreateChecklistTemplateCommand(

public class CreateChecklistTemplateCommandHandler(
IGenericRepository<ChecklistTemplate> repository,
IUnitOfWork unitOfWork) : IRequestHandler<CreateChecklistTemplateCommand, Result<Guid>>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<CreateChecklistTemplateCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateChecklistTemplateCommand request, CancellationToken cancellationToken)
{
Expand All @@ -24,6 +26,8 @@ public async Task<Result<Guid>> Handle(CreateChecklistTemplateCommand request, C
await repository.AddAsync(result.Value, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

cache.Remove(ReferenceCacheKeys.ChecklistTemplates(request.UserId));

return Result.Success(result.Value.Id);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
Expand All @@ -12,7 +13,8 @@ public record DeleteChecklistTemplateCommand(

public class DeleteChecklistTemplateCommandHandler(
IGenericRepository<ChecklistTemplate> repository,
IUnitOfWork unitOfWork) : IRequestHandler<DeleteChecklistTemplateCommand, Result>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<DeleteChecklistTemplateCommand, Result>
{
public async Task<Result> Handle(DeleteChecklistTemplateCommand request, CancellationToken cancellationToken)
{
Expand All @@ -25,6 +27,9 @@ public async Task<Result> Handle(DeleteChecklistTemplateCommand request, Cancell

template.SoftDelete();
await unitOfWork.SaveChangesAsync(cancellationToken);

cache.Remove(ReferenceCacheKeys.ChecklistTemplates(request.UserId));

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
Expand All @@ -13,10 +15,15 @@ public record ChecklistTemplateResponse(
public record GetChecklistTemplatesQuery(Guid UserId) : IRequest<Result<IReadOnlyList<ChecklistTemplateResponse>>>;

public class GetChecklistTemplatesQueryHandler(
IGenericRepository<ChecklistTemplate> repository) : IRequestHandler<GetChecklistTemplatesQuery, Result<IReadOnlyList<ChecklistTemplateResponse>>>
IGenericRepository<ChecklistTemplate> repository,
IMemoryCache cache) : IRequestHandler<GetChecklistTemplatesQuery, Result<IReadOnlyList<ChecklistTemplateResponse>>>
{
public async Task<Result<IReadOnlyList<ChecklistTemplateResponse>>> Handle(GetChecklistTemplatesQuery request, CancellationToken cancellationToken)
{
var cacheKey = ReferenceCacheKeys.ChecklistTemplates(request.UserId);
if (cache.TryGetValue(cacheKey, out IReadOnlyList<ChecklistTemplateResponse>? cached) && cached is not null)
return Result.Success(cached);

var templates = await repository.FindAsync(
t => t.UserId == request.UserId,
cancellationToken);
Expand All @@ -26,6 +33,11 @@ public async Task<Result<IReadOnlyList<ChecklistTemplateResponse>>> Handle(GetCh
.Select(t => new ChecklistTemplateResponse(t.Id, t.Name, t.Items))
.ToList();

cache.Set(cacheKey, (IReadOnlyList<ChecklistTemplateResponse>)result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl
});

return Result.Success<IReadOnlyList<ChecklistTemplateResponse>>(result);
}
}
22 changes: 18 additions & 4 deletions src/Orbit.Application/Common/CacheInvalidationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public static class CacheInvalidationHelper

public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var nowAtUtc = DateTime.UtcNow;
var today = DateOnly.FromDateTime(nowAtUtc);
for (int i = -2; i <= 2; i++)
{
var date = today.AddDays(i);
Expand All @@ -29,7 +30,8 @@ public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId)
/// </summary>
public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var nowAtUtc = DateTime.UtcNow;
var today = DateOnly.FromDateTime(nowAtUtc);
for (int i = -2; i <= 2; i++)
{
var date = today.AddDays(i);
Expand All @@ -40,12 +42,24 @@ public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId)
}

/// <summary>
/// Convenience: invalidate both summary and retrospective caches for a user. Use this from
/// any mutation command that affects habits, logs, or goals.
/// Invalidate the per-user, per-language goal-review cache. The review is derived from the
/// user's active goals and their linked habits' logs, so any goal or habit mutation must clear
/// it, otherwise users see a stale 1-hour-old review after editing a goal or logging a habit.
/// </summary>
public static void InvalidateGoalReviewCache(IMemoryCache cache, Guid userId)
{
foreach (var lang in AppConstants.SupportedLanguages)
cache.Remove($"goal-review:{userId}:{lang}");
}

/// <summary>
/// Convenience: invalidate the summary, retrospective, and goal-review caches for a user. Use
/// this from any mutation command that affects habits, logs, or goals.
/// </summary>
public static void InvalidateUserAiCaches(IMemoryCache cache, Guid userId)
{
InvalidateSummaryCache(cache, userId);
InvalidateRetrospectiveCache(cache, userId);
InvalidateGoalReviewCache(cache, userId);
}
}
24 changes: 24 additions & 0 deletions src/Orbit.Application/Common/ReferenceCacheKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Orbit.Application.Common;

/// <summary>
/// Cache keys and backstop TTL for per-user reference-data reads (tags, checklist templates, user
/// facts, API keys). Centralized so the read query and every mutation that invalidates it share a
/// single key definition and cannot drift apart.
/// </summary>
public static class ReferenceCacheKeys
{
/// <summary>
/// Backstop expiry for reference-data cache entries. Every known write path invalidates its key
/// explicitly, so this only bounds staleness in the event a future write path forgets to; kept
/// short for that safety margin.
/// </summary>
public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10);

public static string Tags(Guid userId) => $"tags:{userId}";

public static string ChecklistTemplates(Guid userId) => $"checklist-templates:{userId}";

public static string UserFacts(Guid userId) => $"user-facts:{userId}";

public static string ApiKeys(Guid userId) => $"api-keys:{userId}";
}
4 changes: 4 additions & 0 deletions src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Domain.Common;
Expand All @@ -24,6 +25,7 @@ public partial class CreateGoalCommandHandler(
IUserDateService userDateService,
IGamificationService gamificationService,
IUnitOfWork unitOfWork,
IMemoryCache cache,
ILogger<CreateGoalCommandHandler> logger) : IRequestHandler<CreateGoalCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateGoalCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -65,6 +67,8 @@ public async Task<Result<Guid>> Handle(CreateGoalCommand request, CancellationTo
LogGamificationGoalCreationFailed(logger, ex, request.UserId);
}

CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId);

return Result.Success(goal.Id);
}

Expand Down
6 changes: 5 additions & 1 deletion src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
Expand All @@ -14,7 +15,8 @@ public record DeleteGoalCommand(
public class DeleteGoalCommandHandler(
IGenericRepository<Goal> goalRepository,
IPayGateService payGate,
IUnitOfWork unitOfWork) : IRequestHandler<DeleteGoalCommand, Result>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<DeleteGoalCommand, Result>
{
public async Task<Result> Handle(DeleteGoalCommand request, CancellationToken cancellationToken)
{
Expand All @@ -32,6 +34,8 @@ public async Task<Result> Handle(DeleteGoalCommand request, CancellationToken ca
goal.SoftDelete();
await unitOfWork.SaveChangesAsync(cancellationToken);

CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
Expand All @@ -17,7 +18,8 @@ public class LinkHabitsToGoalCommandHandler(
IGenericRepository<Goal> goalRepository,
IGenericRepository<Habit> habitRepository,
IPayGateService payGate,
IUnitOfWork unitOfWork) : IRequestHandler<LinkHabitsToGoalCommand, Result>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<LinkHabitsToGoalCommand, Result>
{
public async Task<Result> Handle(LinkHabitsToGoalCommand request, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -51,6 +53,9 @@ public async Task<Result> Handle(LinkHabitsToGoalCommand request, CancellationTo
goal.AddHabit(habit);

await unitOfWork.SaveChangesAsync(cancellationToken);

CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId);

return Result.Success();
}
}
7 changes: 6 additions & 1 deletion src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
Expand All @@ -16,7 +17,8 @@ public record ReorderGoalsCommand(
public class ReorderGoalsCommandHandler(
IGenericRepository<Goal> goalRepository,
IPayGateService payGate,
IUnitOfWork unitOfWork) : IRequestHandler<ReorderGoalsCommand, Result>
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<ReorderGoalsCommand, Result>
{
public async Task<Result> Handle(ReorderGoalsCommand request, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -47,6 +49,9 @@ public async Task<Result> Handle(ReorderGoalsCommand request, CancellationToken
}

await unitOfWork.SaveChangesAsync(cancellationToken);

CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId);

return Result.Success();
}
}
Loading
Loading