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..db8fa3b5b --- /dev/null +++ b/src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs @@ -0,0 +1,189 @@ +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; + } + } + + if (imported.Count > 0) + user.MarkCalendarImported(); + + 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.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 feb4676e7..6f8b8071f 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -29,6 +29,9 @@ 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; } [NotMapped] public bool IsPro => IsLifetimePro || (Plan == UserPlan.Pro && PlanExpiresAt.HasValue && PlanExpiresAt.Value > DateTime.UtcNow); @@ -130,4 +133,19 @@ 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; + } + + public void MarkCalendarImported() => HasImportedCalendar = true; } 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/20260323154616_GoogleCalendarSync.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.Designer.cs new file mode 100644 index 000000000..66432a3e7 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.Designer.cs @@ -0,0 +1,485 @@ +// +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("20260323154616_GoogleCalendarSync")] + partial class GoogleCalendarSync + { + /// + 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("HasImportedCalendar") + .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/20260323154616_GoogleCalendarSync.cs b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.cs new file mode 100644 index 000000000..c14fcb3c9 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260323154616_GoogleCalendarSync.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class GoogleCalendarSync : 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); + + migrationBuilder.AddColumn( + name: "HasImportedCalendar", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GoogleAccessToken", + table: "Users"); + + 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 5fc49b755..5cf6b21a6 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -352,12 +352,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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("HasImportedCalendar") + .HasColumnType("boolean"); + b.Property("IsLifetimePro") .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; + } +}