Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Orbit.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class AuthController(IMediator mediator, ILogger<AuthController> 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")]
Expand Down Expand Up @@ -56,7 +56,7 @@ public async Task<IActionResult> 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)
Expand Down
44 changes: 44 additions & 0 deletions src/Orbit.Api/Controllers/CalendarController.cs
Original file line number Diff line number Diff line change
@@ -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<CalendarController> logger) : ControllerBase
{
[HttpGet("events")]
public async Task<IActionResult> 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<string> EventIds);

[HttpPost("import")]
public async Task<IActionResult> 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 });
}
}
1 change: 1 addition & 0 deletions src/Orbit.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
builder.Services.AddScoped<IAppConfigService, AppConfigService>();
builder.Services.AddScoped<IUserDateService, UserDateService>();
builder.Services.AddScoped<IPayGateService, Orbit.Application.Common.PayGateService>();
builder.Services.AddScoped<Orbit.Domain.Interfaces.IGoogleTokenService, GoogleTokenService>();

// --- Token Service ---
builder.Services.AddScoped<ITokenService, JwtTokenService>();
Expand Down
4 changes: 4 additions & 0 deletions src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<LoginResponse>>;

public class GoogleAuthCommandHandler(
Expand Down Expand Up @@ -77,6 +77,13 @@ public async Task<Result<LoginResponse>> 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);

Expand Down
189 changes: 189 additions & 0 deletions src/Orbit.Application/Calendar/Commands/ImportCalendarEventsCommand.cs
Original file line number Diff line number Diff line change
@@ -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<ImportedHabitItem> Habits);

public record ImportCalendarEventsCommand(Guid UserId, IReadOnlyList<string> EventIds)
: IRequest<Result<ImportCalendarEventsResult>>;

public class ImportCalendarEventsCommandHandler(
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGoogleTokenService googleTokenService,
IUserDateService userDateService,
IUnitOfWork unitOfWork) : IRequestHandler<ImportCalendarEventsCommand, Result<ImportCalendarEventsResult>>
{
public async Task<Result<ImportCalendarEventsResult>> Handle(
ImportCalendarEventsCommand request, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
return Result.Failure<ImportCalendarEventsResult>("User not found");

var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken);
if (accessToken is null)
return Result.Failure<ImportCalendarEventsResult>("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<ImportedHabitItem>();

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<DayOfWeek>? 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<int>();
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<DayOfWeek>? 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<DayOfWeek>? 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);
}
}
98 changes: 98 additions & 0 deletions src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs
Original file line number Diff line number Diff line change
@@ -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<int> Reminders);

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

public class GetCalendarEventsQueryHandler(
IGenericRepository<User> userRepository,
IGoogleTokenService googleTokenService,
IUnitOfWork unitOfWork) : IRequestHandler<GetCalendarEventsQuery, Result<List<CalendarEventItem>>>
{
public async Task<Result<List<CalendarEventItem>>> Handle(GetCalendarEventsQuery request, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
return Result.Failure<List<CalendarEventItem>>("User not found");

var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken);
if (accessToken is null)
return Result.Failure<List<CalendarEventItem>>("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<CalendarEventItem>();
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<int>();
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<List<CalendarEventItem>>($"Google Calendar API error: {ex.Message}");
}
}
}
Loading
Loading