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
23 changes: 23 additions & 0 deletions src/Orbit.Api/Controllers/ProfileController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MediatR;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -23,6 +24,11 @@ public record SetWeekStartDayRequest([property: JsonRequired] int WeekStartDay);
public record SetThemePreferenceRequest(string? ThemePreference);
public record SetColorSchemeRequest(string? ColorScheme);

private static readonly JsonSerializerOptions ExportJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};

[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand Down Expand Up @@ -211,6 +217,23 @@ public async Task<IActionResult> ResetAccount(CancellationToken cancellationToke
: BadRequest(new { error = result.Error });
}

[HttpGet("export")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ExportUserData(CancellationToken cancellationToken)
{
var query = new ExportUserDataQuery(HttpContext.GetUserId());
var result = await mediator.Send(query, cancellationToken);

if (!result.IsSuccess)
return NotFound(new { error = result.Error });

var fileName = $"orbit-data-export-{DateTime.UtcNow:yyyy-MM-dd}.json";
var json = JsonSerializer.SerializeToUtf8Bytes(result.Value, ExportJsonOptions);
return File(json, "application/json", fileName);
}


[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Timezone changed to {Timezone} for user {UserId}")]
private static partial void LogTimezoneChanged(ILogger logger, string timezone, Guid userId);
Expand Down
85 changes: 85 additions & 0 deletions src/Orbit.Application/Profile/Models/UserDataExport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Orbit.Domain.ValueObjects;

namespace Orbit.Application.Profile.Models;

/// <summary>
/// Portable snapshot of everything a user owns, returned by the data-export endpoint.
/// Shape is a stable, JSON-friendly projection of the domain entities (LGPD Art. 18 / GDPR Art. 20).
/// Sensitive fields (Google OAuth tokens, Stripe identifiers) are intentionally excluded.
/// </summary>
public sealed record UserDataExport(
DateTime ExportedAtUtc,
ExportedAccount Account,
ExportedSettings Settings,
IReadOnlyList<ExportedHabit> Habits,
IReadOnlyList<ExportedGoal> Goals,
IReadOnlyList<ExportedTag> Tags,
IReadOnlyList<ExportedUserFact> Facts);

public sealed record ExportedAccount(
string Name,
string Email,
DateTime CreatedAtUtc,
string Plan);

public sealed record ExportedSettings(
string? TimeZone,
string? Language,
int WeekStartDay,
string? ThemePreference,
string? ColorScheme,
bool AiMemoryEnabled,
bool AiSummaryEnabled);

public sealed record ExportedHabit(
Guid Id,
string Title,
string? Description,
string? Emoji,
bool IsBadHabit,
bool IsGeneral,
DateOnly DueDate,
DateOnly? EndDate,
string? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<string> Days,
IReadOnlyList<ChecklistItem> ChecklistItems,
DateTime CreatedAtUtc,
IReadOnlyList<ExportedHabitLog> Logs);

public sealed record ExportedHabitLog(
DateOnly Date,
decimal Value,
string? Note,
DateTime CreatedAtUtc);

public sealed record ExportedGoal(
Guid Id,
string Title,
string? Description,
decimal TargetValue,
decimal CurrentValue,
string Unit,
string Status,
string Type,
DateOnly? Deadline,
DateTime CreatedAtUtc,
DateTime? CompletedAtUtc,
IReadOnlyList<ExportedGoalProgressLog> ProgressLogs);

public sealed record ExportedGoalProgressLog(
decimal Value,
decimal PreviousValue,
string? Note,
DateTime CreatedAtUtc);

public sealed record ExportedTag(
Guid Id,
string Name,
string Color,
DateTime CreatedAtUtc);

public sealed record ExportedUserFact(
string FactText,
string? Category,
DateTime ExtractedAtUtc);
108 changes: 108 additions & 0 deletions src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using MediatR;
using Orbit.Application.Common;
using Orbit.Application.Profile.Models;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Profile.Queries;

public record ExportUserDataQuery(Guid UserId) : IRequest<Result<UserDataExport>>;

public class ExportUserDataQueryHandler(
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IGenericRepository<Goal> goalRepository,
IGenericRepository<GoalProgressLog> goalProgressLogRepository,
IGenericRepository<Tag> tagRepository,
IGenericRepository<UserFact> userFactRepository)
: IRequestHandler<ExportUserDataQuery, Result<UserDataExport>>
{
public async Task<Result<UserDataExport>> Handle(ExportUserDataQuery request, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);

if (user is null)
return Result.Failure<UserDataExport>(ErrorMessages.UserNotFound, ErrorCodes.UserNotFound);

var habits = await habitRepository.FindAsync(h => h.UserId == request.UserId, cancellationToken);
var habitIds = habits.Select(h => h.Id).ToHashSet();
var habitLogs = await habitLogRepository.FindAsync(l => habitIds.Contains(l.HabitId), cancellationToken);
var logsByHabit = habitLogs
.GroupBy(l => l.HabitId)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Date).ToList());

var goals = await goalRepository.FindAsync(g => g.UserId == request.UserId, cancellationToken);
var goalIds = goals.Select(g => g.Id).ToHashSet();
var progressLogs = await goalProgressLogRepository.FindAsync(p => goalIds.Contains(p.GoalId), cancellationToken);
var progressByGoal = progressLogs
.GroupBy(p => p.GoalId)
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.CreatedAtUtc).ToList());

var tags = await tagRepository.FindAsync(t => t.UserId == request.UserId, cancellationToken);
var facts = await userFactRepository.FindAsync(f => f.UserId == request.UserId, cancellationToken);

