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
29 changes: 29 additions & 0 deletions src/Orbit.Api/Controllers/CalendarController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ public async Task<IActionResult> GetEvents(CancellationToken cancellationToken)
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpGet("calendars")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetCalendars(CancellationToken cancellationToken)
{
var query = new GetUserCalendarsQuery(HttpContext.GetUserId());
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

public record SetSelectedCalendarsRequest(IReadOnlyList<string>? CalendarIds);

[HttpPut("selected-calendars")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetSelectedCalendars(
[FromBody] SetSelectedCalendarsRequest request,
CancellationToken cancellationToken)
{
if (request.CalendarIds is null)
return BadRequest(ErrorMessages.CalendarIdsRequired.ToErrorBody());

var command = new SetSelectedCalendarsCommand(HttpContext.GetUserId(), request.CalendarIds);
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPut("dismiss")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
using Orbit.Infrastructure.Configuration;
using Orbit.Infrastructure.Persistence;
using Orbit.Infrastructure.Services;
using Orbit.Infrastructure.Services.Calendar;
using Scalar.AspNetCore;

namespace Orbit.Api.Extensions;
Expand Down Expand Up @@ -90,7 +91,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Notification>>()));
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IGoogleTokenService, GoogleTokenService>();
builder.Services.AddScoped<Orbit.Application.Calendar.Services.ICalendarEventFetcher, Orbit.Infrastructure.Services.GoogleCalendarEventFetcher>();
builder.Services.AddGoogleCalendarServices();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ITokenService, JwtTokenService>();
builder.Services.AddScoped<IAuthSessionService, AuthSessionService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ private async Task<Result<CalendarAutoSyncResult>> FetchAndProcess(
List<CalendarEventItem> fetched;
try
{
fetched = await deps.EventFetcher.FetchAsync(accessToken, updatedMin: null, ct);
fetched = await deps.EventFetcher.FetchAsync(
accessToken, user.GetSelectedCalendarIds(), updatedMin: null, ct);
fetched = NormalizeFetchedEvents(user.Id, fetched);
}
catch (CalendarProviderException ex) when (ex.Kind == CalendarFetchErrorKind.ReconnectRequired)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using MediatR;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Calendar.Commands;

public record SetSelectedCalendarsCommand(Guid UserId, IReadOnlyList<string> CalendarIds)
: IRequest<Result>, IConcurrencyRetryable;

public class SetSelectedCalendarsCommandHandler(
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork) : IRequestHandler<SetSelectedCalendarsCommand, Result>
{
public async Task<Result> Handle(SetSelectedCalendarsCommand request, CancellationToken cancellationToken)
{
var user = await userRepository.FindOneTrackedAsync(
u => u.Id == request.UserId,
cancellationToken: cancellationToken);

if (user is null)
return Result.Failure(ErrorMessages.UserNotFound);

user.SetSelectedCalendars(request.CalendarIds);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
bool IsRecurring,
string? RecurrenceRule,
List<int> Reminders,
DateTime? StartUtc = null);
DateTime? StartUtc = null,
string CalendarId = "",
string CalendarName = "");

public record GetCalendarEventsQuery(Guid UserId) : IRequest<Result<List<CalendarEventItem>>>, IConcurrencyRetryable;

public partial class GetCalendarEventsQueryHandler(

Check warning on line 28 in src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<GoogleCalendarSyncSuggestion> suggestionRepository,
Expand All @@ -33,7 +35,7 @@
IUnitOfWork unitOfWork,
ILogger<GetCalendarEventsQueryHandler> logger) : IRequestHandler<GetCalendarEventsQuery, Result<List<CalendarEventItem>>>
{
private const string GoogleCalendarReconnectMessage = "Google Calendar connection expired. Please reconnect.";

Check warning on line 38 in src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Remove the unused private field 'GoogleCalendarReconnectMessage'.

public async Task<Result<List<CalendarEventItem>>> Handle(GetCalendarEventsQuery request, CancellationToken cancellationToken)
{
Expand All @@ -51,7 +53,8 @@

try
{
var fetched = await eventFetcher.FetchAsync(accessToken, updatedMin: null, cancellationToken);
var fetched = await eventFetcher.FetchAsync(
accessToken, user.GetSelectedCalendarIds(), updatedMin: null, cancellationToken);

var importedEventIds = await BuildImportedEventIdSet(request.UserId, cancellationToken);
var items = fetched
Expand Down
85 changes: 85 additions & 0 deletions src/Orbit.Application/Calendar/Queries/GetUserCalendarsQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Orbit.Application.Behaviors;
using Orbit.Application.Calendar.Services;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Calendar.Queries;

public record UserCalendarItem(
string Id,
string Name,
string AccessRole,
bool Primary,
string? BackgroundColor,
bool IsSynced);

public record GetUserCalendarsQuery(Guid UserId) : IRequest<Result<List<UserCalendarItem>>>, IConcurrencyRetryable;

public partial class GetUserCalendarsQueryHandler(
IGenericRepository<User> userRepository,
IPayGateService payGate,
IGoogleTokenService googleTokenService,
ICalendarEventFetcher eventFetcher,
IUnitOfWork unitOfWork,
ILogger<GetUserCalendarsQueryHandler> logger) : IRequestHandler<GetUserCalendarsQuery, Result<List<UserCalendarItem>>>
{
public async Task<Result<List<UserCalendarItem>>> Handle(GetUserCalendarsQuery request, CancellationToken cancellationToken)
{
var gateCheck = await payGate.CanAccessCalendar(request.UserId, cancellationToken);
if (gateCheck.IsFailure)
return gateCheck.PropagateError<List<UserCalendarItem>>();

var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
return Result.Failure<List<UserCalendarItem>>(ErrorMessages.UserNotFound);

var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken);
if (accessToken is null)
return Result.Failure<List<UserCalendarItem>>(ErrorMessages.CalendarNotConnected);

await unitOfWork.SaveChangesAsync(cancellationToken);

try
{
var calendars = await eventFetcher.ListCalendarsAsync(accessToken, cancellationToken);
var selectedIds = user.GetSelectedCalendarIds();
return Result.Success(MapCalendars(calendars, selectedIds));
}
catch (CalendarProviderException ex)
{
LogGoogleCalendarApiError(logger, ex, request.UserId);
if (ex.Kind == CalendarFetchErrorKind.ReconnectRequired)
{
user.MarkCalendarSyncReconnectRequired(ex.RawErrorCode ?? "reconnect_required");
await unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Failure<List<UserCalendarItem>>(ErrorMessages.CalendarReconnectRequired);
}
return Result.Failure<List<UserCalendarItem>>(ErrorMessages.CalendarFetchFailed);
}
}

private static List<UserCalendarItem> MapCalendars(
IReadOnlyList<CalendarListItem> calendars, IReadOnlyList<string>? selectedIds)
{
var selected = selectedIds is null
? null
: new HashSet<string>(selectedIds, StringComparer.Ordinal);

return calendars
.Select(c => new UserCalendarItem(
c.Id,
c.Name,
c.AccessRole,
c.Primary,
c.BackgroundColor,
selected is null ? c.IsDefaultOwned : selected.Contains(c.Id)))
.ToList();
}

[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Google Calendar list error for user {UserId}")]
private static partial void LogGoogleCalendarApiError(ILogger logger, Exception ex, Guid userId);
}
40 changes: 34 additions & 6 deletions src/Orbit.Application/Calendar/Services/ICalendarEventFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,50 @@ namespace Orbit.Application.Calendar.Services;
public interface ICalendarEventFetcher
{
/// <summary>
/// Fetches Google Calendar events from the user's primary calendar for the next 60
/// days and maps them into <see cref="CalendarEventItem"/> instances. The concrete
/// implementation lives in Infrastructure and owns Google SDK construction, so
/// Application only passes an OAuth access token.
/// Throws <see cref="CalendarProviderException"/> on provider errors; the Kind field
/// tells callers whether to force a reconnect or retry later.
/// Fetches Google Calendar events for the next 60 days across the user's calendars and maps
/// them into <see cref="CalendarEventItem"/> instances tagged with their source calendar.
/// When <paramref name="selectedCalendarIds"/> is null the user's owned, non-deleted,
/// non-hidden calendars are used; when non-null only calendars whose id is in that set are
/// fetched (still skipping deleted/hidden/inaccessible ones). A single calendar that fails
/// (e.g. transient or permission error) is logged and skipped rather than failing the whole
/// fetch. The concrete implementation lives in Infrastructure and owns Google SDK construction,
/// so Application only passes an OAuth access token.
/// Throws <see cref="CalendarProviderException"/> when the initial calendar-list call fails;
/// the Kind field tells callers whether to force a reconnect or retry later.
/// </summary>
/// <param name="accessToken">Google OAuth 2.0 access token for the calling user.</param>
/// <param name="selectedCalendarIds">Explicit calendar-id allow-list, or null to use all owned calendars.</param>
/// <param name="updatedMin">If provided, Google returns only events created/modified after this UTC timestamp.</param>
/// <param name="ct">Cancellation token.</param>
Task<List<CalendarEventItem>> FetchAsync(
string accessToken,
IReadOnlyCollection<string>? selectedCalendarIds,
DateTime? updatedMin,
CancellationToken ct);

/// <summary>
/// Lists every non-deleted, non-hidden calendar on the user's calendar list as
/// <see cref="CalendarListItem"/> instances for a settings picker. Throws
/// <see cref="CalendarProviderException"/> on provider errors.
/// </summary>
/// <param name="accessToken">Google OAuth 2.0 access token for the calling user.</param>
/// <param name="ct">Cancellation token.</param>
Task<List<CalendarListItem>> ListCalendarsAsync(string accessToken, CancellationToken ct);
}

/// <summary>
/// A single calendar from the user's Google calendar list, surfaced to the settings picker.
/// <see cref="IsDefaultOwned"/> reflects the owner/!deleted/!hidden rule used to build the
/// default sync set when the user has no explicit selection.
/// </summary>
public record CalendarListItem(
string Id,
string Name,
string AccessRole,
bool Primary,
string? BackgroundColor,
bool IsDefaultOwned);

/// <summary>
/// Classification of calendar-provider failures used by Application to decide whether
/// to force the user to reconnect vs. mark a transient error for retry.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using FluentValidation;
using Orbit.Application.Calendar.Commands;
using Orbit.Application.Common;

namespace Orbit.Application.Calendar.Validators;

public class SetSelectedCalendarsCommandValidator : AbstractValidator<SetSelectedCalendarsCommand>
{
public SetSelectedCalendarsCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();

RuleFor(x => x.CalendarIds)
.NotNull()
.Must(ids => ids.Count <= AppConstants.MaxSelectedCalendars)
.WithMessage($"You can select at most {AppConstants.MaxSelectedCalendars} calendars.");

RuleForEach(x => x.CalendarIds)
.NotEmpty()
.WithMessage("Calendar ids must be non-empty.");
}
}
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public static class AppConstants
public const long MaxAudioBytes = 26_214_400;
public const int MaxAiToolResultTextLength = 12_000;
public const int MaxCalendarRangeDays = 62;
public const int MaxSelectedCalendars = 50;
public const int MaxPageSize = 200;
public const int AdRewardBonusMessages = 5;
public const int AdRewardDailyCap = 3;
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public static class ErrorCodes
public const string CalendarReconnectRequired = "CALENDAR_RECONNECT_REQUIRED";
public const string CalendarFetchFailed = "CALENDAR_FETCH_FAILED";
public const string AutoSyncEnabledRequired = "AUTO_SYNC_ENABLED_REQUIRED";
public const string CalendarIdsRequired = "CALENDAR_IDS_REQUIRED";
public const string NoHabitsForPeriod = "NO_HABITS_FOR_PERIOD";
public const string AiSummaryDisabled = "AI_SUMMARY_DISABLED";
public const string NoActiveGoals = "NO_ACTIVE_GOALS";
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public static class ErrorMessages
public static readonly AppError CalendarReconnectRequired = new(ErrorCodes.CalendarReconnectRequired, "Google Calendar connection expired. Please reconnect.");
public static readonly AppError CalendarFetchFailed = new(ErrorCodes.CalendarFetchFailed, "Failed to fetch calendar events. Please try again.");
public static readonly AppError AutoSyncEnabledRequired = new(ErrorCodes.AutoSyncEnabledRequired, "Enabled is required.");
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 AiSummaryDisabled = new(ErrorCodes.AiSummaryDisabled, "AI summary is disabled.");
public static readonly AppError NoActiveGoals = new(ErrorCodes.NoActiveGoals, "No active goals found.");
Expand Down
48 changes: 48 additions & 0 deletions src/Orbit.Application/Common/TimeFormatResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace Orbit.Application.Common;

/// <summary>
/// Resolves whether a user's region uses a 24-hour clock, derived from their IANA
/// time zone. The 12-hour-clock zone set is sourced from the CLDR regions that default
/// to a 12-hour clock (the Americas' English/Spanish locales, Australia, New Zealand,
/// South and Southeast Asia, and a few others); every other zone, and a null or unknown
/// value, resolves to 24-hour, which is the global majority.
/// </summary>
public static class TimeFormatResolver
{
private static readonly HashSet<string> TwelveHourTimeZones = new(StringComparer.Ordinal)
{
"America/New_York", "America/Detroit", "America/Kentucky/Louisville", "America/Kentucky/Monticello",
"America/Indiana/Indianapolis", "America/Indiana/Vincennes", "America/Indiana/Winamac",
"America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Vevay",
"America/Chicago", "America/Indiana/Tell_City", "America/Indiana/Knox", "America/Menominee",
"America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/North_Dakota/Beulah",
"America/Denver", "America/Boise", "America/Phoenix", "America/Los_Angeles", "America/Anchorage",
"America/Juneau", "America/Sitka", "America/Metlakatla", "America/Yakutat", "America/Nome",
"America/Adak", "Pacific/Honolulu",
"America/St_Johns", "America/Halifax", "America/Glace_Bay", "America/Moncton", "America/Goose_Bay",
"America/Toronto", "America/Iqaluit", "America/Winnipeg", "America/Resolute", "America/Rankin_Inlet",
"America/Regina", "America/Swift_Current", "America/Edmonton", "America/Cambridge_Bay",
"America/Inuvik", "America/Creston", "America/Dawson_Creek", "America/Fort_Nelson",
"America/Vancouver", "America/Whitehorse", "America/Dawson",
"Australia/Sydney", "Australia/Melbourne", "Australia/Brisbane", "Australia/Perth",
"Australia/Adelaide", "Australia/Hobart", "Australia/Darwin", "Australia/Lord_Howe",
"Australia/Lindeman", "Australia/Broken_Hill", "Australia/Eucla",
"Pacific/Auckland", "Pacific/Chatham",
"Asia/Kolkata", "Asia/Karachi", "Asia/Dhaka", "Asia/Colombo", "Asia/Kathmandu",
"Asia/Manila", "Asia/Kuala_Lumpur", "Asia/Kuching",
"Africa/Cairo", "Asia/Riyadh", "Asia/Amman",
"America/Mexico_City", "America/Cancun", "America/Merida", "America/Monterrey", "America/Matamoros",
"America/Mazatlan", "America/Chihuahua", "America/Ojinaga", "America/Hermosillo", "America/Tijuana",
"America/Bahia_Banderas",
"America/Bogota", "America/El_Salvador", "America/Tegucigalpa", "America/Managua",
"America/Guatemala", "America/Costa_Rica", "America/Panama", "America/Santo_Domingo",
"America/Puerto_Rico"
};

/// <summary>
/// Returns true when the region for <paramref name="ianaTimeZone"/> uses a 24-hour
/// clock. A null or unrecognized zone resolves to 24-hour.
/// </summary>
public static bool Uses24HourClock(string? ianaTimeZone) =>
ianaTimeZone is null || !TwelveHourTimeZones.Contains(ianaTimeZone);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public record RetrospectiveHabitStat(
string? Emoji,
int CompletionRate,
int CompletedCount,
int ScheduledCount);
int ScheduledCount,
bool IsOneTime = false);

public record RetrospectiveMetrics(
int CompletionRate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public static RetrospectiveMetrics Compute(
stats.Add(BuildHabitStat(habit, scheduledDates.Count, completedCount));
}

var completionRate = Percent(totalCompletions, totalScheduled);
var completionRate = Math.Min(100, Percent(totalCompletions, totalScheduled));
var activeDays = CountActiveDays(habits, dateFrom, dateTo);
var periodDays = dateTo.DayNumber - dateFrom.DayNumber + 1;
var weeklyConsistency = BuildWeeklyConsistency(weekdayScheduled, weekdayCompleted);
Expand Down Expand Up @@ -114,15 +114,16 @@ private static RetrospectiveHabitStat BuildHabitStat(Habit habit, int scheduledC
new(
habit.Title,
habit.Emoji,
Percent(completedCount, scheduledCount),
Math.Min(100, Percent(completedCount, scheduledCount)),
completedCount,
scheduledCount);
scheduledCount,
habit.FrequencyUnit is null);

private static IReadOnlyList<int> BuildWeeklyConsistency(int[] weekdayScheduled, int[] weekdayCompleted)
{
var consistency = new int[7];
for (var i = 0; i < 7; i++)
consistency[i] = Percent(weekdayCompleted[i], weekdayScheduled[i]);
consistency[i] = Math.Min(100, Percent(weekdayCompleted[i], weekdayScheduled[i]));
return consistency;
}

Expand Down
Loading
Loading