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
12 changes: 10 additions & 2 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.ModelBinding;
using Orbit.Api.RateLimiting;
using Orbit.Application.Common;
using Orbit.Application.Habits.Commands;
Expand Down Expand Up @@ -387,13 +388,20 @@ public async Task<IActionResult> RestoreHabit(Guid id, CancellationToken cancell
});
}

/// <summary>Returns recent logs, or every log in an inclusive range of at most 366 calendar days.</summary>
/// <param name="from">First date in the user's timezone (yyyy-MM-dd). Supply together with to.</param>
/// <param name="to">Last date in the user's timezone (yyyy-MM-dd). Supply together with from.</param>
[HttpGet("{id:guid}/logs")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetLogs(Guid id, CancellationToken cancellationToken)
public async Task<IActionResult> GetLogs(
Guid id,
CancellationToken cancellationToken,
[FromQuery, ModelBinder(BinderType = typeof(IsoDateQueryModelBinder))] DateOnly? from = null,
[FromQuery, ModelBinder(BinderType = typeof(IsoDateQueryModelBinder))] DateOnly? to = null)
{
var query = new GetHabitLogsQuery(HttpContext.GetUserId(), id);
var query = new GetHabitLogsQuery(HttpContext.GetUserId(), id, from, to);
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(v => Ok(v));
}
Expand Down
34 changes: 34 additions & 0 deletions src/Orbit.Api/ModelBinding/IsoDateQueryModelBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Globalization;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Orbit.Application.Common;

namespace Orbit.Api.ModelBinding;

public sealed class IsoDateQueryModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (value == ValueProviderResult.None)
{
bindingContext.Result = ModelBindingResult.Success(null);
return Task.CompletedTask;
}

if (value.Length != 1 || !DateOnly.TryParseExact(
value.FirstValue, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{
throw new ValidationException([
new ValidationFailure(bindingContext.ModelName, ErrorCopy.English(ValidationErrorCodes.CalendarDateValid))
{
ErrorCode = ValidationErrorCodes.CalendarDateValid
}
]);
}

bindingContext.Result = ModelBindingResult.Success(date);
return Task.CompletedTask;
}
}
16 changes: 16 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7492,6 +7492,22 @@
"format": "uuid"
}
},
{
"name": "from",
"in": "query",
"schema": {
"type": "string",
"format": "date"
}
},
{
"name": "to",
"in": "query",
"schema": {
"type": "string",
"format": "date"
}
},
{
"name": "api-version",
"in": "header",
Expand Down
16 changes: 16 additions & 0 deletions src/Orbit.Api/openapi_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -7585,6 +7585,22 @@
"format": "uuid"
}
},
{
"name": "from",
"in": "query",
"schema": {
"type": "string",
"format": "date"
}
},
{
"name": "to",
"in": "query",
"schema": {
"type": "string",
"format": "date"
}
},
{
"name": "api-version",
"in": "header",
Expand Down
29 changes: 20 additions & 9 deletions src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ public record HabitLogResponse(
decimal Value,
DateTime CreatedAtUtc);

public record GetHabitLogsQuery(Guid UserId, Guid HabitId) : IRequest<Result<IReadOnlyList<HabitLogResponse>>>;
public record GetHabitLogsQuery(
Guid UserId,
Guid HabitId,
DateOnly? From = null,
DateOnly? To = null) : IRequest<Result<IReadOnlyList<HabitLogResponse>>>;

public class GetHabitLogsQueryHandler(
IGenericRepository<Habit> habitRepository,
Expand All @@ -28,14 +32,21 @@ public async Task<Result<IReadOnlyList<HabitLogResponse>>> Handle(GetHabitLogsQu
if (habit is null)
return Result.Failure<IReadOnlyList<HabitLogResponse>>(ErrorMessages.HabitNotFound);

var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);
var cutoff = userToday.AddDays(-AppConstants.HabitLogsLookbackDays);

var logs = await habitLogReader.ReadRecentLogsAsync(
request.HabitId,
cutoff,
AppConstants.MaxHabitLogsReturned,
cancellationToken);
IReadOnlyList<HabitLog> logs;
if (request.From is { } from && request.To is { } to)
{
logs = await habitLogReader.ReadRangeLogsAsync(request.HabitId, from, to, cancellationToken);
}
else
{
var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);
var cutoff = userToday.AddDays(-AppConstants.HabitLogsLookbackDays);
logs = await habitLogReader.ReadRecentLogsAsync(
request.HabitId,
cutoff,
AppConstants.MaxHabitLogsReturned,
cancellationToken);
}

var result = logs
.Select(l => new HabitLogResponse(l.Id, l.Date, l.Value, l.CreatedAtUtc))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FluentValidation;
using Orbit.Application.Common;
using Orbit.Application.Habits.Queries;

namespace Orbit.Application.Habits.Validators;
Expand All @@ -9,5 +10,23 @@ public GetHabitLogsQueryValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.HabitId).NotEmpty();