var export = new UserDataExport(
DateTime.UtcNow,
new ExportedAccount(user.Name, user.Email, user.CreatedAtUtc, user.HasProAccess ? "pro" : "free"),
new ExportedSettings(
user.TimeZone,
user.Language,
user.WeekStartDay,
user.ThemePreference,
user.ColorScheme,
user.AiMemoryEnabled,
user.AiSummaryEnabled),
habits.Select(h => MapHabit(h, logsByHabit)).ToList(),
goals.Select(g => MapGoal(g, progressByGoal)).ToList(),
tags.Select(t => new ExportedTag(t.Id, t.Name, t.Color, t.CreatedAtUtc)).ToList(),
facts.Select(f => new ExportedUserFact(f.FactText, f.Category, f.ExtractedAtUtc)).ToList());

return Result.Success(export);
}

private static ExportedHabit MapHabit(Habit habit, IReadOnlyDictionary<Guid, List<HabitLog>> logsByHabit)
{
var logs = logsByHabit.TryGetValue(habit.Id, out var habitLogs)
? habitLogs.Select(l => new ExportedHabitLog(l.Date, l.Value, l.Note, l.CreatedAtUtc)).ToList()
: [];

return new ExportedHabit(
habit.Id,
habit.Title,
habit.Description,
habit.Emoji,
habit.IsBadHabit,
habit.IsGeneral,
habit.DueDate,
habit.EndDate,
habit.FrequencyUnit?.ToString(),
habit.FrequencyQuantity,
habit.Days.Select(d => d.ToString()).ToList(),
habit.ChecklistItems.ToList(),
habit.CreatedAtUtc,
logs);
}

private static ExportedGoal MapGoal(Goal goal, IReadOnlyDictionary<Guid, List<GoalProgressLog>> progressByGoal)
{
var progress = progressByGoal.TryGetValue(goal.Id, out var goalLogs)
? goalLogs.Select(p => new ExportedGoalProgressLog(p.Value, p.PreviousValue, p.Note, p.CreatedAtUtc)).ToList()
: [];

return new ExportedGoal(
goal.Id,
goal.Title,
goal.Description,
goal.TargetValue,
goal.CurrentValue,
goal.Unit,
goal.Status.ToString(),
goal.Type.ToString(),
goal.Deadline,
goal.CreatedAtUtc,
goal.CompletedAtUtc,
progress);
}
}
3 changes: 2 additions & 1 deletion src/Orbit.Infrastructure/Services/AgentCatalogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,8 @@ private static IReadOnlyList<AgentCapability> BuildCapabilities()
[
"AuthController.RequestDeletion",
"AuthController.ConfirmDeletion",
"ProfileController.ResetAccount"
"ProfileController.ResetAccount",
"ProfileController.ExportUserData"
]),

CreateCapability(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ public sealed partial class DataEncryptionMigrationService(
ILogger<DataEncryptionMigrationService> logger) : BackgroundService
{
private const int BatchSize = 50;
private const string MigrationFlag = "EncryptionMigrationComplete";

// Versioned so adding User to the backfill loop re-runs once in environments where the
// original "EncryptionMigrationComplete" flag was already set (existing User rows kept
// plaintext Google tokens until this pass).
private const string MigrationFlag = "EncryptionMigrationComplete_v2";

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Expand All @@ -40,6 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
success &= await MigrateUserFacts(stoppingToken);
success &= await MigrateEntities<Goal>("Goals", stoppingToken);
success &= await MigrateEntities<GoalProgressLog>("GoalProgressLogs", stoppingToken);
success &= await MigrateEntities<User>("Users", stoppingToken);

if (success)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Infrastructure.Configuration;
using Orbit.Infrastructure.Persistence;
using Orbit.Infrastructure.Services;

namespace Orbit.Infrastructure.Tests.Persistence;

Expand Down Expand Up @@ -47,6 +50,36 @@ public void Model_WithEncryptionService_ConfiguresEncryptedValueConverters()
context.Model.FindEntityType(typeof(GoalProgressLog))!.FindProperty(nameof(GoalProgressLog.Note))!.GetValueConverter().Should().NotBeNull();
}

[Fact]
public void Model_WithEncryptionService_WiresUserGoogleTokensToNullableEncryptingConverter()
{
var encryptionService = Substitute.For<IEncryptionService>();

using var context = CreateContext(encryptionService);
var user = context.Model.FindEntityType(typeof(User))!;

user.FindProperty(nameof(User.GoogleAccessToken))!.GetValueConverter()
.Should().BeOfType<NullableEncryptionValueConverter>();
user.FindProperty(nameof(User.GoogleRefreshToken))!.GetValueConverter()
.Should().BeOfType<NullableEncryptionValueConverter>();
}

[Fact]
public void NullableEncryptingConverter_WithRealEncryption_ProducesCiphertextAtRestAndDecryptsOnRead()
{
var encryptionService = new EncryptionService(
Options.Create(new EncryptionSettings { Key = "DdyUCjjdK326cB9lY00tyUvRDpCQcYJOJIpu21I1D8c=" }),
NullLogger<EncryptionService>.Instance);
var converter = new NullableEncryptionValueConverter(encryptionService);

const string plaintext = "ya29.PLAINTEXT-GOOGLE-TOKEN";
var atRest = (string?)converter.ConvertToProvider(plaintext);

atRest.Should().StartWith("enc:").And.NotBe(plaintext);
converter.ConvertFromProvider(atRest).Should().Be(plaintext);
converter.ConvertToProvider(null).Should().BeNull();
}

[Fact]
public void Model_WithPostgresProvider_UsesArrayAndJsonbColumnMetadata()
{
Expand Down
Loading
Loading