diff --git a/src/Orbit.Api/Controllers/ProfileController.cs b/src/Orbit.Api/Controllers/ProfileController.cs index 0e04349d..09e303ac 100644 --- a/src/Orbit.Api/Controllers/ProfileController.cs +++ b/src/Orbit.Api/Controllers/ProfileController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -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)] @@ -211,6 +217,23 @@ public async Task ResetAccount(CancellationToken cancellationToke : BadRequest(new { error = result.Error }); } + [HttpGet("export")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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); diff --git a/src/Orbit.Application/Profile/Models/UserDataExport.cs b/src/Orbit.Application/Profile/Models/UserDataExport.cs new file mode 100644 index 00000000..b63d4636 --- /dev/null +++ b/src/Orbit.Application/Profile/Models/UserDataExport.cs @@ -0,0 +1,85 @@ +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Profile.Models; + +/// +/// 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. +/// +public sealed record UserDataExport( + DateTime ExportedAtUtc, + ExportedAccount Account, + ExportedSettings Settings, + IReadOnlyList Habits, + IReadOnlyList Goals, + IReadOnlyList Tags, + IReadOnlyList 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 Days, + IReadOnlyList ChecklistItems, + DateTime CreatedAtUtc, + IReadOnlyList 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 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); diff --git a/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs b/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs new file mode 100644 index 00000000..61782c58 --- /dev/null +++ b/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs @@ -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>; + +public class ExportUserDataQueryHandler( + IGenericRepository userRepository, + IGenericRepository habitRepository, + IGenericRepository habitLogRepository, + IGenericRepository goalRepository, + IGenericRepository goalProgressLogRepository, + IGenericRepository tagRepository, + IGenericRepository userFactRepository) + : IRequestHandler> +{ + public async Task> Handle(ExportUserDataQuery request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.Failure(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> 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> 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); + } +} diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index c457d825..aea59527 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -1089,7 +1089,8 @@ private static IReadOnlyList BuildCapabilities() [ "AuthController.RequestDeletion", "AuthController.ConfirmDeletion", - "ProfileController.ResetAccount" + "ProfileController.ResetAccount", + "ProfileController.ExportUserData" ]), CreateCapability( diff --git a/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs b/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs index 609eb228..18885fb3 100644 --- a/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs +++ b/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs @@ -18,7 +18,11 @@ public sealed partial class DataEncryptionMigrationService( ILogger 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) { @@ -40,6 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) success &= await MigrateUserFacts(stoppingToken); success &= await MigrateEntities("Goals", stoppingToken); success &= await MigrateEntities("GoalProgressLogs", stoppingToken); + success &= await MigrateEntities("Users", stoppingToken); if (success) { diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs index 83c48198..8d880f5e 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs @@ -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; @@ -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(); + + using var context = CreateContext(encryptionService); + var user = context.Model.FindEntityType(typeof(User))!; + + user.FindProperty(nameof(User.GoogleAccessToken))!.GetValueConverter() + .Should().BeOfType(); + user.FindProperty(nameof(User.GoogleRefreshToken))!.GetValueConverter() + .Should().BeOfType(); + } + + [Fact] + public void NullableEncryptingConverter_WithRealEncryption_ProducesCiphertextAtRestAndDecryptsOnRead() + { + var encryptionService = new EncryptionService( + Options.Create(new EncryptionSettings { Key = "DdyUCjjdK326cB9lY00tyUvRDpCQcYJOJIpu21I1D8c=" }), + NullLogger.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() { diff --git a/tests/Orbit.IntegrationTests/ExportUserDataTests.cs b/tests/Orbit.IntegrationTests/ExportUserDataTests.cs new file mode 100644 index 00000000..f09853b4 --- /dev/null +++ b/tests/Orbit.IntegrationTests/ExportUserDataTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class ExportUserDataTests : IAsyncLifetime +{ + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"export-test-{Guid.NewGuid()}@integration.test"; + private const string TestCode = "999999"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public ExportUserDataTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Export_NoToken_ReturnsUnauthorized() + { + using var anonClient = _factory.CreateClient(); + var response = await anonClient.GetAsync("/api/profile/export"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Export_ReturnsDownloadableJsonAttachment() + { + var response = await _client.GetAsync("/api/profile/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + response.Content.Headers.ContentDisposition!.DispositionType.Should().Be("attachment"); + response.Content.Headers.ContentDisposition.FileName.Should().Contain("orbit-data-export"); + } + + [Fact] + public async Task Export_ContainsCreatedUserData_ScopedToRequestingUser() + { + var habitResponse = await _client.PostAsJsonAsync("/api/habits", new + { + title = "Export Meditation", + type = "Boolean", + frequencyUnit = "Day", + frequencyQuantity = 1 + }); + habitResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var habitId = await IntegrationTestHelpers.ReadCreatedIdAsync(habitResponse, JsonOptions); + + var logResponse = await _client.PostAsJsonAsync($"/api/habits/{habitId}/log", new { }); + logResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var goalResponse = await _client.PostAsJsonAsync("/api/goals", new + { + title = "Export Goal", + targetValue = 100m, + unit = "pages" + }); + goalResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var tagResponse = await _client.PostAsJsonAsync("/api/tags", new + { + name = "ExportTag", + color = "#FF0000" + }); + tagResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var export = await _client.GetFromJsonAsync("/api/profile/export", JsonOptions); + + export.Should().NotBeNull(); + export!.Account.Email.Should().Be(_email); + export.Habits.Should().ContainSingle(h => h.Title == "Export Meditation") + .Which.Logs.Should().ContainSingle(); + export.Goals.Should().ContainSingle(g => g.Title == "Export Goal"); + export.Tags.Should().ContainSingle(t => t.Name == "Exporttag"); + + using var otherClient = _factory.CreateClient(); + var otherEmail = $"export-other-{Guid.NewGuid()}@integration.test"; + IntegrationTestHelpers.RegisterTestAccount(otherEmail, TestCode); + await IntegrationTestHelpers.AuthenticateWithCodeAsync(otherClient, otherEmail, TestCode, JsonOptions); + + var otherExport = await otherClient.GetFromJsonAsync("/api/profile/export", JsonOptions); + + otherExport.Should().NotBeNull(); + otherExport!.Account.Email.Should().Be(otherEmail); + otherExport.Habits.Should().BeEmpty(); + otherExport.Goals.Should().BeEmpty(); + otherExport.Tags.Should().BeEmpty(); + } + + private sealed record ExportDto( + ExportAccountDto Account, + List Habits, + List Goals, + List Tags); + + private sealed record ExportAccountDto(string Email); + private sealed record ExportHabitDto(string Title, List Logs); + private sealed record ExportLogDto(decimal Value); + private sealed record ExportGoalDto(string Title); + private sealed record ExportTagDto(string Name); +} diff --git a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs index b3d147d2..7db538e7 100644 --- a/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs +++ b/tests/Orbit.IntegrationTests/IntegrationTestWebApplicationFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; namespace Orbit.IntegrationTests; @@ -6,6 +7,11 @@ public sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory { private static int _clientCounter; + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseSetting("Jwt:SecretKey", "OrbitIntegrationTestSecretKey-0123456789-ABCDEF"); + } + protected override void ConfigureClient(HttpClient client) { base.ConfigureClient(client);