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
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

56 changes: 50 additions & 6 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2691,7 +2691,7 @@
"Chat": 80,
"ChecklistTemplates": 5,
"Common": 35,
"Gamification": 28,
"Gamification": 31,
"Goals": 33,
"Habits": 74,
"Marketing": 4,
Expand Down Expand Up @@ -2772,6 +2772,10 @@
"entity": "Cheer",
"file": "src/Orbit.Domain/Entities/Cheer.cs"
},
{
"entity": "ClosedMonthRecap",
"file": "src/Orbit.Domain/Entities/ClosedMonthRecap.cs"
},
{
"entity": "ContentBlock",
"file": "src/Orbit.Domain/Entities/ContentBlock.cs"
Expand Down Expand Up @@ -2895,14 +2899,14 @@
"Challenges": 14,
"Chat": 61,
"ChecklistTemplates": 9,
"Common": 169,
"Gamification": 36,
"Common": 171,
"Gamification": 38,
"Goals": 38,
"Habits": 83,
"Marketing": 6,
"Notifications": 16,
"Profile": 38,
"Referrals": 12,
"Profile": 39,
"Referrals": 13,
"Social": 32,
"Subscriptions": 18,
"Support": 4,
Expand Down Expand Up @@ -4961,6 +4965,26 @@
"GetChecklistTemplatesQueryHandler"
]
},
{
"testClass": "ClosedMonthPeriodRangeTests",
"file": "tests/Orbit.Application.Tests/Queries/Gamification/ClosedMonthPeriodRangeTests.cs",
"references": []
},
{
"testClass": "ClosedMonthRecapResetRaceTests",
"file": "tests/Orbit.Application.Tests/Queries/Gamification/ClosedMonthRecapResetRaceTests.cs",
"references": [
"ClosedMonthRecap",
"GetOrCreateReferralCodeCommand",
"GetRecapQuery",
"GetRecapQueryHandler",
"Goal",
"Habit",
"ResetAccountCommand",
"ResetAccountCommandHandler",
"User"
]
},
{
"testClass": "GetAchievementsQueryHandlerTests",
"file": "tests/Orbit.Application.Tests/Queries/Gamification/GetAchievementsQueryHandlerTests.cs",
Expand All @@ -4985,10 +5009,13 @@
"testClass": "GetRecapQueryHandlerTests",
"file": "tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryHandlerTests.cs",
"references": [
"ClosedMonthRecap",
"GetOrCreateReferralCodeCommand",
"GetRecapQuery",
"GetRecapQueryHandler",
"Habit"
"Goal",
"Habit",
"User"
]
},
{
Expand Down Expand Up @@ -6359,6 +6386,13 @@
"User"
]
},
{
"testClass": "ClosedMonthRecapTests",
"file": "tests/Orbit.Domain.Tests/Entities/ClosedMonthRecapTests.cs",
"references": [
"ClosedMonthRecap"
]
},
{
"testClass": "ContentBlockTests",
"file": "tests/Orbit.Domain.Tests/Entities/ContentBlockTests.cs",
Expand Down Expand Up @@ -7110,6 +7144,7 @@
"BlockedUser",
"ChecklistTemplate",
"Cheer",
"ClosedMonthRecap",
"FriendFeedEvent",
"Friendship",
"Goal",
Expand Down Expand Up @@ -7140,6 +7175,14 @@
"User"
]
},
{
"testClass": "ClosedMonthRecapStoreTests",
"file": "tests/Orbit.Infrastructure.Tests/Persistence/ClosedMonthRecapStoreTests.cs",
"references": [
"ClosedMonthRecap",
"User"
]
},
{
"testClass": "ConcurrencyRetryTests",
"file": "tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs",
Expand Down Expand Up @@ -7215,6 +7258,7 @@
"ChallengeParticipantHabit",
"ChecklistTemplate",
"Cheer",
"ClosedMonthRecap",
"ContentBlock",
"DistributedRateLimitBucket",
"FriendFeedEvent",
Expand Down
30 changes: 25 additions & 5 deletions src/Orbit.Api/Controllers/GamificationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,37 @@ public async Task<IActionResult> RepairStreak(
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetRecap(
[FromQuery] string period,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
[FromQuery] int? year = null,
[FromQuery] int? month = null)
{
if (!RetrospectivePeriodRange.IsKnownPeriod(period))
return BadRequest(ErrorMessages.InvalidPeriod.ToErrorBody());

var userId = HttpContext.GetUserId();
var today = await userDateService.GetUserTodayAsync(userId, cancellationToken);
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken);
var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay);

var query = new GetRecapQuery(userId, dateFrom, dateTo, period);
DateOnly dateFrom;
DateOnly dateTo;

if (year.HasValue || month.HasValue)
{
if (!year.HasValue || !month.HasValue || !period.Equals("month", StringComparison.OrdinalIgnoreCase))
return BadRequest(ErrorMessages.InvalidClosedMonthParameters.ToErrorBody());

var closedMonth = ClosedMonthPeriodRange.Resolve(year.Value, month.Value, today);
if (closedMonth.IsFailure)
return closedMonth.ToErrorResult();

dateFrom = closedMonth.Value.DateFrom;
dateTo = closedMonth.Value.DateTo;
}
else
{
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken);
(dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay);
}

