Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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.

38 changes: 35 additions & 3 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2741,7 +2741,7 @@
"ApiKeys": 11,
"Auth": 22,
"Behaviors": 4,
"Calendar": 15,
"Calendar": 16,
"Challenges": 15,
"Chat": 84,
"ChecklistTemplates": 5,
Expand Down Expand Up @@ -2950,7 +2950,7 @@
"ApiKeys": 14,
"Auth": 26,
"Behaviors": 14,
"Calendar": 16,
"Calendar": 19,
"Challenges": 14,
"Chat": 64,
"ChecklistTemplates": 9,
Expand All @@ -2959,7 +2959,7 @@
"Goals": 54,
"Habits": 93,
"Marketing": 6,
"Notifications": 20,
"Notifications": 21,
"Profile": 40,
"Referrals": 13,
"Social": 36,
Expand Down Expand Up @@ -5038,6 +5038,27 @@
"GetApiKeysQueryHandler"
]
},
{
"testClass": "CalendarEventItemSerializationTests",
"file": "tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs",
"references": []
},
{
"testClass": "CalendarFeedAgreementTests",
"file": "tests/Orbit.Application.Tests/Queries/Calendar/CalendarFeedAgreementTests.cs",
"references": [
"GetCalendarEventsQuery",
"GetCalendarEventsQueryHandler",
"GetCalendarSyncSuggestionsQuery",
"GetCalendarSyncSuggestionsQueryHandler",
"GoogleCalendarSyncSuggestion",
"Habit",
"Notification",
"RunCalendarAutoSyncCommand",
"RunCalendarAutoSyncCommandHandler",
"User"
]
},
{
"testClass": "GetCalendarAutoSyncStateQueryHandlerTests",
"file": "tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarAutoSyncStateQueryHandlerTests.cs",
Expand Down Expand Up @@ -7764,6 +7785,17 @@
"UserSession"
]
},
{
"testClass": "CalendarProjectionEndToEndTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs",
"references": [
"GetCalendarEventsQuery",
"GetCalendarEventsQueryHandler",
"GoogleCalendarSyncSuggestion",
"Habit",
"User"
]
},
{
"testClass": "DataEncryptionMigrationServiceTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/DataEncryptionMigrationServiceTests.cs",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Globalization;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -121,7 +121,6 @@ private async Task<Result<CalendarAutoSyncResult>> FetchAndProcessLocked(
{
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 All @@ -138,10 +137,12 @@ private async Task<Result<CalendarAutoSyncResult>> FetchAndProcessLocked(
return Result.Success(new CalendarAutoSyncResult(0, 0, GoogleCalendarAutoSyncStatus.TransientError));
}

var reconciled = await ReconcileExistingHabits(user, fetched, utcNow, ct);
var newSuggestions = await CreateSuggestions(user, fetched, utcNow, ct);
var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id);
var normalizedFetched = NormalizeFetchedEvents(user.Id, fetched);
var reconciled = await ReconcileExistingHabits(user, normalizedFetched, timeZone, utcNow, ct);
var newSuggestions = await CreateSuggestions(user, normalizedFetched, fetched, timeZone, utcNow, ct);

if (newSuggestions > 0 && IsInQuietHours(user, utcNow)
if (newSuggestions > 0 && IsInQuietHours(timeZone, utcNow)
&& !await HasRecentSuggestionNotification(user.Id, utcNow, ct))
{
await CreateSuggestionNotification(user, newSuggestions, ct);
Expand All @@ -161,8 +162,15 @@ private async Task<Result<CalendarAutoSyncResult>> FetchAndProcessLocked(
return Result.Success(new CalendarAutoSyncResult(newSuggestions, reconciled, GoogleCalendarAutoSyncStatus.Idle));
}

/// <summary>
/// Backfills <c>GoogleEventId</c> on a habit imported before that column existed, by matching
/// title plus day plus time. A habit holds account-local values, so the fetched event is projected
/// into the account timezone first. <c>GetCalendarSyncSuggestionsQuery</c> builds the same key from
/// the same projection, so a suggestion the query hides as already imported is the suggestion this
/// pass links.
/// </summary>
private async Task<int> ReconcileExistingHabits(
User user, List<CalendarEventItem> fetched, DateTime utcNow, CancellationToken ct)
User user, List<CalendarEventItem> fetched, TimeZoneInfo timeZone, DateTime utcNow, CancellationToken ct)
{
var assignedEventIds = (await deps.HabitRepository.FindAsync(
h => h.UserId == user.Id && h.GoogleEventId != null, ct))
Expand All @@ -172,6 +180,7 @@ private async Task<int> ReconcileExistingHabits(

var eventsByKey = fetched
.Where(ev => !assignedEventIds.Contains(ev.Id))
.Select(ev => ev.ProjectTo(timeZone))
.GroupBy(ev => BuildLegacyMatchKey(ev.Title, ev.StartDate, ev.StartTime), StringComparer.Ordinal)
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single().Id, StringComparer.Ordinal);
Expand All @@ -188,8 +197,8 @@ private async Task<int> ReconcileExistingHabits(
var habitsByKey = existingHabits
.GroupBy(habit => BuildLegacyMatchKey(
habit.Title,
habit.DueDate.ToString("yyyy-MM-dd"),
habit.DueTime?.ToString("HH:mm")), StringComparer.Ordinal)
habit.DueDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
habit.DueTime?.ToString("HH:mm", CultureInfo.InvariantCulture)), StringComparer.Ordinal)
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single(), StringComparer.Ordinal);

Expand All @@ -214,7 +223,8 @@ private async Task<int> ReconcileExistingHabits(
}

private async Task<int> CreateSuggestions(
User user, List<CalendarEventItem> fetched, DateTime utcNow, CancellationToken ct)
User user, List<CalendarEventItem> fetched, List<CalendarEventItem> fetchedIncludingDuplicates,
TimeZoneInfo timeZone, DateTime utcNow, CancellationToken ct)
{
if (fetched.Count == 0) return 0;

Expand All @@ -223,22 +233,46 @@ private async Task<int> CreateSuggestions(
.Select(h => h.GoogleEventId!)
.ToHashSet(StringComparer.Ordinal);

// Reserve ids from ALL suggestions (incl. imported/dismissed): UserId+GoogleEventId is a full unique index, so re-inserting a known event throws 23505 — https://thomasluizon.sentry.io/issues/ORBIT-API-E
var existingSuggestionEventIds = (await deps.SuggestionRepository.FindAsync(
s => s.UserId == user.Id, ct))
// Reserve ids from ALL suggestions (incl. imported/dismissed): UserId+GoogleEventId is a full unique index, so re-inserting a known event throws 23505. https://thomasluizon.sentry.io/issues/ORBIT-API-E
var existingSuggestions = await deps.SuggestionRepository.FindAsync(s => s.UserId == user.Id, ct);
var existingSuggestionEventIds = existingSuggestions
.Select(s => s.GoogleEventId)
.ToHashSet(StringComparer.Ordinal);
var reservedEventIds = new HashSet<string>(habitEventIds, StringComparer.Ordinal);
reservedEventIds.UnionWith(existingSuggestionEventIds);

var fetchedById = fetchedIncludingDuplicates
.GroupBy(ev => ev.Id, StringComparer.Ordinal)
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single(), StringComparer.Ordinal);
var legacyPendingIds = existingSuggestions
.Where(s => s.DismissedAtUtc is null && s.ImportedAtUtc is null
&& StoredCalendarEventJson.NeedsRecurrenceEvidenceRefresh(s.RawEventJson)
&& fetchedById.TryGetValue(s.GoogleEventId, out var ev)
&& !ev.NeedsRecurrenceEvidenceRefresh)
.Select(s => s.GoogleEventId)
.ToHashSet(StringComparer.Ordinal);
if (legacyPendingIds.Count > 0)
{
var pendingTracked = await deps.SuggestionRepository.FindTrackedAsync(
s => s.UserId == user.Id && s.DismissedAtUtc == null && s.ImportedAtUtc == null, ct);
foreach (var suggestion in pendingTracked.Where(s => legacyPendingIds.Contains(s.GoogleEventId)))
{
var refreshed = fetchedById[suggestion.GoogleEventId];
suggestion.RefreshPendingEvent(
refreshed.Title, ResolveStartDateUtc(refreshed), StoredCalendarEventJson.Serialize(refreshed));
}
}

int created = 0;
foreach (var ev in fetched)
{
if (created >= MaxSuggestionsPerTick) break;
if (ev.HasUnrepresentableRecurrenceAfterProjection(timeZone)) continue;
if (!reservedEventIds.Add(ev.Id)) continue;

var startDateUtc = ParseStartDateUtc(ev);
var rawJson = JsonSerializer.Serialize(ev);
var startDateUtc = ResolveStartDateUtc(ev);
var rawJson = StoredCalendarEventJson.Serialize(ev);

var suggestion = GoogleCalendarSyncSuggestion.Create(
user.Id,
Expand Down Expand Up @@ -301,21 +335,17 @@ private async Task CreateSuggestionNotification(User user, int count, Cancellati
await deps.NotificationRepository.AddAsync(notification, ct);
}

private bool IsInQuietHours(User user, DateTime utcNow)
private static bool IsInQuietHours(TimeZoneInfo timeZone, DateTime utcNow)
{
var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id);
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcNow, DateTimeKind.Utc), tz);
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcNow, DateTimeKind.Utc), timeZone);
return local.Hour >= QuietHoursStart && local.Hour < QuietHoursEnd;
}

private static DateTime ParseStartDateUtc(CalendarEventItem ev)
private static DateTime ResolveStartDateUtc(CalendarEventItem ev)
{
if (ev.StartUtc is { } startUtc)
return DateTime.SpecifyKind(startUtc, DateTimeKind.Utc);

if (DateOnly.TryParse(ev.StartDate, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date))
return date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);

return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}

Expand Down
Loading
Loading