RuleFor(x => x.From).NotNull()
.WithCopy(ValidationErrorCodes.DateFromRequired)
.When(x => x.To.HasValue);
RuleFor(x => x.To).NotNull()
.WithCopy(ValidationErrorCodes.DateToRequired)
.When(x => x.From.HasValue);

When(x => x.From.HasValue && x.To.HasValue, () =>
{
RuleFor(x => x.To).GreaterThanOrEqualTo(x => x.From)
.WithCopy(ValidationErrorCodes.DateRangeOrder);
RuleFor(x => x.To)
.Must((query, to) => query.From is { } from && to is { } end
&& end.DayNumber - from.DayNumber < AppConstants.MaxRangeDays)
.WithCopy(ValidationErrorCodes.DateRangeLimit)
.When(x => x.To >= x.From);
});
}
}
12 changes: 9 additions & 3 deletions src/Orbit.Domain/Interfaces/IHabitLogReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
namespace Orbit.Domain.Interfaces;

/// <summary>
/// DB-side read for a habit's recent logs that bounds the result set server-side, so the handler never
/// materializes a habit's full log history to order and cap it in memory. The lookback window, newest-first
/// ordering, and row cap all run inside the query, backed by the (HabitId, Date) index.
/// DB-side reads for habit logs. Date bounds and newest-first ordering run server-side,
/// backed by the (HabitId, Date) index. Recent reads also apply a row cap.
/// </summary>
public interface IHabitLogReader
{
Expand All @@ -22,4 +21,11 @@ Task<IReadOnlyList<HabitLog>> ReadRecentLogsAsync(
DateOnly since,
int limit,
CancellationToken cancellationToken = default);

/// <summary>Loads every log in the inclusive date range, ordered newest-first.</summary>
Task<IReadOnlyList<HabitLog>> ReadRangeLogsAsync(
Guid habitId,
DateOnly from,
DateOnly to,
CancellationToken cancellationToken = default);
}
22 changes: 22 additions & 0 deletions src/Orbit.Infrastructure/Persistence/HabitLogReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ public async Task<IReadOnlyList<HabitLog>> ReadRecentLogsAsync(
.ToListAsync(cancellationToken);
}

public async Task<IReadOnlyList<HabitLog>> ReadRangeLogsAsync(
Guid habitId,
DateOnly from,
DateOnly to,
CancellationToken cancellationToken = default)
{
return await BuildRangeLogs(context.HabitLogs.AsNoTracking(), habitId, from, to)
.ToListAsync(cancellationToken);
}

internal static IOrderedQueryable<HabitLog> BuildRangeLogs(
IQueryable<HabitLog> logs,
Guid habitId,
DateOnly from,
DateOnly to)
{
return logs
.Where(l => l.HabitId == habitId && l.Date >= from && l.Date <= to)
.OrderByDescending(l => l.Date)
.ThenByDescending(l => l.CreatedAtUtc);
}