var query = new GetRecapQuery(userId, dateFrom, dateTo, period, year, month);
var result = await mediator.Send(query, cancellationToken);

return result.ToPayGateAwareResult(v => Ok(v));
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Orbit.Api.Observability;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Application.Gamification;
using Orbit.Application.Gamification.Services;
using Orbit.Application.Goals.Services;
using Orbit.Application.Habits.Validators;
Expand Down Expand Up @@ -76,6 +77,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IAccountResetRepository, AccountResetRepository>();
builder.Services.AddScoped<IIdempotencyStore, IdempotencyStore>();
builder.Services.AddScoped<IClosedMonthRecapStore, ClosedMonthRecapStore>();
builder.Services.AddScoped<IAppConfigService, AppConfigService>();
builder.Services.AddScoped<IUserDateService, UserDateService>();
builder.Services.AddScoped<IUserStreakService, UserStreakService>();
Expand Down
24 changes: 24 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4342,6 +4342,30 @@
"schema": {
"type": "string"
}
},
{
"name": "year",
"in": "query",
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
}
},
{
"name": "month",
"in": "query",
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
}
}
],
"responses": {
Expand Down
3 changes: 3 additions & 0 deletions src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ public static class ErrorCodes
public const string CalendarIdsRequired = "CALENDAR_IDS_REQUIRED";
public const string NoHabitsForPeriod = "NO_HABITS_FOR_PERIOD";
public const string InvalidPeriod = "INVALID_PERIOD";
public const string InvalidClosedMonthParameters = "INVALID_CLOSED_MONTH_PARAMETERS";
public const string RecapMonthNotClosed = "RECAP_MONTH_NOT_CLOSED";
public const string RecapMonthBeforeAccount = "RECAP_MONTH_BEFORE_ACCOUNT";
public const string AiSummaryDisabled = "AI_SUMMARY_DISABLED";
public const string NoActiveGoals = "NO_ACTIVE_GOALS";
public const string NoGoalsData = "NO_GOALS_DATA";
Expand Down
3 changes: 3 additions & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ public static class ErrorMessages
public static readonly AppError CalendarIdsRequired = new(ErrorCodes.CalendarIdsRequired, "Calendar ids are required.");
public static readonly AppError NoHabitsForPeriod = new(ErrorCodes.NoHabitsForPeriod, "No habits found for this period.");
public static readonly AppError InvalidPeriod = new(ErrorCodes.InvalidPeriod, "Period must be one of: week, month, quarter, semester, year.");
public static readonly AppError InvalidClosedMonthParameters = new(ErrorCodes.InvalidClosedMonthParameters, "Year and month must be valid, provided together, and used only with the month period.");
public static readonly AppError RecapMonthNotClosed = new(ErrorCodes.RecapMonthNotClosed, "The requested recap month has not closed yet.");
public static readonly AppError RecapMonthBeforeAccount = new(ErrorCodes.RecapMonthBeforeAccount, "The requested recap month is before this account existed.");
public static readonly AppError AiSummaryDisabled = new(ErrorCodes.AiSummaryDisabled, "AI summary is disabled.");
public static readonly AppError NoActiveGoals = new(ErrorCodes.NoActiveGoals, "No active goals found.");
public static readonly AppError NoGoalsData = new(ErrorCodes.NoGoalsData, "No goals data provided.");
Expand Down
10 changes: 10 additions & 0 deletions src/Orbit.Application/Gamification/ClosedMonthRecapLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Gamification;

/// <summary>
/// Provides the account-scoped transaction lock shared by first-time closed month recap creation and
/// account reset, so reset cannot race a snapshot built from data it erases.
/// </summary>
public static class ClosedMonthRecapLock
{
public static string ForUser(Guid userId) => $"closed-month-recap:{userId}";
}
18 changes: 18 additions & 0 deletions src/Orbit.Application/Gamification/IClosedMonthRecapStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Orbit.Domain.Entities;

namespace Orbit.Application.Gamification;

/// <summary>
/// Persists the first response computed for an account and resolved closed month so later requests
/// return the same result even when mutable habit cadence data has changed.
/// </summary>
public interface IClosedMonthRecapStore
{
Task<string?> FindResponseJsonAsync(
Guid userId,
DateOnly dateFrom,
DateOnly dateTo,
CancellationToken cancellationToken);

Task AddAsync(ClosedMonthRecap recap, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Orbit.Application.Common;
using Orbit.Domain.Common;

namespace Orbit.Application.Gamification.Queries;

public readonly record struct ClosedMonthPeriod(DateOnly DateFrom, DateOnly DateTo);

/// <summary>Resolves a completed calendar month from the user's local calendar.</summary>
public static class ClosedMonthPeriodRange
{
public static Result<ClosedMonthPeriod> Resolve(int year, int month, DateOnly userToday)
{
if (year is < 1 or > 9999 || month is < 1 or > 12)
return Result.Failure<ClosedMonthPeriod>(ErrorMessages.InvalidClosedMonthParameters);

var dateFrom = new DateOnly(year, month, 1);
var dateTo = new DateOnly(year, month, DateTime.DaysInMonth(year, month));

if (dateTo >= userToday)
return Result.Failure<ClosedMonthPeriod>(ErrorMessages.RecapMonthNotClosed);

return Result.Success(new ClosedMonthPeriod(dateFrom, dateTo));
}
}
Loading
Loading