diff --git a/src/Orbit.Domain/Common/DomainErrors.cs b/src/Orbit.Domain/Common/DomainErrors.cs index 3a2f0be7..7e64f10a 100644 --- a/src/Orbit.Domain/Common/DomainErrors.cs +++ b/src/Orbit.Domain/Common/DomainErrors.cs @@ -72,6 +72,7 @@ public static class DomainErrors public static readonly AppError OneTimeTaskHasEndDate = new("ONE_TIME_TASK_HAS_END_DATE", "One-time tasks cannot have an end date."); public static readonly AppError EndDateBeforeStartDate = new("END_DATE_BEFORE_START", "End date must be on or after the start date."); public static readonly AppError MaxScheduledReminders = new("MAX_SCHEDULED_REMINDERS", "A habit can have at most {0} scheduled reminders."); + public static readonly AppError MaxReminderTimes = new("MAX_REMINDER_TIMES", "A habit can have at most {0} reminder times."); public static readonly AppError DuplicateScheduledReminders = new("DUPLICATE_SCHEDULED_REMINDERS", "Scheduled reminders must not contain duplicate entries."); public static readonly AppError EmojiTooLong = new("EMOJI_TOO_LONG", "Habit emoji must not exceed {0} characters."); diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index f6bce784..1ec8ae60 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -129,6 +129,10 @@ public static Result Create(HabitCreateParams p) if (reminderValidation is not null) return Result.Failure(reminderValidation); + var reminderTimesValidation = HabitInvariants.ValidateReminderTimes(p.ReminderTimes); + if (reminderTimesValidation is not null) + return Result.Failure(reminderTimesValidation); + return Result.Success(new Habit { UserId = p.UserId, @@ -342,7 +346,11 @@ public Result Update(HabitUpdateParams p) if (emojiValidation is not null) return emojiValidation; - return HabitInvariants.ValidateScheduledReminders(p.ScheduledReminders); + var scheduledReminderValidation = HabitInvariants.ValidateScheduledReminders(p.ScheduledReminders); + if (scheduledReminderValidation is not null) + return scheduledReminderValidation; + + return HabitInvariants.ValidateReminderTimes(p.ReminderTimes); } private void ApplyRequiredUpdates(HabitUpdateParams p) diff --git a/src/Orbit.Domain/Entities/HabitInvariants.cs b/src/Orbit.Domain/Entities/HabitInvariants.cs index 102e4c1f..5b93edfc 100644 --- a/src/Orbit.Domain/Entities/HabitInvariants.cs +++ b/src/Orbit.Domain/Entities/HabitInvariants.cs @@ -74,6 +74,17 @@ internal static class HabitInvariants return null; } + public static AppError? ValidateReminderTimes(IReadOnlyList? reminderTimes) + { + if (reminderTimes is null) + return null; + + if (reminderTimes.Count > DomainConstants.MaxReminderTimes) + return DomainErrors.MaxReminderTimes.Format(DomainConstants.MaxReminderTimes); + + return null; + } + public static AppError? ValidateEmoji(string? emoji) { if (emoji is null) diff --git a/src/Orbit.Domain/Entities/PendingClarification.cs b/src/Orbit.Domain/Entities/PendingClarification.cs index 2a87bc9c..e1800441 100644 --- a/src/Orbit.Domain/Entities/PendingClarification.cs +++ b/src/Orbit.Domain/Entities/PendingClarification.cs @@ -18,8 +18,18 @@ public class PendingClarification : Entity public string QuickActionsJson { get; private set; } = "[]"; public DateTime CreatedAtUtc { get; private set; } public DateTime ExpiresAtUtc { get; private set; } - // EF Core unmaps read-only auto-properties (would DropColumn); keep the private setter so this column stays mapped. https://github.com/thomasluizon/orbit-api/pull/389 - public DateTime? ResolvedAtUtc { get; private set; } + +#pragma warning disable CS0649 // Written only at the SQL layer via ExecuteUpdate and read back via reflection on materialization; there is no C# writer, which is what removes the S1144 unused-private-setter finding. https://github.com/thomasluizon/orbit-api/pull/390 + private DateTime? _resolvedAtUtc; +#pragma warning restore CS0649 + + /// + /// UTC instant this clarification was resolved; null while still open. Exposed as a read-only + /// property over an explicitly-mapped backing field (see ConfigurePendingClarificationEntity) so + /// the column stays mapped without a private setter -- read-only auto-properties get dropped by + /// EF convention. The one-shot resolve flips it atomically via ExecuteUpdate. https://github.com/thomasluizon/orbit-api/pull/390 + /// + public DateTime? ResolvedAtUtc => _resolvedAtUtc; private PendingClarification() { } diff --git a/src/Orbit.Domain/Entities/Report.cs b/src/Orbit.Domain/Entities/Report.cs index d86e70d8..3a163233 100644 --- a/src/Orbit.Domain/Entities/Report.cs +++ b/src/Orbit.Domain/Entities/Report.cs @@ -12,8 +12,18 @@ public class Report : Entity public Guid? CheerId { get; private set; } public ReportStatus Status { get; private set; } public DateTime CreatedAtUtc { get; private set; } - // EF Core unmaps read-only auto-properties (would DropColumn); keep the private setter so this column stays mapped. https://github.com/thomasluizon/orbit-api/pull/389 - public DateTime? ReviewedAtUtc { get; private set; } + +#pragma warning disable CS0649 // EF writes this backing field via reflection on materialization; there is no C# writer, which is what removes the S1144 unused-private-setter finding. https://github.com/thomasluizon/orbit-api/pull/390 + private DateTime? _reviewedAtUtc; +#pragma warning restore CS0649 + + /// + /// UTC instant an admin reviewed this report; null until reviewed. Exposed as a read-only + /// property over an explicitly-mapped backing field (see ConfigureReportEntity) so the column + /// stays mapped without a private setter -- read-only auto-properties get dropped by EF + /// convention. https://github.com/thomasluizon/orbit-api/pull/390 + /// + public DateTime? ReviewedAtUtc => _reviewedAtUtc; private Report() { } diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 603e60e7..7a1508bb 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -402,6 +402,7 @@ private static void ConfigurePendingClarificationEntity(ModelBuilder modelBuilde entity.Property(item => item.Question).HasMaxLength(500); entity.Property(item => item.PartialArgumentsJson).HasColumnType(JsonbColumnType); entity.Property(item => item.QuickActionsJson).HasColumnType(JsonbColumnType); + entity.Property(item => item.ResolvedAtUtc).HasField("_resolvedAtUtc"); entity.HasOne().WithMany().HasForeignKey(item => item.UserId).OnDelete(DeleteBehavior.Cascade); }); } @@ -565,6 +566,7 @@ private static void ConfigureReportEntity(ModelBuilder modelBuilder) entity.Property(r => r.Reason).HasConversion().HasMaxLength(32); entity.Property(r => r.Status).HasConversion().HasMaxLength(32); entity.Property(r => r.Details).HasMaxLength(DomainConstants.MaxReportDetailsLength); + entity.Property(r => r.ReviewedAtUtc).HasField("_reviewedAtUtc"); entity.HasOne().WithMany().HasForeignKey(r => r.ReporterId).OnDelete(DeleteBehavior.Restrict); entity.HasOne().WithMany().HasForeignKey(r => r.ReportedUserId).OnDelete(DeleteBehavior.Restrict); entity.HasOne().WithMany().HasForeignKey(r => r.CheerId).OnDelete(DeleteBehavior.SetNull); diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index 0bdf7c2f..23b4cda3 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.ValueObjects; @@ -921,6 +922,56 @@ public void Update_ScheduledReminders_Null_KeepsExisting() habit.ScheduledReminders.Should().HaveCount(1); } + [Fact] + public void Create_ReminderTimes_AtLimit_ReturnsSuccess() + { + var reminderTimes = Enumerable.Range(0, DomainConstants.MaxReminderTimes).ToList(); + + var result = Habit.Create(new HabitCreateParams(ValidUserId, "Exercise", FrequencyUnit.Day, 1, DueDate: DateOnly.FromDateTime(DateTime.UtcNow), + ReminderTimes: reminderTimes)); + + result.IsSuccess.Should().BeTrue(); + result.Value.ReminderTimes.Should().HaveCount(DomainConstants.MaxReminderTimes); + } + + [Fact] + public void Create_ReminderTimes_OverLimit_ReturnsFailure() + { + var reminderTimes = Enumerable.Range(0, DomainConstants.MaxReminderTimes + 1).ToList(); + + var result = Habit.Create(new HabitCreateParams(ValidUserId, "Exercise", FrequencyUnit.Day, 1, DueDate: DateOnly.FromDateTime(DateTime.UtcNow), + ReminderTimes: reminderTimes)); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain($"at most {DomainConstants.MaxReminderTimes} reminder times"); + } + + [Fact] + public void Update_ReminderTimes_AtLimit_ReturnsSuccess() + { + var habit = CreateValidHabit(); + var reminderTimes = Enumerable.Range(0, DomainConstants.MaxReminderTimes).ToList(); + + var result = habit.Update(new HabitUpdateParams("Exercise", null, FrequencyUnit.Day, 1, null, false, null, + ReminderTimes: reminderTimes)); + + result.IsSuccess.Should().BeTrue(); + habit.ReminderTimes.Should().HaveCount(DomainConstants.MaxReminderTimes); + } + + [Fact] + public void Update_ReminderTimes_OverLimit_ReturnsFailure() + { + var habit = CreateValidHabit(); + var reminderTimes = Enumerable.Range(0, DomainConstants.MaxReminderTimes + 1).ToList(); + + var result = habit.Update(new HabitUpdateParams("Exercise", null, FrequencyUnit.Day, 1, null, false, null, + ReminderTimes: reminderTimes)); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain($"at most {DomainConstants.MaxReminderTimes} reminder times"); + } + [Fact] public void Update_AllOptionalFields_Applied() {