From cd227cbb05d5c449ae6fd93268a880dfac8e84be Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 22 Mar 2026 21:44:38 -0300 Subject: [PATCH 1/7] feat: add skip habit endpoint New POST /api/habits/{id}/skip endpoint that advances a recurring habit's due date to the next scheduled occurrence without creating a HabitLog entry. Reuses existing AdvanceDueDate domain method. Validates: habit exists, owned by user, is recurring, due today or overdue, not already completed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Orbit.Api/Controllers/HabitsController.cs | 13 +++++ .../Habits/Commands/SkipHabitCommand.cs | 49 +++++++++++++++++++ .../Validators/SkipHabitCommandValidator.cs | 16 ++++++ 3 files changed, 78 insertions(+) create mode 100644 src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs create mode 100644 src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 407d3f772..4f020ddaf 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -205,6 +205,19 @@ public async Task LogHabit( : BadRequest(new { error = result.Error }); } + [HttpPost("{id:guid}/skip")] + public async Task SkipHabit( + Guid id, + CancellationToken cancellationToken) + { + var command = new SkipHabitCommand(HttpContext.GetUserId(), id); + var result = await mediator.Send(command, cancellationToken); + + return result.IsSuccess + ? NoContent() + : BadRequest(new { error = result.Error }); + } + [HttpPut("{id:guid}")] public async Task UpdateHabit( Guid id, diff --git a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs new file mode 100644 index 000000000..f5f4fc1f4 --- /dev/null +++ b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Habits.Commands; + +public record SkipHabitCommand(Guid UserId, Guid HabitId) : IRequest; + +public class SkipHabitCommandHandler( + IGenericRepository habitRepository, + IUserDateService userDateService, + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler +{ + public async Task Handle(SkipHabitCommand request, CancellationToken cancellationToken) + { + var habit = await habitRepository.FindOneTrackedAsync( + h => h.Id == request.HabitId, + cancellationToken: cancellationToken); + + if (habit is null) + return Result.Failure(ErrorMessages.HabitNotFound); + + if (habit.UserId != request.UserId) + return Result.Failure(ErrorMessages.HabitNotOwned); + + if (habit.IsCompleted) + return Result.Failure("Cannot skip a completed habit."); + + if (habit.FrequencyUnit is null) + return Result.Failure("Cannot skip a one-time task."); + + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + + if (habit.DueDate > today) + return Result.Failure("Cannot skip a habit that is not yet due."); + + habit.AdvanceDueDate(today); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + CacheInvalidationHelper.InvalidateSummaryCache(cache, habit.UserId); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs new file mode 100644 index 000000000..bd55bb97d --- /dev/null +++ b/src/Orbit.Application/Habits/Validators/SkipHabitCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Habits.Validators; + +public class SkipHabitCommandValidator : AbstractValidator +{ + public SkipHabitCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty(); + + RuleFor(x => x.HabitId) + .NotEmpty(); + } +} From e8eab680a9c00b7a2f9144333c72034ad49e59a9 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 10:47:35 -0300 Subject: [PATCH 2/7] feat: multiple reminders per habit and sub-habit days fix - Replace ReminderMinutesBefore (int) with ReminderTimes (List) stored as jsonb, supporting multiple reminder times per habit - Update SentReminder to track MinutesBefore for per-reminder deduplication with unique index (HabitId, Date, MinutesBefore) - Update ReminderSchedulerService to iterate over all reminder times with human-readable notification text (minutes/hours/days) - Fix CreateSubHabitCommand to accept days, frequency, and dueTime overrides so AI chat sub-habits get correct active days - Update all DTOs, commands, queries, and AI chat integration Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Orbit.Api/Controllers/HabitsController.cs | 8 +-- .../Chat/Commands/ProcessUserChatCommand.cs | 10 ++- .../Habits/Commands/CreateHabitCommand.cs | 6 +- .../Habits/Commands/CreateSubHabitCommand.cs | 13 +++- .../Habits/Commands/UpdateHabitCommand.cs | 14 ++-- .../Habits/Queries/GetHabitScheduleQuery.cs | 4 +- src/Orbit.Domain/Entities/Habit.cs | 12 ++-- src/Orbit.Domain/Entities/SentReminder.cs | 4 +- src/Orbit.Domain/Models/AiAction.cs | 2 +- .../Persistence/OrbitDbContext.cs | 14 +++- .../Services/ReminderSchedulerService.cs | 72 +++++++++---------- 11 files changed, 91 insertions(+), 68 deletions(-) diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 4f020ddaf..e37db7e82 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -25,7 +25,7 @@ public record CreateHabitRequest( DateOnly? DueDate = null, TimeOnly? DueTime = null, bool ReminderEnabled = false, - int ReminderMinutesBefore = 15, + IReadOnlyList? ReminderTimes = null, bool SlipAlertEnabled = false, IReadOnlyList? TagIds = null, IReadOnlyList? ChecklistItems = null); @@ -40,7 +40,7 @@ public record UpdateHabitRequest( DateOnly? DueDate = null, TimeOnly? DueTime = null, bool? ReminderEnabled = null, - int? ReminderMinutesBefore = null, + IReadOnlyList? ReminderTimes = null, bool? SlipAlertEnabled = null, IReadOnlyList? ChecklistItems = null); @@ -171,7 +171,7 @@ public async Task CreateHabit( request.DueDate, request.DueTime, request.ReminderEnabled, - request.ReminderMinutesBefore, + request.ReminderTimes, request.SlipAlertEnabled, request.TagIds, request.ChecklistItems); @@ -236,7 +236,7 @@ public async Task UpdateHabit( request.DueDate, request.DueTime, request.ReminderEnabled, - request.ReminderMinutesBefore, + request.ReminderTimes, request.SlipAlertEnabled, request.ChecklistItems); diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 94711f308..4c98ab319 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -389,7 +389,7 @@ public async Task> Handle( dueDate: dueDate, dueTime: action.DueTime, reminderEnabled: action.ReminderEnabled ?? false, - reminderMinutesBefore: action.ReminderMinutesBefore ?? 15, + reminderTimes: action.ReminderTimes, slipAlertEnabled: slipAlertEnabled, checklistItems: action.ChecklistItems); @@ -490,7 +490,7 @@ public async Task> Handle( action.DueDate ?? habit.DueDate, dueTime: action.DueTime, reminderEnabled: action.ReminderEnabled, - reminderMinutesBefore: action.ReminderMinutesBefore, + reminderTimes: action.ReminderTimes, checklistItems: action.ChecklistItems); if (result.IsFailure) @@ -531,7 +531,11 @@ public async Task> Handle( userId, action.HabitId.Value, action.Title, - action.Description), ct); + action.Description, + action.FrequencyUnit, + action.FrequencyQuantity, + action.Days, + action.DueTime), ct); if (result.IsFailure) return Result.Failure<(Guid? Id, string? Name)>(result.Error); diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index a9606b271..4f1d1706b 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -49,7 +49,7 @@ namespace Orbit.Application.Habits.Commands; Note = "with time")] [AiExample( "Exam tomorrow at 5pm, remind me 30 minutes before", - """{ "actions": [{ "type": "CreateHabit", "title": "Exam", "dueDate": "{TOMORROW}", "dueTime": "17:00", "reminderEnabled": true, "reminderMinutesBefore": 30 }], "aiMessage": "Scheduled your exam for tomorrow at 5pm with a reminder 30 minutes before!" }""", + """{ "actions": [{ "type": "CreateHabit", "title": "Exam", "dueDate": "{TOMORROW}", "dueTime": "17:00", "reminderEnabled": true, "reminderTimes": [30] }], "aiMessage": "Scheduled your exam for tomorrow at 5pm with a reminder 30 minutes before!" }""", Note = "with reminder")] [AiExample( "Create a supermarket list with milk, eggs, bread", @@ -67,7 +67,7 @@ public record CreateHabitCommand( [property: AiField("string", "YYYY-MM-DD, when the habit starts or is due", Required = true)] DateOnly? DueDate = null, [property: AiField("string", "HH:mm 24h format, e.g. \"15:00\" for 3pm. ONLY include when user mentions a specific time")] TimeOnly? DueTime = null, [property: AiField("boolean", "Set true when user asks for a reminder/notification")] bool ReminderEnabled = false, - [property: AiField("integer", "Minutes before dueTime to send reminder, default 15")] int ReminderMinutesBefore = 15, + [property: AiField("integer[]", "Array of minutes before dueTime to send reminders, e.g. [1440, 30] for 1 day and 30 min before. Default [15]")] IReadOnlyList? ReminderTimes = null, [property: AiField("boolean", "Defaults to true when isBadHabit is true -- sends AI-generated motivational alerts before predicted slip windows")] bool SlipAlertEnabled = false, [property: AiField("string[]", "Array of tag name strings, ONLY when user explicitly asks to tag it", Name = "tagNames")] IReadOnlyList? TagIds = null, [property: AiField("object[]", "Array of {text, isChecked} for inline checklists, e.g. shopping lists, packing lists. Use INSTEAD of sub-habits when user wants a simple checklist within a habit")] IReadOnlyList? ChecklistItems = null) : IRequest>; @@ -108,7 +108,7 @@ public async Task> Handle(CreateHabitCommand request, CancellationT dueDate, dueTime: request.DueTime, reminderEnabled: request.ReminderEnabled, - reminderMinutesBefore: request.ReminderMinutesBefore, + reminderTimes: request.ReminderTimes, slipAlertEnabled: request.SlipAlertEnabled, checklistItems: request.ChecklistItems); diff --git a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs index c81ef69c1..3cc0e8f56 100644 --- a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs @@ -3,6 +3,7 @@ using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; using Orbit.Application.Common.Attributes; @@ -28,7 +29,11 @@ public record CreateSubHabitCommand( Guid UserId, [property: AiField("string", "ID of existing PARENT habit from Active Habits list", Required = true, Name = "habitId")] Guid ParentHabitId, [property: AiField("string", "Name of the new sub-habit", Required = true)] string Title, - [property: AiField("string", "Optional description")] string? Description) : IRequest>; + [property: AiField("string", "Optional description")] string? Description, + [property: AiField("Day|Week|Month|Year", "Override parent frequency")] FrequencyUnit? FrequencyUnit = null, + [property: AiField("integer", "Override parent frequency quantity")] int? FrequencyQuantity = null, + [property: AiField("string[]", "Specific weekdays, only when frequencyQuantity is 1")] IReadOnlyList? Days = null, + [property: AiField("string", "HH:mm 24h format")] TimeOnly? DueTime = null) : IRequest>; public class CreateSubHabitCommandHandler( IGenericRepository habitRepository, @@ -65,10 +70,12 @@ public async Task> Handle(CreateSubHabitCommand request, Cancellati var childResult = Habit.Create( request.UserId, request.Title, - parent.FrequencyUnit, - parent.FrequencyQuantity, + request.FrequencyUnit ?? parent.FrequencyUnit, + request.FrequencyQuantity ?? parent.FrequencyQuantity, request.Description, + days: request.Days, dueDate: childDueDate, + dueTime: request.DueTime, parentHabitId: parent.Id); if (childResult.IsFailure) diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index 68bc000b4..fd7abaf36 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -42,7 +42,7 @@ public record UpdateHabitCommand( [property: AiField("string", "New due date YYYY-MM-DD")] DateOnly? DueDate = null, [property: AiField("string", "HH:mm 24h format to set or change time")] TimeOnly? DueTime = null, [property: AiField("boolean", "Enable or disable reminders")] bool? ReminderEnabled = null, - [property: AiField("integer", "Minutes before dueTime to send reminder")] int? ReminderMinutesBefore = null, + [property: AiField("integer[]", "Array of minutes before dueTime for reminders")] IReadOnlyList? ReminderTimes = null, [property: AiField("boolean", "Enable or disable slip alerts")] bool? SlipAlertEnabled = null, [property: AiField("object[]", "New checklist items")] IReadOnlyList? ChecklistItems = null) : IRequest; @@ -72,22 +72,22 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c request.DueDate, dueTime: request.DueTime, reminderEnabled: request.ReminderEnabled, - reminderMinutesBefore: request.ReminderMinutesBefore, + reminderTimes: request.ReminderTimes, slipAlertEnabled: request.SlipAlertEnabled, checklistItems: request.ChecklistItems); if (result.IsFailure) return result; - // If dueTime changed, clear today's sent reminder so it re-triggers + // If dueTime changed, clear today's sent reminders so they re-trigger if (request.DueTime.HasValue) { var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - var existing = await sentReminderRepository.FindOneTrackedAsync( + var existing = await sentReminderRepository.FindAsync( r => r.HabitId == request.HabitId && r.Date == userToday, - cancellationToken: cancellationToken); - if (existing is not null) - sentReminderRepository.Remove(existing); + cancellationToken); + foreach (var r in existing) + sentReminderRepository.Remove(r); } await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index 8451e25f4..8a51441e5 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -27,7 +27,7 @@ public record HabitScheduleItem( IReadOnlyList ScheduledDates, bool IsOverdue, bool ReminderEnabled, - int ReminderMinutesBefore, + IReadOnlyList ReminderTimes, bool SlipAlertEnabled, IReadOnlyList ChecklistItems, IReadOnlyList Tags, @@ -196,7 +196,7 @@ private static HabitScheduleItem MapToScheduleItem( scheduledDates, isOverdue, h.ReminderEnabled, - h.ReminderMinutesBefore, + h.ReminderTimes, h.SlipAlertEnabled, h.ChecklistItems, MapTags(h), diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index e9c2fab49..6ecb2b987 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -16,7 +16,7 @@ public class Habit : Entity public DateOnly DueDate { get; private set; } public TimeOnly? DueTime { get; private set; } public bool ReminderEnabled { get; private set; } - public int ReminderMinutesBefore { get; private set; } = 15; + public IReadOnlyList ReminderTimes { get; private set; } = [15]; public bool SlipAlertEnabled { get; private set; } public IReadOnlyList ChecklistItems { get; private set; } = []; public int? Position { get; private set; } @@ -48,7 +48,7 @@ public static Result Create( TimeOnly? dueTime = null, Guid? parentHabitId = null, bool reminderEnabled = false, - int reminderMinutesBefore = 15, + IReadOnlyList? reminderTimes = null, bool slipAlertEnabled = false, IReadOnlyList? checklistItems = null) { @@ -77,7 +77,7 @@ public static Result Create( DueTime = dueTime, ParentHabitId = parentHabitId, ReminderEnabled = reminderEnabled, - ReminderMinutesBefore = reminderMinutesBefore, + ReminderTimes = reminderTimes ?? [15], SlipAlertEnabled = slipAlertEnabled, ChecklistItems = checklistItems ?? [], CreatedAtUtc = DateTime.UtcNow @@ -170,7 +170,7 @@ public Result Update( DateOnly? dueDate, TimeOnly? dueTime = null, bool? reminderEnabled = null, - int? reminderMinutesBefore = null, + IReadOnlyList? reminderTimes = null, bool? slipAlertEnabled = null, IReadOnlyList? checklistItems = null) { @@ -197,8 +197,8 @@ public Result Update( if (reminderEnabled.HasValue) ReminderEnabled = reminderEnabled.Value; - if (reminderMinutesBefore.HasValue) - ReminderMinutesBefore = reminderMinutesBefore.Value; + if (reminderTimes is not null) + ReminderTimes = reminderTimes; if (slipAlertEnabled.HasValue) SlipAlertEnabled = slipAlertEnabled.Value; if (checklistItems is not null) diff --git a/src/Orbit.Domain/Entities/SentReminder.cs b/src/Orbit.Domain/Entities/SentReminder.cs index f80fc8f12..8c34268cf 100644 --- a/src/Orbit.Domain/Entities/SentReminder.cs +++ b/src/Orbit.Domain/Entities/SentReminder.cs @@ -6,16 +6,18 @@ public class SentReminder : Entity { public Guid HabitId { get; private set; } public DateOnly Date { get; private set; } + public int MinutesBefore { get; private set; } public DateTime SentAtUtc { get; private set; } private SentReminder() { } - public static SentReminder Create(Guid habitId, DateOnly date) + public static SentReminder Create(Guid habitId, DateOnly date, int minutesBefore) { return new SentReminder { HabitId = habitId, Date = date, + MinutesBefore = minutesBefore, SentAtUtc = DateTime.UtcNow }; } diff --git a/src/Orbit.Domain/Models/AiAction.cs b/src/Orbit.Domain/Models/AiAction.cs index 309b3d670..4262b10d0 100644 --- a/src/Orbit.Domain/Models/AiAction.cs +++ b/src/Orbit.Domain/Models/AiAction.cs @@ -15,7 +15,7 @@ public record AiAction public bool? IsBadHabit { get; init; } public bool? SlipAlertEnabled { get; init; } public bool? ReminderEnabled { get; init; } - public int? ReminderMinutesBefore { get; init; } + public List? ReminderTimes { get; init; } public DateOnly? DueDate { get; init; } public TimeOnly? DueTime { get; init; } public string? Note { get; init; } diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index eea00c84b..eec7dc1c6 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -61,6 +61,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) c => JsonSerializer.Serialize(c, (JsonSerializerOptions?)null).GetHashCode(), c => JsonSerializer.Deserialize>(JsonSerializer.Serialize(c, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); + entity.Property(h => h.ReminderTimes) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new List { 15 }) + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb") + .Metadata.SetValueComparer( + new ValueComparer>( + (l1, l2) => JsonSerializer.Serialize(l1, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(l2, (JsonSerializerOptions?)null), + c => JsonSerializer.Serialize(c, (JsonSerializerOptions?)null).GetHashCode(), + c => JsonSerializer.Deserialize>(JsonSerializer.Serialize(c, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); + }); modelBuilder.Entity(entity => @@ -93,7 +105,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { - entity.HasIndex(r => new { r.HabitId, r.Date }).IsUnique(); + entity.HasIndex(r => new { r.HabitId, r.Date, r.MinutesBefore }).IsUnique(); }); modelBuilder.Entity(entity => diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index 1bf3e388e..8afe69eda 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -71,46 +71,44 @@ private async Task CheckAndSendReminders(CancellationToken ct) .AnyAsync(l => l.HabitId == habit.Id && l.Date == userToday, ct); if (alreadyLogged) continue; - // Calculate reminder time - var reminderTime = habit.DueTime!.Value.AddMinutes(-habit.ReminderMinutesBefore); + foreach (var minutesBefore in habit.ReminderTimes) + { + var reminderTime = habit.DueTime!.Value.AddMinutes(-minutesBefore); - // Check if we're within the 1-minute window for sending - var diffMinutes = (userTimeNow - reminderTime).TotalMinutes; - if (diffMinutes < 0 || diffMinutes >= 1) continue; + var diffMinutes = (userTimeNow - reminderTime).TotalMinutes; + if (diffMinutes < 0 || diffMinutes >= 1) continue; - // Check if already sent - var alreadySent = await dbContext.SentReminders - .AnyAsync(r => r.HabitId == habit.Id && r.Date == userToday, ct); - if (alreadySent) continue; + var alreadySent = await dbContext.SentReminders + .AnyAsync(r => r.HabitId == habit.Id && r.Date == userToday && r.MinutesBefore == minutesBefore, ct); + if (alreadySent) continue; - // Send push notification (translated) - var lang = user.Language ?? "en"; - var minutesText = (lang.StartsWith("pt"), habit.ReminderMinutesBefore > 0) switch - { - (true, true) => $"Em {habit.ReminderMinutesBefore} minutos", - (true, false) => "Agora", - (false, true) => $"Due in {habit.ReminderMinutesBefore} minutes", - (false, false) => "Due now" - }; - - await pushService.SendToUserAsync( - habit.UserId, - habit.Title, - minutesText, - "/", - ct); - - // Record sent reminder + create in-app notification - var sentReminder = SentReminder.Create(habit.Id, userToday); - await dbContext.SentReminders.AddAsync(sentReminder, ct); - - var notification = Notification.Create( - habit.UserId, habit.Title, minutesText, "/", habit.Id); - await dbContext.Notifications.AddAsync(notification, ct); - - await dbContext.SaveChangesAsync(ct); - - logger.LogInformation("Sent reminder for habit {HabitId} to user {UserId}", habit.Id, habit.UserId); + var lang = user.Language ?? "en"; + var minutesText = FormatReminderText(minutesBefore, lang); + + await pushService.SendToUserAsync(habit.UserId, habit.Title, minutesText, "/", ct); + + var sentReminder = SentReminder.Create(habit.Id, userToday, minutesBefore); + await dbContext.SentReminders.AddAsync(sentReminder, ct); + + var notification = Notification.Create(habit.UserId, habit.Title, minutesText, "/", habit.Id); + await dbContext.Notifications.AddAsync(notification, ct); + + await dbContext.SaveChangesAsync(ct); + + logger.LogInformation("Sent reminder ({Minutes}min) for habit {HabitId} to user {UserId}", minutesBefore, habit.Id, habit.UserId); + } } } + + private static string FormatReminderText(int minutesBefore, string lang) + { + var isPt = lang.StartsWith("pt"); + return minutesBefore switch + { + 0 => isPt ? "Agora" : "Due now", + < 60 => isPt ? $"Em {minutesBefore} minutos" : $"Due in {minutesBefore} minutes", + < 1440 => isPt ? $"Em {minutesBefore / 60} hora{(minutesBefore / 60 > 1 ? "s" : "")}" : $"Due in {minutesBefore / 60} hour{(minutesBefore / 60 > 1 ? "s" : "")}", + _ => isPt ? $"Em {minutesBefore / 1440} dia{(minutesBefore / 1440 > 1 ? "s" : "")}" : $"Due in {minutesBefore / 1440} day{(minutesBefore / 1440 > 1 ? "s" : "")}" + }; + } } From 69ecabb8cd707f7f9cd00c2bda6f35b384f2ab1a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 10:55:55 -0300 Subject: [PATCH 3/7] chore: add migration for MultipleReminderTimes Migrates existing ReminderMinutesBefore values to ReminderTimes jsonb array before dropping the old column. Adds MinutesBefore to SentReminders with updated unique index. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...23135513_MultipleReminderTimes.Designer.cs | 476 ++++++++++++++++++ .../20260323135513_MultipleReminderTimes.cs | 75 +++ .../Migrations/OrbitDbContextModelSnapshot.cs | 12 +- 3 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs diff --git a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs new file mode 100644 index 000000000..76101c52b --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs @@ -0,0 +1,476 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260323135513_MultipleReminderTimes")] + partial class MultipleReminderTimes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("CompletedTours") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasDismissedMissions") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs b/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs new file mode 100644 index 000000000..32f4f23e5 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class MultipleReminderTimes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SentReminders_HabitId_Date", + table: "SentReminders"); + + migrationBuilder.AddColumn( + name: "ReminderTimes", + table: "Habits", + type: "jsonb", + nullable: false, + defaultValueSql: "'[15]'::jsonb"); + + // Migrate existing single values to array format + migrationBuilder.Sql( + """UPDATE "Habits" SET "ReminderTimes" = jsonb_build_array("ReminderMinutesBefore") WHERE "ReminderMinutesBefore" IS NOT NULL"""); + + migrationBuilder.DropColumn( + name: "ReminderMinutesBefore", + table: "Habits"); + + migrationBuilder.AddColumn( + name: "MinutesBefore", + table: "SentReminders", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_SentReminders_HabitId_Date_MinutesBefore", + table: "SentReminders", + columns: new[] { "HabitId", "Date", "MinutesBefore" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SentReminders_HabitId_Date_MinutesBefore", + table: "SentReminders"); + + migrationBuilder.DropColumn( + name: "MinutesBefore", + table: "SentReminders"); + + migrationBuilder.DropColumn( + name: "ReminderTimes", + table: "Habits"); + + migrationBuilder.AddColumn( + name: "ReminderMinutesBefore", + table: "Habits", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_SentReminders_HabitId_Date", + table: "SentReminders", + columns: new[] { "HabitId", "Date" }, + unique: true); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 4e4704454..5fc49b755 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -126,8 +126,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReminderEnabled") .HasColumnType("boolean"); - b.Property("ReminderMinutesBefore") - .HasColumnType("integer"); + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); b.Property("SlipAlertEnabled") .HasColumnType("boolean"); @@ -256,12 +259,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HabitId") .HasColumnType("uuid"); + b.Property("MinutesBefore") + .HasColumnType("integer"); + b.Property("SentAtUtc") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); - b.HasIndex("HabitId", "Date") + b.HasIndex("HabitId", "Date", "MinutesBefore") .IsUnique(); b.ToTable("SentReminders"); From e77efbe1d1cee6d3608ea9aeb3b0a34107504bc7 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 11:00:41 -0300 Subject: [PATCH 4/7] fix: regenerate migration with clean model snapshot The previous migration had a stale model snapshot that didn't include the value converter/comparer metadata for ReminderTimes, causing PendingModelChangesWarning in CI. Regenerated from scratch. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...gner.cs => 20260323135955_MultipleReminderTimes.Designer.cs} | 2 +- ...ReminderTimes.cs => 20260323135955_MultipleReminderTimes.cs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/Orbit.Infrastructure/Migrations/{20260323135513_MultipleReminderTimes.Designer.cs => 20260323135955_MultipleReminderTimes.Designer.cs} (99%) rename src/Orbit.Infrastructure/Migrations/{20260323135513_MultipleReminderTimes.cs => 20260323135955_MultipleReminderTimes.cs} (96%) diff --git a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.Designer.cs similarity index 99% rename from src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs rename to src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.Designer.cs index 76101c52b..c585d34c8 100644 --- a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.Designer.cs +++ b/src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.Designer.cs @@ -12,7 +12,7 @@ namespace Orbit.Infrastructure.Migrations { [DbContext(typeof(OrbitDbContext))] - [Migration("20260323135513_MultipleReminderTimes")] + [Migration("20260323135955_MultipleReminderTimes")] partial class MultipleReminderTimes { /// diff --git a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs b/src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.cs similarity index 96% rename from src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs rename to src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.cs index 32f4f23e5..b8b2d3664 100644 --- a/src/Orbit.Infrastructure/Migrations/20260323135513_MultipleReminderTimes.cs +++ b/src/Orbit.Infrastructure/Migrations/20260323135955_MultipleReminderTimes.cs @@ -21,7 +21,7 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValueSql: "'[15]'::jsonb"); - // Migrate existing single values to array format + // Migrate existing single values to array format before dropping old column migrationBuilder.Sql( """UPDATE "Habits" SET "ReminderTimes" = jsonb_build_array("ReminderMinutesBefore") WHERE "ReminderMinutesBefore" IS NOT NULL"""); From f139b15ac72dbf4128de871d5bc87c133e247ff5 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 12:31:37 -0300 Subject: [PATCH 5/7] feat: Google Calendar sync - import events as habits - Add GoogleAccessToken/GoogleRefreshToken to User entity - Store Google tokens during OAuth login flow - Add GET /api/calendar/events (preview events for selection) - Add POST /api/calendar/import (create habits from selected events) - RRULE parsing: DAILY, WEEKLY, MONTHLY, YEARLY, BYDAY, INTERVAL - Google token refresh service with OAuth2 token endpoint - Map event reminders to Orbit reminderTimes array - DB migration for Google token columns Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Orbit.Api/Controllers/AuthController.cs | 4 +- .../Controllers/CalendarController.cs | 44 ++ src/Orbit.Api/Program.cs | 1 + src/Orbit.Api/appsettings.json | 4 + .../Auth/Commands/GoogleAuthCommand.cs | 9 +- .../Commands/ImportCalendarEventsCommand.cs | 186 +++++++ .../Queries/GetCalendarEventsQuery.cs | 98 ++++ .../Orbit.Application.csproj | 1 + src/Orbit.Domain/Entities/User.cs | 15 + .../Interfaces/IGoogleTokenService.cs | 8 + ...323153115_GoogleCalendarTokens.Designer.cs | 482 ++++++++++++++++++ .../20260323153115_GoogleCalendarTokens.cs | 38 ++ .../Migrations/OrbitDbContextModelSnapshot.cs | 6 + .../Orbit.Infrastructure.csproj | 1 + .../Services/GoogleTokenService.cs | 54 ++ 15 files changed, 948 insertions(+), 3 deletions(-) create mode 100644 src/Orbit.Api/Controllers/CalendarController.cs create mode 100644 src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs create mode 100644 src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs create mode 100644 src/Orbit.Domain/Interfaces/IGoogleTokenService.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs create mode 100644 src/Orbit.Infrastructure/Services/GoogleTokenService.cs diff --git a/src/Orbit.Api/Controllers/AuthController.cs b/src/Orbit.Api/Controllers/AuthController.cs index 981212a6c..743a3eead 100644 --- a/src/Orbit.Api/Controllers/AuthController.cs +++ b/src/Orbit.Api/Controllers/AuthController.cs @@ -12,7 +12,7 @@ public class AuthController(IMediator mediator, ILogger logger) { public record SendCodeRequest(string Email, string Language = "en"); public record VerifyCodeRequest(string Email, string Code, string Language = "en"); - public record GoogleAuthRequest(string AccessToken, string Language = "en"); + public record GoogleAuthRequest(string AccessToken, string Language = "en", string? GoogleAccessToken = null, string? GoogleRefreshToken = null); public record ConfirmDeletionRequest(string Code); [HttpPost("send-code")] @@ -56,7 +56,7 @@ public async Task GoogleAuth( [FromBody] GoogleAuthRequest request, CancellationToken cancellationToken) { - var command = new GoogleAuthCommand(request.AccessToken, request.Language); + var command = new GoogleAuthCommand(request.AccessToken, request.Language, request.GoogleAccessToken, request.GoogleRefreshToken); var result = await mediator.Send(command, cancellationToken); if (result.IsSuccess) diff --git a/src/Orbit.Api/Controllers/CalendarController.cs b/src/Orbit.Api/Controllers/CalendarController.cs new file mode 100644 index 000000000..4482c4918 --- /dev/null +++ b/src/Orbit.Api/Controllers/CalendarController.cs @@ -0,0 +1,44 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Orbit.Api.Extensions; +using Orbit.Application.Calendar.Commands; +using Orbit.Application.Calendar.Queries; + +namespace Orbit.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class CalendarController(IMediator mediator, ILogger logger) : ControllerBase +{ + [HttpGet("events")] + public async Task GetEvents(CancellationToken cancellationToken) + { + var query = new GetCalendarEventsQuery(HttpContext.GetUserId()); + var result = await mediator.Send(query, cancellationToken); + + if (result.IsSuccess) + return Ok(result.Value); + + logger.LogWarning("Failed to fetch calendar events: {Error}", result.Error); + return BadRequest(new { error = result.Error }); + } + + public record ImportRequest(IReadOnlyList EventIds); + + [HttpPost("import")] + public async Task Import( + [FromBody] ImportRequest request, + CancellationToken cancellationToken) + { + var command = new ImportCalendarEventsCommand(HttpContext.GetUserId(), request.EventIds); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + return Ok(result.Value); + + logger.LogWarning("Failed to import calendar events: {Error}", result.Error); + return BadRequest(new { error = result.Error }); + } +} diff --git a/src/Orbit.Api/Program.cs b/src/Orbit.Api/Program.cs index 9efbf03a5..789e50588 100644 --- a/src/Orbit.Api/Program.cs +++ b/src/Orbit.Api/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- Token Service --- builder.Services.AddScoped(); diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index a62c58eab..0400a99d4 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -33,6 +33,10 @@ "PrivateKey": "REPLACE-IN-DEVELOPMENT-JSON", "Subject": "mailto:hello@useorbit.org" }, + "Google": { + "ClientId": "", + "ClientSecret": "" + }, "Stripe": { "SecretKey": "REPLACE-IN-DEVELOPMENT-JSON", "PublishableKey": "REPLACE-IN-DEVELOPMENT-JSON", diff --git a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs index 832710e5f..a63124ad2 100644 --- a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs +++ b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs @@ -8,7 +8,7 @@ namespace Orbit.Application.Auth.Commands; -public record GoogleAuthCommand(string AccessToken, string Language = "en") +public record GoogleAuthCommand(string AccessToken, string Language = "en", string? GoogleAccessToken = null, string? GoogleRefreshToken = null) : IRequest>; public class GoogleAuthCommandHandler( @@ -77,6 +77,13 @@ public async Task> Handle(GoogleAuthCommand request, Cance }, CancellationToken.None); } + // Store Google tokens for Calendar API access + if (request.GoogleAccessToken is not null) + { + user.SetGoogleTokens(request.GoogleAccessToken, request.GoogleRefreshToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + // Generate app JWT var token = tokenService.GenerateToken(user.Id, user.Email); diff --git a/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs b/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs new file mode 100644 index 000000000..b4b5f70a4 --- /dev/null +++ b/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs @@ -0,0 +1,186 @@ +using Google.Apis.Calendar.v3; +using Google.Apis.Services; +using MediatR; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Calendar.Commands; + +public record ImportedHabitItem(Guid Id, string Title); + +public record ImportCalendarEventsResult(int Imported, List Habits); + +public record ImportCalendarEventsCommand(Guid UserId, IReadOnlyList EventIds) + : IRequest>; + +public class ImportCalendarEventsCommandHandler( + IGenericRepository userRepository, + IGenericRepository habitRepository, + IGoogleTokenService googleTokenService, + IUserDateService userDateService, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + public async Task> Handle( + ImportCalendarEventsCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user is null) + return Result.Failure("User not found"); + + var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken); + if (accessToken is null) + return Result.Failure("Google Calendar not connected."); + + await unitOfWork.SaveChangesAsync(cancellationToken); // Persist refreshed token + + var credential = Google.Apis.Auth.OAuth2.GoogleCredential.FromAccessToken(accessToken); + var service = new CalendarService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Orbit" + }); + + var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + var imported = new List(); + + foreach (var eventId in request.EventIds) + { + try + { + var ev = await service.Events.Get("primary", eventId).ExecuteAsync(cancellationToken); + if (ev is null || string.IsNullOrWhiteSpace(ev.Summary)) continue; + + // Parse dates + DateOnly dueDate; + TimeOnly? dueTime = null; + + if (ev.Start?.DateTimeDateTimeOffset is not null) + { + var dt = ev.Start.DateTimeDateTimeOffset.Value; + dueDate = DateOnly.FromDateTime(dt.DateTime); + dueTime = TimeOnly.FromDateTime(dt.DateTime); + } + else if (ev.Start?.Date is not null) + { + dueDate = DateOnly.Parse(ev.Start.Date); + } + else + { + dueDate = userToday; + } + + if (dueDate < userToday) dueDate = userToday; + + // Parse recurrence + FrequencyUnit? freqUnit = null; + int? freqQty = null; + IReadOnlyList? days = null; + + var rrule = ev.Recurrence?.FirstOrDefault(r => + r.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase)); + + if (rrule is not null) + { + var parsed = ParseRRule(rrule); + freqUnit = parsed.FreqUnit; + freqQty = parsed.FreqQty; + days = parsed.Days; + } + + // Parse reminders + var reminderTimes = new List(); + bool reminderEnabled = false; + if (ev.Reminders?.Overrides is not null) + { + foreach (var r in ev.Reminders.Overrides) + { + if (r.Minutes.HasValue) + reminderTimes.Add(r.Minutes.Value); + } + reminderEnabled = reminderTimes.Count > 0; + } + + var habitResult = Habit.Create( + request.UserId, + ev.Summary, + freqUnit, + freqQty, + ev.Description, + days: days, + dueDate: dueDate, + dueTime: dueTime, + reminderEnabled: reminderEnabled, + reminderTimes: reminderTimes.Count > 0 ? reminderTimes : null); + + if (habitResult.IsFailure) continue; + + await habitRepository.AddAsync(habitResult.Value, cancellationToken); + imported.Add(new ImportedHabitItem(habitResult.Value.Id, ev.Summary)); + } + catch + { + // Skip events that fail to import + continue; + } + } + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(new ImportCalendarEventsResult(imported.Count, imported)); + } + + private static (FrequencyUnit? FreqUnit, int? FreqQty, IReadOnlyList? Days) ParseRRule(string rrule) + { + var parts = rrule.Replace("RRULE:", "").Split(';') + .Select(p => p.Split('=')) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0].ToUpperInvariant(), p => p[1]); + + FrequencyUnit? freqUnit = null; + int? freqQty = 1; + List? days = null; + + if (parts.TryGetValue("FREQ", out var freq)) + { + freqUnit = freq.ToUpperInvariant() switch + { + "DAILY" => FrequencyUnit.Day, + "WEEKLY" => FrequencyUnit.Week, + "MONTHLY" => FrequencyUnit.Month, + "YEARLY" => FrequencyUnit.Year, + _ => null + }; + } + + if (parts.TryGetValue("INTERVAL", out var interval) && int.TryParse(interval, out var qty)) + { + freqQty = qty; + } + + if (parts.TryGetValue("BYDAY", out var byDay)) + { + days = byDay.Split(',').Select(d => d.Trim().ToUpperInvariant() switch + { + "MO" => DayOfWeek.Monday, + "TU" => DayOfWeek.Tuesday, + "WE" => DayOfWeek.Wednesday, + "TH" => DayOfWeek.Thursday, + "FR" => DayOfWeek.Friday, + "SA" => DayOfWeek.Saturday, + "SU" => DayOfWeek.Sunday, + _ => (DayOfWeek?)null + }).Where(d => d.HasValue).Select(d => d!.Value).ToList(); + + // BYDAY with WEEKLY frequency means specific days -> use Day freq with days list + if (freqUnit == FrequencyUnit.Week && days.Count > 0 && freqQty == 1) + { + freqUnit = FrequencyUnit.Day; + } + } + + return (freqUnit, freqQty, days?.Count > 0 ? days : null); + } +} diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs new file mode 100644 index 000000000..249a863ac --- /dev/null +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -0,0 +1,98 @@ +using Google.Apis.Calendar.v3; +using Google.Apis.Services; +using MediatR; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Calendar.Queries; + +public record CalendarEventItem( + string Id, + string Title, + string? Description, + string? StartDate, + string? StartTime, + string? EndTime, + bool IsRecurring, + string? RecurrenceRule, + List Reminders); + +public record GetCalendarEventsQuery(Guid UserId) : IRequest>>; + +public class GetCalendarEventsQueryHandler( + IGenericRepository userRepository, + IGoogleTokenService googleTokenService, + IUnitOfWork unitOfWork) : IRequestHandler>> +{ + public async Task>> Handle(GetCalendarEventsQuery request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user is null) + return Result.Failure>("User not found"); + + var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken); + if (accessToken is null) + return Result.Failure>("Google Calendar not connected. Please sign in with Google first."); + + await unitOfWork.SaveChangesAsync(cancellationToken); // Persist refreshed token + + try + { + var credential = Google.Apis.Auth.OAuth2.GoogleCredential.FromAccessToken(accessToken); + var service = new CalendarService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Orbit" + }); + + var listRequest = service.Events.List("primary"); + listRequest.TimeMinDateTimeOffset = DateTimeOffset.UtcNow; + listRequest.TimeMaxDateTimeOffset = DateTimeOffset.UtcNow.AddDays(60); + listRequest.SingleEvents = false; // Keep recurring events as masters + listRequest.MaxResults = 250; + listRequest.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; + + var events = await listRequest.ExecuteAsync(cancellationToken); + + var items = new List(); + foreach (var ev in events.Items ?? []) + { + if (string.IsNullOrWhiteSpace(ev.Summary)) continue; + + var startDate = ev.Start?.Date ?? ev.Start?.DateTimeDateTimeOffset?.ToString("yyyy-MM-dd"); + var startTime = ev.Start?.DateTimeDateTimeOffset?.ToString("HH:mm"); + var endTime = ev.End?.DateTimeDateTimeOffset?.ToString("HH:mm"); + var isRecurring = ev.Recurrence?.Count > 0; + var rrule = ev.Recurrence?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase)); + + var reminders = new List(); + if (ev.Reminders?.Overrides is not null) + { + foreach (var r in ev.Reminders.Overrides) + { + if (r.Minutes.HasValue) + reminders.Add(r.Minutes.Value); + } + } + + items.Add(new CalendarEventItem( + ev.Id, + ev.Summary, + ev.Description, + startDate, + startTime, + endTime, + isRecurring, + rrule, + reminders)); + } + + return Result.Success(items); + } + catch (Google.GoogleApiException ex) + { + return Result.Failure>($"Google Calendar API error: {ex.Message}"); + } + } +} diff --git a/src/Orbit.Application/Orbit.Application.csproj b/src/Orbit.Application/Orbit.Application.csproj index 993807a4d..a0d8178c6 100644 --- a/src/Orbit.Application/Orbit.Application.csproj +++ b/src/Orbit.Application/Orbit.Application.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index feb4676e7..889d948ff 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -29,6 +29,8 @@ public class User : Entity public int AiMessagesUsedThisMonth { get; private set; } = 0; public DateTime? AiMessagesResetAt { get; private set; } public DateTime CreatedAtUtc { get; private set; } + public string? GoogleAccessToken { get; private set; } + public string? GoogleRefreshToken { get; private set; } [NotMapped] public bool IsPro => IsLifetimePro || (Plan == UserPlan.Pro && PlanExpiresAt.HasValue && PlanExpiresAt.Value > DateTime.UtcNow); @@ -130,4 +132,17 @@ public void IncrementAiMessageCount() } AiMessagesUsedThisMonth++; } + + public void SetGoogleTokens(string accessToken, string? refreshToken) + { + GoogleAccessToken = accessToken; + if (refreshToken is not null) + GoogleRefreshToken = refreshToken; + } + + public void ClearGoogleTokens() + { + GoogleAccessToken = null; + GoogleRefreshToken = null; + } } diff --git a/src/Orbit.Domain/Interfaces/IGoogleTokenService.cs b/src/Orbit.Domain/Interfaces/IGoogleTokenService.cs new file mode 100644 index 000000000..3d90b75b2 --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IGoogleTokenService.cs @@ -0,0 +1,8 @@ +using Orbit.Domain.Entities; + +namespace Orbit.Domain.Interfaces; + +public interface IGoogleTokenService +{ + Task GetValidAccessTokenAsync(User user, CancellationToken ct = default); +} diff --git a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs new file mode 100644 index 000000000..b645aaceb --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs @@ -0,0 +1,482 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260323153115_GoogleCalendarTokens")] + partial class GoogleCalendarTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("CompletedTours") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasDismissedMissions") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs b/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs new file mode 100644 index 000000000..b0108df31 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class GoogleCalendarTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GoogleAccessToken", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleRefreshToken", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GoogleAccessToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "GoogleRefreshToken", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 5fc49b755..201cebf18 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -352,6 +352,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + b.Property("HasCompletedOnboarding") .HasColumnType("boolean"); diff --git a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj index bd0238a3f..de63e094c 100644 --- a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj +++ b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Orbit.Infrastructure/Services/GoogleTokenService.cs b/src/Orbit.Infrastructure/Services/GoogleTokenService.cs new file mode 100644 index 000000000..3cd034cdd --- /dev/null +++ b/src/Orbit.Infrastructure/Services/GoogleTokenService.cs @@ -0,0 +1,54 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Services; + +public class GoogleTokenService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) : IGoogleTokenService +{ + public async Task GetValidAccessTokenAsync(User user, CancellationToken ct = default) + { + if (user.GoogleAccessToken is null) + return null; + + // Try the stored token first - if refresh token exists, try refresh + if (user.GoogleRefreshToken is not null) + { + try + { + var client = httpClientFactory.CreateClient(); + var response = await client.PostAsync("https://oauth2.googleapis.com/token", + new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = configuration["Google:ClientId"]!, + ["client_secret"] = configuration["Google:ClientSecret"]!, + ["refresh_token"] = user.GoogleRefreshToken, + ["grant_type"] = "refresh_token" + }), ct); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadFromJsonAsync(ct); + var newToken = json.GetProperty("access_token").GetString(); + if (newToken is not null) + { + user.SetGoogleTokens(newToken, null); + return newToken; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh Google token for user {UserId}", user.Id); + } + } + + return user.GoogleAccessToken; + } +} From 445a2f1d513377f8b68dcc5371faa1193407b51f Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 12:38:04 -0300 Subject: [PATCH 6/7] feat: add HasImportedCalendar flag and expose in profile - Add HasImportedCalendar to User entity, set after successful import - Add HasGoogleConnection (computed from token presence) to profile response - DB migration for HasImportedCalendar column Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Calendar/Commands/ImportCalendarEventsCommand.cs | 3 +++ src/Orbit.Application/Profile/Queries/GetProfileQuery.cs | 8 ++++++-- src/Orbit.Domain/Entities/User.cs | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs b/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs index b4b5f70a4..db8fa3b5b 100644 --- a/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs +++ b/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs @@ -127,6 +127,9 @@ public async Task> Handle( } } + if (imported.Count > 0) + user.MarkCalendarImported(); + await unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(new ImportCalendarEventsResult(imported.Count, imported)); diff --git a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs index 5d43c3cfb..0033e47b3 100644 --- a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs +++ b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs @@ -20,7 +20,9 @@ public record ProfileResponse( DateTime? TrialEndsAt, DateTime? PlanExpiresAt, int AiMessagesUsed, - int AiMessagesLimit); + int AiMessagesLimit, + bool HasImportedCalendar, + bool HasGoogleConnection); public record GetProfileQuery(Guid UserId) : IRequest; @@ -53,6 +55,8 @@ public async Task Handle(GetProfileQuery request, CancellationT user.TrialEndsAt, user.PlanExpiresAt, user.AiMessagesUsedThisMonth, - aiMessageLimit); + aiMessageLimit, + user.HasImportedCalendar, + user.GoogleAccessToken is not null); } } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 889d948ff..6f8b8071f 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -29,6 +29,7 @@ public class User : Entity public int AiMessagesUsedThisMonth { get; private set; } = 0; public DateTime? AiMessagesResetAt { get; private set; } public DateTime CreatedAtUtc { get; private set; } + public bool HasImportedCalendar { get; private set; } = false; public string? GoogleAccessToken { get; private set; } public string? GoogleRefreshToken { get; private set; } @@ -145,4 +146,6 @@ public void ClearGoogleTokens() GoogleAccessToken = null; GoogleRefreshToken = null; } + + public void MarkCalendarImported() => HasImportedCalendar = true; } From ff3aad70cd00494ba9096c5fe206f976b5b53a38 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 23 Mar 2026 12:46:25 -0300 Subject: [PATCH 7/7] fix: regenerate migration with clean model snapshot Co-Authored-By: Claude Opus 4.6 (1M context) --- ...> 20260323154616_GoogleCalendarSync.Designer.cs} | 7 +++++-- ...kens.cs => 20260323154616_GoogleCalendarSync.cs} | 13 ++++++++++++- .../Migrations/OrbitDbContextModelSnapshot.cs | 3 +++ 3 files changed, 20 insertions(+), 3 deletions(-) rename src/Orbit.Infrastructure/Migrations/{20260323153115_GoogleCalendarTokens.Designer.cs => 20260323154616_GoogleCalendarSync.Designer.cs} (98%) rename src/Orbit.Infrastructure/Migrations/{20260323153115_GoogleCalendarTokens.cs => 20260323154616_GoogleCalendarSync.cs} (71%) diff --git a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.Designer.cs similarity index 98% rename from src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs rename to src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.Designer.cs index b645aaceb..66432a3e7 100644 --- a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.Designer.cs +++ b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.Designer.cs @@ -12,8 +12,8 @@ namespace Orbit.Infrastructure.Migrations { [DbContext(typeof(OrbitDbContext))] - [Migration("20260323153115_GoogleCalendarTokens")] - partial class GoogleCalendarTokens + [Migration("20260323154616_GoogleCalendarSync")] + partial class GoogleCalendarSync { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -367,6 +367,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("HasDismissedMissions") .HasColumnType("boolean"); + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + b.Property("IsLifetimePro") .HasColumnType("boolean"); diff --git a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.cs similarity index 71% rename from src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs rename to src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.cs index b0108df31..c14fcb3c9 100644 --- a/src/Orbit.Infrastructure/Migrations/20260323153115_GoogleCalendarTokens.cs +++ b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.cs @@ -5,7 +5,7 @@ namespace Orbit.Infrastructure.Migrations { /// - public partial class GoogleCalendarTokens : Migration + public partial class GoogleCalendarSync : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -21,6 +21,13 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Users", type: "text", nullable: true); + + migrationBuilder.AddColumn( + name: "HasImportedCalendar", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); } /// @@ -33,6 +40,10 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropColumn( name: "GoogleRefreshToken", table: "Users"); + + migrationBuilder.DropColumn( + name: "HasImportedCalendar", + table: "Users"); } } } diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 201cebf18..5cf6b21a6 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -364,6 +364,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HasDismissedMissions") .HasColumnType("boolean"); + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + b.Property("IsLifetimePro") .HasColumnType("boolean");