internal static IQueryable<HabitLog> BuildRecentLogs(
IQueryable<HabitLog> logs,
Guid habitId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,43 @@ public async Task Handle_CallsUserDateService()

await _userDateService.Received(1).GetUserTodayAsync(UserId, Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_ExplicitRange_PassesDatesAndCancellationWithoutResolvingToday()
{
var from = new DateOnly(2025, 1, 1);
var to = new DateOnly(2025, 1, 31);
var habit = CreateTestHabit();
var log = habit.Log(from).Value;
SetupHabitFound(habit);
using var cancellation = new CancellationTokenSource();
_habitLogReader.ReadRangeLogsAsync(HabitId, from, to, cancellation.Token).Returns([log]);

var result = await _handler.Handle(new GetHabitLogsQuery(UserId, HabitId, from, to), cancellation.Token);

result.Value.Should().ContainSingle().Which.Should().BeEquivalentTo(
new HabitLogResponse(log.Id, log.Date, log.Value, log.CreatedAtUtc));
await _habitLogReader.Received(1).ReadRangeLogsAsync(HabitId, from, to, cancellation.Token);
await _habitLogReader.DidNotReceive().ReadRecentLogsAsync(
Arg.Any<Guid>(), Arg.Any<DateOnly>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
await _userDateService.DidNotReceive().GetUserTodayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_RangeForUnownedHabit_DoesNotReadLogs()
{
var owner = CreateTestHabit();
_habitRepo.FindOneTrackedAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<Func<IQueryable<Habit>, IQueryable<Habit>>?>(), Arg.Any<CancellationToken>())
.Returns(call => call.Arg<Expression<Func<Habit, bool>>>().Compile()(owner) ? owner : null);

var result = await _handler.Handle(new GetHabitLogsQuery(
Guid.NewGuid(), owner.Id, new DateOnly(2025, 1, 1), new DateOnly(2025, 1, 31)), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.ErrorCode.Should().Be("HABIT_NOT_FOUND");
await _habitLogReader.DidNotReceive().ReadRangeLogsAsync(
Arg.Any<Guid>(), Arg.Any<DateOnly>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using FluentAssertions;
using FluentValidation.TestHelper;
using Orbit.Application.Common;
using Orbit.Application.Habits.Queries;
using Orbit.Application.Habits.Validators;

namespace Orbit.Application.Tests.Validators;

public class GetHabitLogsQueryValidatorTests
{
private readonly GetHabitLogsQueryValidator _validator = new();

[Theory]
[InlineData(null, null)]
[InlineData("2025-01-01", "2025-01-01")]
[InlineData("2024-01-01", "2024-12-31")]
[InlineData("0001-01-01", "0001-01-01")]
[InlineData("9999-12-31", "9999-12-31")]
public void ValidRange_HasNoErrors(string? from, string? to)
{
_validator.TestValidate(Query(from, to)).ShouldNotHaveAnyValidationErrors();
}

[Theory]
[InlineData("2025-01-31", "2025-01-01", nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateRangeOrder)]
[InlineData("2024-01-01", "2025-01-01", nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateRangeLimit)]
[InlineData("2025-01-01", null, nameof(GetHabitLogsQuery.To), ValidationErrorCodes.DateToRequired)]
[InlineData(null, "2025-01-01", nameof(GetHabitLogsQuery.From), ValidationErrorCodes.DateFromRequired)]
public void InvalidRange_HasFieldError(string? from, string? to, string field, string code)
{
var result = _validator.TestValidate(Query(from, to));

result.Errors.Should().ContainSingle().Which.Should().Match<FluentValidation.Results.ValidationFailure>(
error => error.PropertyName == field && error.ErrorCode == code);
}

[Fact]
public void EmptyIds_HaveErrors()
{
var result = _validator.TestValidate(new GetHabitLogsQuery(Guid.Empty, Guid.Empty));

result.ShouldHaveValidationErrorFor(query => query.UserId);
result.ShouldHaveValidationErrorFor(query => query.HabitId);
}

private static GetHabitLogsQuery Query(string? from, string? to) => new(
Guid.NewGuid(), Guid.NewGuid(),
from is null ? null : DateOnly.ParseExact(from, "yyyy-MM-dd"),
to is null ? null : DateOnly.ParseExact(to, "yyyy-MM-dd"));
}
Loading
Loading