diff --git a/src/Orbit.Api/Controllers/ChallengesController.cs b/src/Orbit.Api/Controllers/ChallengesController.cs new file mode 100644 index 00000000..060c3335 --- /dev/null +++ b/src/Orbit.Api/Controllers/ChallengesController.cs @@ -0,0 +1,106 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Orbit.Api.Extensions; +using Orbit.Api.RateLimiting; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Challenges.Queries; +using Orbit.Domain.Enums; + +namespace Orbit.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/challenges")] +public partial class ChallengesController(IMediator mediator, ILogger logger) : ControllerBase +{ + public record CreateChallengeBody( + ChallengeType Type, + string Title, + string? Description, + int? TargetCount, + DateOnly PeriodStartUtc, + DateOnly? PeriodEndUtc, + IReadOnlyList? LinkedHabitIds, + IReadOnlyList? InvitedFriendUserIds); + + public record JoinChallengeBody(string Code, IReadOnlyList? LinkedHabitIds); + + [HttpPost] + [DistributedRateLimit("challenges")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create( + [FromBody] CreateChallengeBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var command = new CreateChallengeCommand( + userId, + body.Type, + body.Title, + body.Description, + body.TargetCount, + body.PeriodStartUtc, + body.PeriodEndUtc, + body.LinkedHabitIds ?? [], + body.InvitedFriendUserIds ?? []); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogChallengeCreated(logger, userId); + + return result.ToPayGateAwareResult(id => Ok(new { id })); + } + + [HttpPost("join")] + [DistributedRateLimit("challenges")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Join( + [FromBody] JoinChallengeBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var command = new JoinChallengeCommand(userId, body.Code, body.LinkedHabitIds ?? []); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogChallengeJoined(logger, userId); + + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpDelete("{challengeId:guid}/leave")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Leave(Guid challengeId, CancellationToken cancellationToken) + { + var command = new LeaveChallengeCommand(HttpContext.GetUserId(), challengeId); + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpGet("{challengeId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDetail(Guid challengeId, CancellationToken cancellationToken) + { + var query = new GetChallengeDetailQuery(HttpContext.GetUserId(), challengeId); + var result = await mediator.Send(query, cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Challenge created by user {UserId}")] + private static partial void LogChallengeCreated(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Challenge joined by user {UserId}")] + private static partial void LogChallengeJoined(ILogger logger, Guid userId); +} diff --git a/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs index 7a6f732d..cb039a31 100644 --- a/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs +++ b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs @@ -42,10 +42,17 @@ public static class ResultActionResultExtensions [ErrorCodes.SocialDisabled] = StatusCodes.Status403Forbidden, [ErrorCodes.Blocked] = StatusCodes.Status403Forbidden, + [ErrorCodes.NotChallengeParticipant] = StatusCodes.Status403Forbidden, [ErrorCodes.FriendRequestNotFound] = StatusCodes.Status404NotFound, + [ErrorCodes.ChallengeNotFound] = StatusCodes.Status404NotFound, + [ErrorCodes.InvalidJoinCode] = StatusCodes.Status404NotFound, [ErrorCodes.PairNotFound] = StatusCodes.Status404NotFound, + [ErrorCodes.ChallengeFull] = StatusCodes.Status409Conflict, + [ErrorCodes.AlreadyJoinedChallenge] = StatusCodes.Status409Conflict, + [ErrorCodes.ChallengeClosed] = StatusCodes.Status409Conflict, + [ErrorCodes.InternalServerError] = StatusCodes.Status500InternalServerError, }; diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs index 3b343455..c734e494 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -123,6 +123,7 @@ private static void AddHabitCommandDependencies(WebApplicationBuilder builder) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService())); builder.Services.AddScoped(sp => new Orbit.Application.Habits.Commands.BulkLogServices( diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index c1a3af47..f31c5692 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -83,6 +83,15 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + new Orbit.Application.Challenges.Services.ChallengeProgressRepositories( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); builder.Services.AddScoped(sp => new Orbit.Application.Social.Commands.SendCheerRepositories( sp.GetRequiredService>(), diff --git a/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs b/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs new file mode 100644 index 00000000..98913f4d --- /dev/null +++ b/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Challenges.Commands; + +public record CreateChallengeCommand( + Guid UserId, + ChallengeType Type, + string Title, + string? Description, + int? TargetCount, + DateOnly PeriodStartUtc, + DateOnly? PeriodEndUtc, + IReadOnlyList LinkedHabitIds, + IReadOnlyList InvitedFriendUserIds) : IRequest>; + +public class CreateChallengeCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository challengeRepository, + IGenericRepository habitRepository, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + private const string JoinCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private const int JoinCodeLength = 8; + + public async Task> Handle(CreateChallengeCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var ownedHabits = await VerifyOwnedHabitsAsync(request.UserId, request.LinkedHabitIds, cancellationToken); + if (ownedHabits.IsFailure) + return ownedHabits.PropagateError(); + + var invitedFriendIds = request.InvitedFriendUserIds + .Where(id => id != request.UserId) + .Distinct() + .ToList(); + + if (1 + invitedFriendIds.Count > AppConstants.MaxChallengeParticipants) + return Result.Failure(ErrorMessages.ChallengeFull.Format(AppConstants.MaxChallengeParticipants)); + + var friendCheck = await VerifyInvitedFriendsAsync(request.UserId, invitedFriendIds, cancellationToken); + if (friendCheck.IsFailure) + return friendCheck.PropagateError(); + + var joinCode = await GenerateUniqueJoinCodeAsync(cancellationToken); + + var createResult = Challenge.Create(new CreateChallengeParams( + request.UserId, + request.Type, + request.Title, + request.Description, + request.TargetCount, + request.PeriodStartUtc, + request.PeriodEndUtc, + joinCode)); + if (createResult.IsFailure) + return createResult.PropagateError(); + + var challenge = createResult.Value; + challenge.AddParticipant(request.UserId, request.LinkedHabitIds); + foreach (var friendId in invitedFriendIds) + challenge.AddParticipant(friendId, []); + + await challengeRepository.AddAsync(challenge, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(challenge.Id); + } + + private async Task VerifyOwnedHabitsAsync(Guid userId, IReadOnlyList habitIds, CancellationToken cancellationToken) + { + var distinctIds = habitIds.Distinct().ToList(); + var owned = await habitRepository.CountAsync( + h => h.UserId == userId && distinctIds.Contains(h.Id), + cancellationToken); + + return owned == distinctIds.Count + ? Result.Success() + : Result.Failure(ErrorMessages.HabitNotFound); + } + + private async Task VerifyInvitedFriendsAsync(Guid userId, IReadOnlyList invitedFriendIds, CancellationToken cancellationToken) + { + foreach (var friendId in invitedFriendIds) + { + if (!await friendGraphService.AreAcceptedFriendsAsync(userId, friendId, cancellationToken)) + return Result.Failure(ErrorMessages.NotFriends); + + if (await friendGraphService.IsBlockedBetweenAsync(userId, friendId, cancellationToken)) + return Result.Failure(ErrorMessages.NotFriends); + } + + return Result.Success(); + } + + private async Task GenerateUniqueJoinCodeAsync(CancellationToken cancellationToken) + { + while (true) + { + var code = GenerateJoinCode(); + var exists = await challengeRepository.AnyAsync(c => c.JoinCode == code, cancellationToken); + if (!exists) + return code; + } + } + + private static string GenerateJoinCode() + { + return string.Create(JoinCodeLength, JoinCodeAlphabet, static (span, alphabet) => + { + Span bytes = stackalloc byte[span.Length]; + RandomNumberGenerator.Fill(bytes); + for (var i = 0; i < span.Length; i++) + span[i] = alphabet[bytes[i] % alphabet.Length]; + }); + } +} diff --git a/src/Orbit.Application/Challenges/Commands/JoinChallengeCommand.cs b/src/Orbit.Application/Challenges/Commands/JoinChallengeCommand.cs new file mode 100644 index 00000000..59019d53 --- /dev/null +++ b/src/Orbit.Application/Challenges/Commands/JoinChallengeCommand.cs @@ -0,0 +1,106 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; +using Orbit.Application.Gamification; +using Orbit.Application.Gamification.Models; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Challenges.Commands; + +public record JoinChallengeCommand( + Guid UserId, + string Code, + IReadOnlyList LinkedHabitIds) : IRequest; + +public class JoinChallengeCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository challengeRepository, + IGenericRepository habitRepository, + IGenericRepository achievementRepository, + IXpAwarder xpAwarder, + IUnitOfWork unitOfWork) : IRequestHandler +{ + private const string TeamPlayerAchievementId = "team_player"; + + public async Task Handle(JoinChallengeCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + var user = access.Value; + + var ownedHabits = await VerifyOwnedHabitsAsync(request.UserId, request.LinkedHabitIds, cancellationToken); + if (ownedHabits.IsFailure) + return ownedHabits; + + var normalizedCode = request.Code.Trim().ToUpperInvariant(); + var challenge = await challengeRepository.FindOneTrackedAsync( + c => c.JoinCode == normalizedCode, + q => q.Include(c => c.Participants).ThenInclude(p => p.LinkedHabits), + cancellationToken); + + if (challenge is null) + return Result.Failure(ErrorMessages.InvalidJoinCode); + + if (challenge.Status != ChallengeStatus.Active) + return Result.Failure(ErrorMessages.ChallengeClosed); + + if (challenge.Participants.Any(p => p.UserId == request.UserId && p.IsActive)) + return Result.Failure(ErrorMessages.AlreadyJoinedChallenge); + + if (await friendGraphService.IsBlockedBetweenAsync(request.UserId, challenge.CreatorId, cancellationToken)) + return Result.Failure(ErrorMessages.InvalidJoinCode); + + if (challenge.GetActiveParticipants().Count >= AppConstants.MaxChallengeParticipants) + return Result.Failure(ErrorMessages.ChallengeFull.Format(AppConstants.MaxChallengeParticipants)); + + challenge.AddParticipant(request.UserId, request.LinkedHabitIds); + + await AwardTeamPlayerAsync(user, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } + + private async Task VerifyOwnedHabitsAsync(Guid userId, IReadOnlyList habitIds, CancellationToken cancellationToken) + { + var distinctIds = habitIds.Distinct().ToList(); + var owned = await habitRepository.CountAsync( + h => h.UserId == userId && distinctIds.Contains(h.Id), + cancellationToken); + + return owned == distinctIds.Count + ? Result.Success() + : Result.Failure(ErrorMessages.HabitNotFound); + } + + private async Task AwardTeamPlayerAsync(User user, CancellationToken cancellationToken) + { + var alreadyEarned = await achievementRepository.AnyAsync( + a => a.UserId == user.Id && a.AchievementId == TeamPlayerAchievementId, + cancellationToken); + if (alreadyEarned) + return; + + var earned = new HashSet(); + var newAchievements = new List<(UserAchievement Entity, AchievementDefinition Definition)>(); + AchievementChecks.TryGrant(TeamPlayerAchievementId, user, earned, newAchievements); + + if (newAchievements.Count == 0) + return; + + await achievementRepository.AddAsync(newAchievements[0].Entity, cancellationToken); + await xpAwarder.AwardAsync( + user, newAchievements[0].Definition.XpReward, XpAwardSource.Achievement, + newAchievements[0].Entity.Id, awardedAtUtc: DateTime.UtcNow, cancellationToken); + + var newLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); + if (newLevel.Level != user.Level) + user.SetLevel(newLevel.Level); + } +} diff --git a/src/Orbit.Application/Challenges/Commands/LeaveChallengeCommand.cs b/src/Orbit.Application/Challenges/Commands/LeaveChallengeCommand.cs new file mode 100644 index 00000000..32c53b94 --- /dev/null +++ b/src/Orbit.Application/Challenges/Commands/LeaveChallengeCommand.cs @@ -0,0 +1,33 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Challenges.Commands; + +public record LeaveChallengeCommand(Guid UserId, Guid ChallengeId) : IRequest; + +public class LeaveChallengeCommandHandler( + IGenericRepository challengeRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(LeaveChallengeCommand request, CancellationToken cancellationToken) + { + var challenge = await challengeRepository.FindOneTrackedAsync( + c => c.Id == request.ChallengeId, + q => q.Include(c => c.Participants), + cancellationToken); + + if (challenge is null) + return Result.Failure(ErrorMessages.ChallengeNotFound); + + if (!challenge.TryLeave(request.UserId)) + return Result.Failure(ErrorMessages.NotChallengeParticipant); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Challenges/Queries/GetChallengeDetailQuery.cs b/src/Orbit.Application/Challenges/Queries/GetChallengeDetailQuery.cs new file mode 100644 index 00000000..e23474bb --- /dev/null +++ b/src/Orbit.Application/Challenges/Queries/GetChallengeDetailQuery.cs @@ -0,0 +1,136 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Challenges.Services; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Challenges.Queries; + +public record ChallengeParticipantResponse( + Guid UserId, + string Name, + DateTime JoinedAtUtc); + +public record ChallengeDetailResponse( + Guid Id, + Guid CreatorId, + ChallengeType Type, + string Title, + string? Description, + ChallengeStatus Status, + int? TargetCount, + int CurrentProgress, + bool IsComplete, + DateOnly PeriodStartUtc, + DateOnly? PeriodEndUtc, + string JoinCode, + DateTime? CompletedAtUtc, + DateTime CreatedAtUtc, + IReadOnlyList Participants, + IReadOnlyList YourLinkedHabitIds); + +public record GetChallengeDetailQuery(Guid UserId, Guid ChallengeId) : IRequest>; + +public class GetChallengeDetailQueryHandler( + IGenericRepository challengeRepository, + IGenericRepository habitLogRepository, + IGenericRepository userRepository, + IUserDateService userDateService) : IRequestHandler> +{ + public async Task> Handle(GetChallengeDetailQuery request, CancellationToken cancellationToken) + { + var challenges = await challengeRepository.FindAsync( + c => c.Id == request.ChallengeId, + q => q.Include(c => c.Participants).ThenInclude(p => p.LinkedHabits), + cancellationToken); + + var challenge = challenges.Count > 0 ? challenges[0] : null; + if (challenge is null || !challenge.Participants.Any(p => p.UserId == request.UserId)) + return Result.Failure(ErrorMessages.ChallengeNotFound); + + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + var windowEnd = challenge.PeriodEndUtc ?? today; + var lastDay = windowEnd < today ? windowEnd : today; + + var activeParticipants = challenge.GetActiveParticipants(); + var contributingHabitSets = activeParticipants + .Select(p => (IReadOnlyCollection)p.LinkedHabits.Select(h => h.HabitId).ToList()) + .Where(set => set.Count > 0) + .ToList(); + var contributingHabitIds = contributingHabitSets.SelectMany(set => set).Distinct().ToList(); + + var logs = contributingHabitIds.Count > 0 + ? await habitLogRepository.FindAsync( + l => contributingHabitIds.Contains(l.HabitId) + && l.Date >= challenge.PeriodStartUtc + && l.Date <= lastDay, + cancellationToken) + : []; + + var (currentProgress, isComplete) = ComputeProgress(challenge, contributingHabitIds, contributingHabitSets, logs, lastDay, today); + + var participants = await BuildParticipantRosterAsync(activeParticipants, cancellationToken); + var yourLinkedHabitIds = challenge.Participants + .Where(p => p.UserId == request.UserId && p.IsActive) + .SelectMany(p => p.LinkedHabits.Select(h => h.HabitId)) + .ToList(); + + return Result.Success(new ChallengeDetailResponse( + challenge.Id, + challenge.CreatorId, + challenge.Type, + challenge.Title, + challenge.Description, + challenge.Status, + challenge.TargetCount, + currentProgress, + isComplete, + challenge.PeriodStartUtc, + challenge.PeriodEndUtc, + challenge.JoinCode, + challenge.CompletedAtUtc, + challenge.CreatedAtUtc, + participants, + yourLinkedHabitIds)); + } + + private static (int CurrentProgress, bool IsComplete) ComputeProgress( + Challenge challenge, + IReadOnlyCollection contributingHabitIds, + IReadOnlyList> contributingHabitSets, + IReadOnlyCollection logs, + DateOnly lastDay, + DateOnly today) + { + if (challenge.Type == ChallengeType.CoopGoal) + { + var count = ChallengeProgressCalculator.CalculateCoopGoalProgress( + contributingHabitIds, logs, challenge.PeriodStartUtc, lastDay); + var reachedTarget = challenge.TargetCount.HasValue && count >= challenge.TargetCount.Value; + var windowEnded = challenge.PeriodEndUtc.HasValue && today > challenge.PeriodEndUtc.Value; + return (count, challenge.Status == ChallengeStatus.Completed || reachedTarget || windowEnded); + } + + var streak = ChallengeProgressCalculator.CalculateSharedStreak( + contributingHabitSets, logs, challenge.PeriodStartUtc, lastDay, today); + return (streak, false); + } + + private async Task> BuildParticipantRosterAsync( + IReadOnlyList activeParticipants, CancellationToken cancellationToken) + { + var userIds = activeParticipants.Select(p => p.UserId).Distinct().ToList(); + var users = await userRepository.FindAsync(u => userIds.Contains(u.Id), cancellationToken); + var namesByUserId = users.ToDictionary(u => u.Id, u => u.Name); + + return activeParticipants + .Select(p => new ChallengeParticipantResponse( + p.UserId, + namesByUserId.TryGetValue(p.UserId, out var name) ? name : string.Empty, + p.JoinedAtUtc)) + .ToList(); + } +} diff --git a/src/Orbit.Application/Challenges/Services/ChallengeProgressCalculator.cs b/src/Orbit.Application/Challenges/Services/ChallengeProgressCalculator.cs new file mode 100644 index 00000000..d7369737 --- /dev/null +++ b/src/Orbit.Application/Challenges/Services/ChallengeProgressCalculator.cs @@ -0,0 +1,63 @@ +using Orbit.Domain.Entities; + +namespace Orbit.Application.Challenges.Services; + +/// +/// Pure read-side shared-progress math for challenges, mirroring HabitMetricsCalculator: a log +/// counts only when its Value is greater than 0 (0 is a skip). CoopGoal progress is the count of +/// qualifying logs across all contributing habits in the window; StreakTogether is the run of consecutive +/// days on which every contributing participant logged, lenient about an unfinished today, reset by any +/// single miss. A contributing participant is an active participant who has linked at least one habit. +/// +public static class ChallengeProgressCalculator +{ + public static int CalculateCoopGoalProgress( + IReadOnlyCollection contributingHabitIds, + IReadOnlyCollection logs, + DateOnly periodStart, + DateOnly periodEnd) + { + if (contributingHabitIds.Count == 0) + return 0; + + return logs.Count(log => + log.Value > 0 + && contributingHabitIds.Contains(log.HabitId) + && log.Date >= periodStart + && log.Date <= periodEnd); + } + + public static int CalculateSharedStreak( + IReadOnlyList> contributingParticipantHabitSets, + IReadOnlyCollection logs, + DateOnly periodStart, + DateOnly lastDay, + DateOnly today) + { + if (contributingParticipantHabitSets.Count == 0) + return 0; + + var loggedDatesByParticipant = contributingParticipantHabitSets + .Select(habitIds => logs + .Where(log => log.Value > 0 && habitIds.Contains(log.HabitId)) + .Select(log => log.Date) + .ToHashSet()) + .ToList(); + + var streak = 0; + for (var day = lastDay; day >= periodStart; day = day.AddDays(-1)) + { + var everyoneLogged = loggedDatesByParticipant.All(dates => dates.Contains(day)); + + if (day == today && !everyoneLogged && streak == 0) + continue; + + if (!everyoneLogged) + break; + + streak++; + } + + return streak; + } +} diff --git a/src/Orbit.Application/Challenges/Services/ChallengeProgressService.cs b/src/Orbit.Application/Challenges/Services/ChallengeProgressService.cs new file mode 100644 index 00000000..c9cf203f --- /dev/null +++ b/src/Orbit.Application/Challenges/Services/ChallengeProgressService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Gamification; +using Orbit.Application.Gamification.Models; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Challenges.Services; + +/// Groups the repositories the challenge progress seam touches to keep the constructor small. +public record ChallengeProgressRepositories( + IGenericRepository Challenges, + IGenericRepository Participants, + IGenericRepository ParticipantHabits, + IGenericRepository HabitLogs, + IGenericRepository Users, + IGenericRepository Achievements); + +public class ChallengeProgressService( + ChallengeProgressRepositories repositories, + IXpAwarder xpAwarder, + IUnitOfWork unitOfWork, + IUserDateService userDateService) : IChallengeProgressService +{ + private const string MissionAccomplishedAchievementId = "mission_accomplished"; + + public async Task EvaluateOnHabitLoggedAsync(Guid userId, Guid habitId, CancellationToken cancellationToken = default) + { + var links = await repositories.ParticipantHabits.FindAsync(cph => cph.HabitId == habitId, cancellationToken); + if (links.Count == 0) + return; + + var participantIds = links.Select(link => link.ChallengeParticipantId).ToList(); + var participants = await repositories.Participants.FindAsync( + p => participantIds.Contains(p.Id) && p.UserId == userId && p.LeftAtUtc == null, + cancellationToken); + if (participants.Count == 0) + return; + + var challengeIds = participants.Select(p => p.ChallengeId).Distinct().ToList(); + var challenges = await repositories.Challenges.FindTrackedAsync( + c => challengeIds.Contains(c.Id) && c.Status == ChallengeStatus.Active && c.Type == ChallengeType.CoopGoal, + q => q.Include(c => c.Participants).ThenInclude(p => p.LinkedHabits), + cancellationToken); + if (challenges.Count == 0) + return; + + var today = await userDateService.GetUserTodayAsync(userId, cancellationToken); + + var anyCompleted = false; + foreach (var challenge in challenges) + { + if (await TryCompleteCoopGoalAsync(challenge, today, cancellationToken)) + anyCompleted = true; + } + + if (anyCompleted) + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + private async Task TryCompleteCoopGoalAsync(Challenge challenge, DateOnly today, CancellationToken cancellationToken) + { + if (!challenge.TargetCount.HasValue) + return false; + + var contributingHabitIds = challenge.GetActiveParticipants() + .SelectMany(p => p.LinkedHabits.Select(h => h.HabitId)) + .Distinct() + .ToList(); + if (contributingHabitIds.Count == 0) + return false; + + var windowEnd = challenge.PeriodEndUtc ?? today; + var lastDay = windowEnd < today ? windowEnd : today; + + var logs = await repositories.HabitLogs.FindAsync( + l => contributingHabitIds.Contains(l.HabitId) + && l.Date >= challenge.PeriodStartUtc + && l.Date <= lastDay, + cancellationToken); + + var progress = ChallengeProgressCalculator.CalculateCoopGoalProgress( + contributingHabitIds, logs, challenge.PeriodStartUtc, lastDay); + + if (progress < challenge.TargetCount.Value) + return false; + + if (!challenge.MarkCompleted()) + return false; + + await AwardMissionAccomplishedAsync(challenge, cancellationToken); + return true; + } + + private async Task AwardMissionAccomplishedAsync(Challenge challenge, CancellationToken cancellationToken) + { + var participantUserIds = challenge.GetActiveParticipants().Select(p => p.UserId).Distinct().ToList(); + var users = await repositories.Users.FindTrackedAsync(u => participantUserIds.Contains(u.Id), cancellationToken); + var alreadyEarned = await repositories.Achievements.FindAsync( + a => participantUserIds.Contains(a.UserId) && a.AchievementId == MissionAccomplishedAchievementId, + cancellationToken); + var earnedUserIds = alreadyEarned.Select(a => a.UserId).ToHashSet(); + + foreach (var user in users) + { + if (earnedUserIds.Contains(user.Id)) + continue; + + var earned = new HashSet(); + var newAchievements = new List<(UserAchievement Entity, AchievementDefinition Definition)>(); + AchievementChecks.TryGrant(MissionAccomplishedAchievementId, user, earned, newAchievements); + + if (newAchievements.Count == 0) + continue; + + await repositories.Achievements.AddAsync(newAchievements[0].Entity, cancellationToken); + await xpAwarder.AwardAsync( + user, newAchievements[0].Definition.XpReward, XpAwardSource.Achievement, + newAchievements[0].Entity.Id, awardedAtUtc: DateTime.UtcNow, cancellationToken); + + var newLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); + if (newLevel.Level != user.Level) + user.SetLevel(newLevel.Level); + } + } +} diff --git a/src/Orbit.Application/Challenges/Services/IChallengeProgressService.cs b/src/Orbit.Application/Challenges/Services/IChallengeProgressService.cs new file mode 100644 index 00000000..d3ad06f3 --- /dev/null +++ b/src/Orbit.Application/Challenges/Services/IChallengeProgressService.cs @@ -0,0 +1,12 @@ +namespace Orbit.Application.Challenges.Services; + +/// +/// Post-log seam for cooperative challenges. Invoked after a habit is logged to recompute the shared +/// progress of the user's active CoopGoal challenges that include the habit, completing any whose target +/// the new log just reached and awarding Mission Accomplished to their participants. Streak challenges +/// have no completion event, so they are evaluated read-side only. +/// +public interface IChallengeProgressService +{ + Task EvaluateOnHabitLoggedAsync(Guid userId, Guid habitId, CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs new file mode 100644 index 00000000..33a43776 --- /dev/null +++ b/src/Orbit.Application/Challenges/Validators/CreateChallengeCommandValidator.cs @@ -0,0 +1,51 @@ +using FluentValidation; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Challenges.Validators; + +public class CreateChallengeCommandValidator : AbstractValidator +{ + public CreateChallengeCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.Title).NotEmpty().MaximumLength(AppConstants.MaxChallengeTitleLength); + + RuleFor(x => x.Description).MaximumLength(AppConstants.MaxChallengeDescriptionLength); + + RuleFor(x => x.TargetCount) + .NotNull().GreaterThan(0) + .When(x => x.Type == ChallengeType.CoopGoal) + .WithMessage("A goal challenge requires a target count greater than 0."); + + RuleFor(x => x.TargetCount) + .Null() + .When(x => x.Type == ChallengeType.StreakTogether) + .WithMessage("A streak challenge cannot have a target count."); + + RuleFor(x => x.PeriodEndUtc) + .NotNull() + .When(x => x.Type == ChallengeType.CoopGoal) + .WithMessage("A goal challenge requires an end date."); + + RuleFor(x => x.PeriodEndUtc) + .GreaterThanOrEqualTo(x => x.PeriodStartUtc) + .When(x => x.PeriodEndUtc.HasValue); + + RuleFor(x => x.LinkedHabitIds) + .NotEmpty() + .WithMessage("Link at least one of your habits to the challenge."); + + RuleFor(x => x.LinkedHabitIds) + .Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant) + .WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits."); + + RuleForEach(x => x.LinkedHabitIds).NotEmpty(); + + RuleFor(x => x.InvitedFriendUserIds) + .Must(ids => ids.Count <= AppConstants.MaxChallengeParticipants - 1) + .WithMessage($"You can invite at most {AppConstants.MaxChallengeParticipants - 1} friends."); + } +} diff --git a/src/Orbit.Application/Challenges/Validators/GetChallengeDetailQueryValidator.cs b/src/Orbit.Application/Challenges/Validators/GetChallengeDetailQueryValidator.cs new file mode 100644 index 00000000..09d62871 --- /dev/null +++ b/src/Orbit.Application/Challenges/Validators/GetChallengeDetailQueryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Challenges.Queries; + +namespace Orbit.Application.Challenges.Validators; + +public class GetChallengeDetailQueryValidator : AbstractValidator +{ + public GetChallengeDetailQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.ChallengeId).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs new file mode 100644 index 00000000..a3e2d7be --- /dev/null +++ b/src/Orbit.Application/Challenges/Validators/JoinChallengeCommandValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Common; + +namespace Orbit.Application.Challenges.Validators; + +public class JoinChallengeCommandValidator : AbstractValidator +{ + private const int MaxJoinCodeLength = 16; + + public JoinChallengeCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.Code).NotEmpty().MaximumLength(MaxJoinCodeLength); + + RuleFor(x => x.LinkedHabitIds) + .NotEmpty() + .WithMessage("Link at least one of your habits to the challenge."); + + RuleFor(x => x.LinkedHabitIds) + .Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant) + .WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits."); + + RuleForEach(x => x.LinkedHabitIds).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Challenges/Validators/LeaveChallengeCommandValidator.cs b/src/Orbit.Application/Challenges/Validators/LeaveChallengeCommandValidator.cs new file mode 100644 index 00000000..5658cc68 --- /dev/null +++ b/src/Orbit.Application/Challenges/Validators/LeaveChallengeCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Challenges.Commands; + +namespace Orbit.Application.Challenges.Validators; + +public class LeaveChallengeCommandValidator : AbstractValidator +{ + public LeaveChallengeCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.ChallengeId).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index a615174d..98b9a421 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -72,6 +72,10 @@ public static class AppConstants public const int MaxSetHandlePerDay = 5; public const int FriendFeedPageSize = 30; public const int MaxFriendFeedPageSize = 50; + public const int MaxChallengeParticipants = DomainConstants.MaxChallengeParticipants; + public const int MaxChallengeTitleLength = 200; + public const int MaxChallengeDescriptionLength = 2000; + public const int MaxHabitsPerChallengeParticipant = 20; public static readonly int[] StreakMilestoneTiers = [7, 14, 30, 90, 100, 365]; public static readonly string[] SupportedLanguages = ["en", "pt-BR"]; } diff --git a/src/Orbit.Application/Common/ErrorCodes.cs b/src/Orbit.Application/Common/ErrorCodes.cs index 0bbdb10b..b68c79d0 100644 --- a/src/Orbit.Application/Common/ErrorCodes.cs +++ b/src/Orbit.Application/Common/ErrorCodes.cs @@ -122,6 +122,12 @@ public static class ErrorCodes public const string ContentRejected = "CONTENT_REJECTED"; public const string FriendRequestNotFound = "FRIEND_REQUEST_NOT_FOUND"; public const string CheerNotFound = "CHEER_NOT_FOUND"; + public const string ChallengeNotFound = "CHALLENGE_NOT_FOUND"; + public const string ChallengeFull = "CHALLENGE_FULL"; + public const string AlreadyJoinedChallenge = "ALREADY_JOINED_CHALLENGE"; + public const string NotChallengeParticipant = "NOT_CHALLENGE_PARTICIPANT"; + public const string InvalidJoinCode = "INVALID_JOIN_CODE"; + public const string ChallengeClosed = "CHALLENGE_CLOSED"; public const string PairNotFound = "PAIR_NOT_FOUND"; public const string PairLimitReached = "PAIR_LIMIT_REACHED"; public const string AlreadyPaired = "ALREADY_PAIRED"; diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index b17db15b..be83848c 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -133,6 +133,12 @@ public static class ErrorMessages public static readonly AppError ContentRejected = new(ErrorCodes.ContentRejected, "This note can't be sent. Please revise it and try again."); public static readonly AppError FriendRequestNotFound = new(ErrorCodes.FriendRequestNotFound, "Friend request not found."); public static readonly AppError CheerNotFound = new(ErrorCodes.CheerNotFound, "Cheer not found."); + public static readonly AppError ChallengeNotFound = new(ErrorCodes.ChallengeNotFound, "Challenge not found."); + public static readonly AppError ChallengeFull = new(ErrorCodes.ChallengeFull, "This challenge has reached its participant limit of {0}."); + public static readonly AppError AlreadyJoinedChallenge = new(ErrorCodes.AlreadyJoinedChallenge, "You have already joined this challenge."); + public static readonly AppError NotChallengeParticipant = new(ErrorCodes.NotChallengeParticipant, "You are not a participant in this challenge."); + public static readonly AppError InvalidJoinCode = new(ErrorCodes.InvalidJoinCode, "Invalid join code."); + public static readonly AppError ChallengeClosed = new(ErrorCodes.ChallengeClosed, "This challenge is no longer accepting participants."); public static readonly AppError PairNotFound = new(ErrorCodes.PairNotFound, "Accountability pair not found."); public static readonly AppError PairLimitReached = new(ErrorCodes.PairLimitReached, "You've reached the maximum of {0} accountability pairs."); public static readonly AppError AlreadyPaired = new(ErrorCodes.AlreadyPaired, "You already have an accountability pair with this person."); diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index e6fe0bbd..ec12e525 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Orbit.Application.Challenges.Services; using Orbit.Application.Common; using Orbit.Application.Goals.Services; using Orbit.Application.Habits.Services; @@ -46,6 +47,7 @@ public record LogHabitServices( IUserDateService UserDateService, IUserStreakService UserStreakService, IGamificationService GamificationService, + IChallengeProgressService ChallengeProgressService, IMediator Mediator); public partial class LogHabitCommandHandler( @@ -191,6 +193,7 @@ private async Task> HandleLogAsync( var streakState = await services.UserStreakService.RecalculateAsync(request.UserId, cancellationToken); var gamificationResult = await ProcessGamificationSafeAsync(request.UserId, request.HabitId, cancellationToken); + await ProcessChallengeProgressSafeAsync(request.UserId, request.HabitId, cancellationToken); await ProcessOnboardingChecklistSafeAsync(request.UserId, OnboardingChecklistSignal.HabitLogged, cancellationToken); if (goalSync.AnyJustCompleted) @@ -283,6 +286,19 @@ private static bool IsUniqueViolation(Exception exception) } } + private async Task ProcessChallengeProgressSafeAsync( + Guid userId, Guid habitId, CancellationToken cancellationToken) + { + try + { + await services.ChallengeProgressService.EvaluateOnHabitLoggedAsync(userId, habitId, cancellationToken); + } + catch (Exception ex) + { + LogChallengeProgressFailed(logger, ex, habitId); + } + } + private async Task ProcessOnboardingChecklistSafeAsync( Guid userId, OnboardingChecklistSignal signal, CancellationToken cancellationToken) { @@ -368,6 +384,9 @@ private async Task ProcessGoalCompletionSafeAsync(Guid userId, CancellationToken [LoggerMessage(EventId = 4, Level = LogLevel.Warning, Message = "Onboarding checklist processing failed for user {UserId}")] private static partial void LogOnboardingChecklistFailed(ILogger logger, Exception ex, Guid userId); + + [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Challenge progress processing failed for habit {HabitId}")] + private static partial void LogChallengeProgressFailed(ILogger logger, Exception ex, Guid habitId); } internal record LinkedGoalSyncResult(IReadOnlyList? Updates, bool AnyJustCompleted) diff --git a/src/Orbit.Domain/Common/DomainConstants.cs b/src/Orbit.Domain/Common/DomainConstants.cs index ea820038..0409b358 100644 --- a/src/Orbit.Domain/Common/DomainConstants.cs +++ b/src/Orbit.Domain/Common/DomainConstants.cs @@ -11,5 +11,6 @@ public static class DomainConstants public const int HandleMaxLength = 20; public const int MaxCheerNoteLength = 200; public const int MaxReportDetailsLength = 500; + public const int MaxChallengeParticipants = 10; public const int MaxAccountabilityNoteLength = 200; } diff --git a/src/Orbit.Domain/Common/DomainErrors.cs b/src/Orbit.Domain/Common/DomainErrors.cs index 5556619c..2e38e335 100644 --- a/src/Orbit.Domain/Common/DomainErrors.cs +++ b/src/Orbit.Domain/Common/DomainErrors.cs @@ -82,4 +82,9 @@ public static class DomainErrors public static readonly AppError GoalAlreadyCompleted = new("GOAL_ALREADY_COMPLETED", "Goal is already completed."); public static readonly AppError GoalAlreadyAbandoned = new("GOAL_ALREADY_ABANDONED", "Goal is already abandoned."); public static readonly AppError GoalAlreadyActive = new("GOAL_ALREADY_ACTIVE", "Goal is already active."); + + public static readonly AppError ChallengeTargetRequired = new("CHALLENGE_TARGET_REQUIRED", "A goal challenge must have a target count greater than 0."); + public static readonly AppError ChallengeTargetNotAllowed = new("CHALLENGE_TARGET_NOT_ALLOWED", "A streak challenge cannot have a target count."); + public static readonly AppError ChallengePeriodInvalid = new("CHALLENGE_PERIOD_INVALID", "Challenge end date must be on or after the start date."); + public static readonly AppError ChallengeJoinCodeRequired = new("CHALLENGE_JOIN_CODE_REQUIRED", "Join code is required."); } diff --git a/src/Orbit.Domain/Entities/Challenge.cs b/src/Orbit.Domain/Entities/Challenge.cs new file mode 100644 index 00000000..a882ccb5 --- /dev/null +++ b/src/Orbit.Domain/Entities/Challenge.cs @@ -0,0 +1,122 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Entities; + +public record CreateChallengeParams( + Guid CreatorId, + ChallengeType Type, + string Title, + string? Description, + int? TargetCount, + DateOnly PeriodStartUtc, + DateOnly? PeriodEndUtc, + string JoinCode); + +public class Challenge : Entity, ITimestamped, ISoftDeletable +{ + public Guid CreatorId { get; private set; } + public ChallengeType Type { get; private set; } + public string Title { get; private set; } = null!; + public string? Description { get; private set; } + public ChallengeStatus Status { get; private set; } + public int? TargetCount { get; private set; } + public DateOnly PeriodStartUtc { get; private set; } + public DateOnly? PeriodEndUtc { get; private set; } + public string JoinCode { get; private set; } = null!; + public DateTime? CompletedAtUtc { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; + public bool IsDeleted { get; private set; } + public DateTime? DeletedAtUtc { get; private set; } + + private readonly List _participants = []; + public IReadOnlyCollection Participants => _participants.AsReadOnly(); + + public IReadOnlyList GetActiveParticipants() => _participants.Where(p => p.IsActive).ToList(); + + private Challenge() { } + + public static Result Create(CreateChallengeParams p) + { + if (p.CreatorId == Guid.Empty) + return Result.Failure(DomainErrors.UserIdRequired); + + if (string.IsNullOrWhiteSpace(p.Title)) + return Result.Failure(DomainErrors.TitleRequired); + + if (string.IsNullOrWhiteSpace(p.JoinCode)) + return Result.Failure(DomainErrors.ChallengeJoinCodeRequired); + + var typeValidation = ChallengeInvariants.ValidateTypeAndTarget(p.Type, p.TargetCount); + if (typeValidation is not null) + return Result.Failure(typeValidation); + + var periodValidation = ChallengeInvariants.ValidatePeriod(p.PeriodStartUtc, p.PeriodEndUtc); + if (periodValidation is not null) + return Result.Failure(periodValidation); + + return Result.Success(new Challenge + { + CreatorId = p.CreatorId, + Type = p.Type, + Title = p.Title.Trim(), + Description = p.Description?.Trim(), + Status = ChallengeStatus.Active, + TargetCount = p.TargetCount, + PeriodStartUtc = p.PeriodStartUtc, + PeriodEndUtc = p.PeriodEndUtc, + JoinCode = p.JoinCode, + CreatedAtUtc = DateTime.UtcNow + }); + } + + /// + /// Adds a participant and links their own habits, whose logs feed the shared progress. The caller + /// is responsible for enforcing participation policy (status, cap, friendship, no duplicate active + /// membership) before invoking this — the aggregate owns only how participants are stored. + /// + public ChallengeParticipant AddParticipant(Guid userId, IReadOnlyList habitIds) + { + var participant = ChallengeParticipant.Create(Id, userId); + foreach (var habitId in habitIds) + participant.LinkHabit(habitId); + + _participants.Add(participant); + UpdatedAtUtc = DateTime.UtcNow; + return participant; + } + + public bool TryLeave(Guid userId) + { + var participant = _participants.Find(p => p.UserId == userId && p.IsActive); + if (participant is null) + return false; + + participant.Leave(); + UpdatedAtUtc = DateTime.UtcNow; + return true; + } + + /// + /// Transitions an active challenge to completed, returning true only on the Active to Completed + /// transition so callers fire the Mission Accomplished award exactly once. + /// + public bool MarkCompleted() + { + if (Status != ChallengeStatus.Active) + return false; + + Status = ChallengeStatus.Completed; + CompletedAtUtc = DateTime.UtcNow; + UpdatedAtUtc = DateTime.UtcNow; + return true; + } + + public void SoftDelete() + { + IsDeleted = true; + DeletedAtUtc = DateTime.UtcNow; + UpdatedAtUtc = DateTime.UtcNow; + } +} diff --git a/src/Orbit.Domain/Entities/ChallengeInvariants.cs b/src/Orbit.Domain/Entities/ChallengeInvariants.cs new file mode 100644 index 00000000..12eb5f9a --- /dev/null +++ b/src/Orbit.Domain/Entities/ChallengeInvariants.cs @@ -0,0 +1,32 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Entities; + +/// +/// Pure validation guards for invariants. Each method returns the matching +/// entry on violation (or null when valid), exactly as the factory expects. +/// Enforces type/target coherence (CoopGoal needs a positive target, StreakTogether forbids one) and +/// period ordering. +/// +internal static class ChallengeInvariants +{ + public static AppError? ValidateTypeAndTarget(ChallengeType type, int? targetCount) + { + if (type == ChallengeType.CoopGoal && (targetCount is null || targetCount <= 0)) + return DomainErrors.ChallengeTargetRequired; + + if (type == ChallengeType.StreakTogether && targetCount is not null) + return DomainErrors.ChallengeTargetNotAllowed; + + return null; + } + + public static AppError? ValidatePeriod(DateOnly periodStartUtc, DateOnly? periodEndUtc) + { + if (periodEndUtc.HasValue && periodEndUtc.Value < periodStartUtc) + return DomainErrors.ChallengePeriodInvalid; + + return null; + } +} diff --git a/src/Orbit.Domain/Entities/ChallengeParticipant.cs b/src/Orbit.Domain/Entities/ChallengeParticipant.cs new file mode 100644 index 00000000..8bcdcc8f --- /dev/null +++ b/src/Orbit.Domain/Entities/ChallengeParticipant.cs @@ -0,0 +1,41 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class ChallengeParticipant : Entity +{ + public Guid ChallengeId { get; private set; } + public Guid UserId { get; private set; } + public DateTime JoinedAtUtc { get; private set; } + public DateTime? LeftAtUtc { get; private set; } + + public bool IsActive => LeftAtUtc is null; + + private readonly List _linkedHabits = []; + public IReadOnlyCollection LinkedHabits => _linkedHabits.AsReadOnly(); + + private ChallengeParticipant() { } + + internal static ChallengeParticipant Create(Guid challengeId, Guid userId) + { + return new ChallengeParticipant + { + ChallengeId = challengeId, + UserId = userId, + JoinedAtUtc = DateTime.UtcNow + }; + } + + internal void LinkHabit(Guid habitId) + { + if (_linkedHabits.Exists(h => h.HabitId == habitId)) + return; + + _linkedHabits.Add(ChallengeParticipantHabit.Create(Id, habitId)); + } + + internal void Leave() + { + LeftAtUtc ??= DateTime.UtcNow; + } +} diff --git a/src/Orbit.Domain/Entities/ChallengeParticipantHabit.cs b/src/Orbit.Domain/Entities/ChallengeParticipantHabit.cs new file mode 100644 index 00000000..07b7f91d --- /dev/null +++ b/src/Orbit.Domain/Entities/ChallengeParticipantHabit.cs @@ -0,0 +1,20 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class ChallengeParticipantHabit : Entity +{ + public Guid ChallengeParticipantId { get; private set; } + public Guid HabitId { get; private set; } + + private ChallengeParticipantHabit() { } + + internal static ChallengeParticipantHabit Create(Guid challengeParticipantId, Guid habitId) + { + return new ChallengeParticipantHabit + { + ChallengeParticipantId = challengeParticipantId, + HabitId = habitId + }; + } +} diff --git a/src/Orbit.Domain/Enums/ChallengeStatus.cs b/src/Orbit.Domain/Enums/ChallengeStatus.cs new file mode 100644 index 00000000..3454c7b9 --- /dev/null +++ b/src/Orbit.Domain/Enums/ChallengeStatus.cs @@ -0,0 +1,7 @@ +namespace Orbit.Domain.Enums; + +public enum ChallengeStatus +{ + Active, + Completed +} diff --git a/src/Orbit.Domain/Enums/ChallengeType.cs b/src/Orbit.Domain/Enums/ChallengeType.cs new file mode 100644 index 00000000..98db732c --- /dev/null +++ b/src/Orbit.Domain/Enums/ChallengeType.cs @@ -0,0 +1,7 @@ +namespace Orbit.Domain.Enums; + +public enum ChallengeType +{ + CoopGoal, + StreakTogether +} diff --git a/src/Orbit.Infrastructure/Migrations/20260630231107_AddChallenges.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260630231107_AddChallenges.Designer.cs new file mode 100644 index 00000000..8f8d3593 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260630231107_AddChallenges.Designer.cs @@ -0,0 +1,2469 @@ +// +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("20260630231107_AddChallenges")] + partial class AddChallenges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + 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.AccountabilityCheckIn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PairId", "CreatedAtUtc"); + + b.HasIndex("PairId", "UserId", "Date") + .IsUnique(); + + b.ToTable("AccountabilityCheckIns"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("AccountabilityPairs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("PairId", "UserId", "HabitId") + .IsUnique(); + + b.ToTable("AccountabilityPairHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CachedTokens") + .HasColumnType("bigint"); + + b.Property("Calls") + .HasColumnType("bigint"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("CostUsd") + .HasColumnType("numeric"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose") + .IsUnique(); + + b.ToTable("AiUsageDaily"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + 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" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PeriodEndUtc") + .HasColumnType("date"); + + b.Property("PeriodStartUtc") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChallengeId", "UserId") + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + + b.ToTable("ChallengeParticipants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeParticipantId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("ChallengeParticipantId", "HabitId") + .IsUnique(); + + b.ToTable("ChallengeParticipantHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + 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("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + 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.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + 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("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + 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.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + 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("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("PublicProfileShowAchievements") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowLevel") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowStreak") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowTopHabits") + .HasColumnType("boolean"); + + b.Property("PublicProfileSlug") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialOptIn") + .HasColumnType("boolean"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("PublicProfileSlug") + .IsUnique() + .HasFilter("\"PublicProfileSlug\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + 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("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("AwardedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AwardedAtUtc"); + + b.ToTable("XpAwardLogs"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.AccountabilityCheckIn", b => + { + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.HasOne("Orbit.Domain.Entities.Challenge", null) + .WithMany("Participants") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.HasOne("Orbit.Domain.Entities.ChallengeParticipant", null) + .WithMany("LinkedHabits") + .HasForeignKey("ChallengeParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .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.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Navigation("LinkedHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + 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/20260630231107_AddChallenges.cs b/src/Orbit.Infrastructure/Migrations/20260630231107_AddChallenges.cs new file mode 100644 index 00000000..6f921371 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260630231107_AddChallenges.cs @@ -0,0 +1,145 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddChallenges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Challenges", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TargetCount = table.Column(type: "integer", nullable: true), + PeriodStartUtc = table.Column(type: "date", nullable: false), + PeriodEndUtc = table.Column(type: "date", nullable: true), + JoinCode = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Challenges", x => x.Id); + table.ForeignKey( + name: "FK_Challenges_Users_CreatorId", + column: x => x.CreatorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ChallengeParticipants", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChallengeId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + JoinedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + LeftAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ChallengeParticipants", x => x.Id); + table.ForeignKey( + name: "FK_ChallengeParticipants_Challenges_ChallengeId", + column: x => x.ChallengeId, + principalTable: "Challenges", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChallengeParticipants_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ChallengeParticipantHabits", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChallengeParticipantId = table.Column(type: "uuid", nullable: false), + HabitId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChallengeParticipantHabits", x => x.Id); + table.ForeignKey( + name: "FK_ChallengeParticipantHabits_ChallengeParticipants_ChallengeP~", + column: x => x.ChallengeParticipantId, + principalTable: "ChallengeParticipants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChallengeParticipantHabits_Habits_HabitId", + column: x => x.HabitId, + principalTable: "Habits", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChallengeParticipantHabits_ChallengeParticipantId_HabitId", + table: "ChallengeParticipantHabits", + columns: new[] { "ChallengeParticipantId", "HabitId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChallengeParticipantHabits_HabitId", + table: "ChallengeParticipantHabits", + column: "HabitId"); + + migrationBuilder.CreateIndex( + name: "IX_ChallengeParticipants_ChallengeId_UserId", + table: "ChallengeParticipants", + columns: new[] { "ChallengeId", "UserId" }, + unique: true, + filter: "\"LeftAtUtc\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ChallengeParticipants_UserId", + table: "ChallengeParticipants", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Challenges_CreatorId", + table: "Challenges", + column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_Challenges_JoinCode", + table: "Challenges", + column: "JoinCode", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChallengeParticipantHabits"); + + migrationBuilder.DropTable( + name: "ChallengeParticipants"); + + migrationBuilder.DropTable( + name: "Challenges"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index fa3ef459..f8d54928 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -635,6 +635,124 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlockedUsers"); }); + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PeriodEndUtc") + .HasColumnType("date"); + + b.Property("PeriodStartUtc") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChallengeId", "UserId") + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + + b.ToTable("ChallengeParticipants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeParticipantId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("ChallengeParticipantId", "HabitId") + .IsUnique(); + + b.ToTable("ChallengeParticipantHabits"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => { b.Property("Id") @@ -2119,6 +2237,45 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.HasOne("Orbit.Domain.Entities.Challenge", null) + .WithMany("Participants") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.HasOne("Orbit.Domain.Entities.ChallengeParticipant", null) + .WithMany("LinkedHabits") + .HasForeignKey("ChallengeParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => { b.HasOne("Orbit.Domain.Entities.User", null) @@ -2282,6 +2439,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Navigation("LinkedHabits"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => { b.Navigation("ProgressLogs"); diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index 2bbba79a..d0616668 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -127,6 +127,15 @@ await context.Friendships .Where(f => f.RequesterId == userId || f.AddresseeId == userId) .ExecuteDeleteAsync(cancellationToken); + await context.ChallengeParticipants + .Where(p => p.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.Challenges + .IgnoreQueryFilters() + .Where(c => c.CreatorId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.Goals .IgnoreQueryFilters() .Where(g => g.UserId == userId) diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index ecddab99..89c90f5f 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -63,6 +63,9 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet BlockedUsers => Set(); public DbSet Reports => Set(); public DbSet FriendFeedEvents => Set(); + public DbSet Challenges => Set(); + public DbSet ChallengeParticipants => Set(); + public DbSet ChallengeParticipantHabits => Set(); public DbSet AccountabilityPairs => Set(); public DbSet AccountabilityPairHabits => Set(); public DbSet AccountabilityCheckIns => Set(); @@ -119,6 +122,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ConfigureBlockedUserEntity(modelBuilder); ConfigureReportEntity(modelBuilder); ConfigureFriendFeedEventEntity(modelBuilder); + ConfigureChallengeEntity(modelBuilder); + ConfigureChallengeParticipantEntity(modelBuilder); + ConfigureChallengeParticipantHabitEntity(modelBuilder); ConfigureAccountabilityPairEntity(modelBuilder); ConfigureAccountabilityPairHabitEntity(modelBuilder); ConfigureAccountabilityCheckInEntity(modelBuilder); @@ -582,6 +588,52 @@ private static void ConfigureFriendFeedEventEntity(ModelBuilder modelBuilder) }); } + private static void ConfigureChallengeEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(c => c.CreatorId); + entity.HasIndex(c => c.JoinCode).IsUnique(); + entity.HasQueryFilter(c => !c.IsDeleted); + entity.Property(c => c.Type).HasConversion().HasMaxLength(32); + entity.Property(c => c.Status).HasConversion().HasMaxLength(32); + entity.Property(c => c.JoinCode).HasMaxLength(16); + entity.Property(c => c.Title).HasMaxLength(Orbit.Application.Common.AppConstants.MaxChallengeTitleLength); + entity.Property(c => c.Description).HasMaxLength(Orbit.Application.Common.AppConstants.MaxChallengeDescriptionLength); + entity.HasOne().WithMany().HasForeignKey(c => c.CreatorId).OnDelete(DeleteBehavior.Restrict); + entity.HasMany(c => c.Participants) + .WithOne() + .HasForeignKey(p => p.ChallengeId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureChallengeParticipantEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(p => new { p.ChallengeId, p.UserId }) + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + entity.HasIndex(p => p.UserId); + entity.HasOne().WithMany().HasForeignKey(p => p.UserId).OnDelete(DeleteBehavior.Restrict); + entity.HasMany(p => p.LinkedHabits) + .WithOne() + .HasForeignKey(h => h.ChallengeParticipantId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureChallengeParticipantHabitEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(h => new { h.ChallengeParticipantId, h.HabitId }).IsUnique(); + entity.HasIndex(h => h.HabitId); + entity.HasOne().WithMany().HasForeignKey(h => h.HabitId).OnDelete(DeleteBehavior.Cascade); + }); + } + private static void ConfigureUserEntity(ModelBuilder modelBuilder, NullableEncryptionValueConverter? nullableEncConverter) { modelBuilder.Entity(entity => diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index a8a42be7..7ddb3dc3 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -900,7 +900,7 @@ private static AgentCapability[] SocialCapabilities() CreateCapability( AgentCapabilityIds.SocialManage, "Manage Social", - "Manages friendships, cheers, the friend feed, handles, blocking, reporting, and accountability buddies. Cataloged but not exposed to the agent in this phase.", + "Manages friendships, cheers, the friend feed, handles, blocking, reporting, accountability buddies, and cooperative challenges. Cataloged but not exposed to the agent in this phase.", "social", AgentScopes.ManageSocial, AgentRiskClass.Low, @@ -919,6 +919,10 @@ private static AgentCapability[] SocialCapabilities() "FriendsController.Block", "FriendsController.Unblock", "FriendsController.Report", + "ChallengesController.Create", + "ChallengesController.Join", + "ChallengesController.Leave", + "ChallengesController.GetDetail", "ProfileController.SetHandle", "ProfileController.SetSocialOptIn", "ProfileController.UpdatePublicProfile", diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index ef1f320d..97021785 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -28,6 +28,7 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider ["set-handle"] = new(TimeSpan.FromHours(24), PermitLimit: 5, SegmentCount: 1), ["block"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1), ["unblock"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1), + ["challenges"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1), ["public-profile"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4) }; diff --git a/tests/Orbit.Application.Tests/Challenges/ChallengeProgressCalculatorTests.cs b/tests/Orbit.Application.Tests/Challenges/ChallengeProgressCalculatorTests.cs new file mode 100644 index 00000000..5a697d37 --- /dev/null +++ b/tests/Orbit.Application.Tests/Challenges/ChallengeProgressCalculatorTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using Orbit.Application.Challenges.Services; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Tests.Challenges; + +public class ChallengeProgressCalculatorTests +{ + private static readonly DateOnly Today = new(2026, 3, 20); + private static readonly Guid HabitA = Guid.NewGuid(); + private static readonly Guid HabitB = Guid.NewGuid(); + + private static HabitLog Log(Guid habitId, DateOnly date, decimal value) => + HabitLog.Create(habitId, date, value); + + [Fact] + public void CoopGoal_CountsOnlyCompletionsInWindowAcrossContributingHabits() + { + var periodStart = Today.AddDays(-10); + var logs = new[] + { + Log(HabitA, Today.AddDays(-1), 1), + Log(HabitB, Today.AddDays(-2), 1), + Log(HabitA, Today.AddDays(-3), 0), + Log(HabitA, Today.AddDays(-30), 1), + Log(Guid.NewGuid(), Today, 1), + }; + + var progress = ChallengeProgressCalculator.CalculateCoopGoalProgress( + new[] { HabitA, HabitB }, logs, periodStart, Today); + + progress.Should().Be(2); + } + + [Fact] + public void CoopGoal_NoContributingHabits_ReturnsZero() + { + var progress = ChallengeProgressCalculator.CalculateCoopGoalProgress( + [], [Log(HabitA, Today, 1)], Today.AddDays(-5), Today); + + progress.Should().Be(0); + } + + [Fact] + public void SharedStreak_AdvancesOnlyWhileEveryParticipantLogged() + { + var periodStart = Today.AddDays(-10); + var logs = new List + { + Log(HabitA, Today, 1), Log(HabitB, Today, 1), + Log(HabitA, Today.AddDays(-1), 1), Log(HabitB, Today.AddDays(-1), 1), + Log(HabitA, Today.AddDays(-2), 1), Log(HabitB, Today.AddDays(-2), 1), + Log(HabitA, Today.AddDays(-3), 1), + }; + + var streak = ChallengeProgressCalculator.CalculateSharedStreak( + [new[] { HabitA }, new[] { HabitB }], logs, periodStart, Today, Today); + + streak.Should().Be(3); + } + + [Fact] + public void SharedStreak_ResetsOnSingleParticipantMiss() + { + var periodStart = Today.AddDays(-10); + var logs = new List + { + Log(HabitA, Today, 1), Log(HabitB, Today, 1), + Log(HabitA, Today.AddDays(-1), 1), + }; + + var streak = ChallengeProgressCalculator.CalculateSharedStreak( + [new[] { HabitA }, new[] { HabitB }], logs, periodStart, Today, Today); + + streak.Should().Be(1); + } + + [Fact] + public void SharedStreak_UnfinishedToday_DoesNotBreakYesterdaysStreak() + { + var periodStart = Today.AddDays(-3); + var logs = new List + { + Log(HabitA, Today.AddDays(-1), 1), Log(HabitB, Today.AddDays(-1), 1), + Log(HabitA, Today.AddDays(-2), 1), Log(HabitB, Today.AddDays(-2), 1), + }; + + var streak = ChallengeProgressCalculator.CalculateSharedStreak( + [new[] { HabitA }, new[] { HabitB }], logs, periodStart, Today, Today); + + streak.Should().Be(2); + } + + [Fact] + public void SharedStreak_SkipValueDoesNotCountAsLogged() + { + var periodStart = Today.AddDays(-3); + var logs = new List + { + Log(HabitA, Today, 1), Log(HabitB, Today, 0), + }; + + var streak = ChallengeProgressCalculator.CalculateSharedStreak( + [new[] { HabitA }, new[] { HabitB }], logs, periodStart, Today, Today); + + streak.Should().Be(0); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs new file mode 100644 index 00000000..1ace2519 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs @@ -0,0 +1,128 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Application.Tests.Social; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Challenges; + +public class CreateChallengeCommandHandlerTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IGenericRepository _challengeRepository = Substitute.For>(); + private readonly IGenericRepository _habitRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + + private readonly CreateChallengeCommandHandler _handler; + + private readonly User _creator = SocialTestHelpers.OptedInUser("Creator"); + private readonly User _friend = SocialTestHelpers.OptedInUser("Friend"); + private readonly Guid _habitId = Guid.NewGuid(); + + private static readonly DateOnly PeriodStart = new(2026, 3, 1); + private static readonly DateOnly PeriodEnd = new(2026, 3, 31); + + public CreateChallengeCommandHandlerTests() + { + var guard = new SocialAccessGuard(_userRepository); + var friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + _handler = new CreateChallengeCommandHandler(guard, friendGraph, _challengeRepository, _habitRepository, _unitOfWork); + + SocialTestHelpers.StubUsers(_userRepository, _creator, _friend); + SocialTestHelpers.StubFind(_friendshipRepository, AcceptedFriendship()); + SocialTestHelpers.StubFind(_blockedUserRepository); + _habitRepository.CountAsync(Arg.Any>>(), Arg.Any()).Returns(1); + _challengeRepository.AnyAsync(Arg.Any>>(), Arg.Any()).Returns(false); + } + + private Friendship AcceptedFriendship() + { + var friendship = Friendship.Create(_creator.Id, _friend.Id).Value; + friendship.Accept(); + return friendship; + } + + private CreateChallengeCommand Command(IReadOnlyList? invited = null) => + new(_creator.Id, ChallengeType.CoopGoal, "March Challenge", null, 30, PeriodStart, PeriodEnd, + [_habitId], invited ?? [_friend.Id]); + + [Fact] + public async Task ValidCommand_PersistsChallengeWithCreatorAndInvitedFriend() + { + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _challengeRepository.Received(1).AddAsync( + Arg.Is(c => + c.CreatorId == _creator.Id + && c.Participants.Count == 2 + && c.Participants.Any(p => p.UserId == _creator.Id && p.LinkedHabits.Count == 1) + && c.Participants.Any(p => p.UserId == _friend.Id)), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task SocialDisabled_ReturnsSocialDisabled() + { + var optedOut = SocialTestHelpers.OptedOutUser("Private"); + SocialTestHelpers.StubUsers(_userRepository, optedOut); + + var result = await _handler.Handle( + new CreateChallengeCommand(optedOut.Id, ChallengeType.CoopGoal, "X", null, 5, PeriodStart, PeriodEnd, [_habitId], []), + CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.SocialDisabled); + await _challengeRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HabitNotOwned_ReturnsHabitNotFound() + { + _habitRepository.CountAsync(Arg.Any>>(), Arg.Any()).Returns(0); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.HabitNotFound); + await _challengeRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NonFriendInvite_ReturnsNotFriends() + { + SocialTestHelpers.StubFind(_friendshipRepository); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.NotFriends); + await _challengeRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task BlockedInvitee_ReturnsNotFriends() + { + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_friend.Id, _creator.Id).Value); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.NotFriends); + } + + [Fact] + public async Task InvitingOverParticipantCap_ReturnsChallengeFull() + { + var tooManyFriends = Enumerable.Range(0, AppConstants.MaxChallengeParticipants).Select(_ => Guid.NewGuid()).ToList(); + + var result = await _handler.Handle(Command(invited: tooManyFriends), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ChallengeFull); + await _challengeRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Challenges/JoinChallengeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Challenges/JoinChallengeCommandHandlerTests.cs new file mode 100644 index 00000000..cd6bc6ea --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Challenges/JoinChallengeCommandHandlerTests.cs @@ -0,0 +1,157 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Common; +using Orbit.Application.Gamification.Services; +using Orbit.Application.Social.Services; +using Orbit.Application.Tests.Social; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Challenges; + +public class JoinChallengeCommandHandlerTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IGenericRepository _challengeRepository = Substitute.For>(); + private readonly IGenericRepository _habitRepository = Substitute.For>(); + private readonly IGenericRepository _achievementRepository = Substitute.For>(); + private readonly IGenericRepository _xpAwardLogRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + + private readonly JoinChallengeCommandHandler _handler; + + private readonly User _joiner = SocialTestHelpers.OptedInUser("Joiner"); + private readonly Guid _creatorId = Guid.NewGuid(); + private readonly Guid _joinerHabitId = Guid.NewGuid(); + private const string Code = "ABC23456"; + + public JoinChallengeCommandHandlerTests() + { + var guard = new SocialAccessGuard(_userRepository); + var friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + _handler = new JoinChallengeCommandHandler( + guard, friendGraph, _challengeRepository, _habitRepository, _achievementRepository, + new XpAwarder(_xpAwardLogRepository), _unitOfWork); + + SocialTestHelpers.StubUsers(_userRepository, _joiner); + SocialTestHelpers.StubFind(_blockedUserRepository); + SocialTestHelpers.StubFind(_achievementRepository); + _habitRepository.CountAsync(Arg.Any>>(), Arg.Any()).Returns(1); + StubChallengeLookup(ActiveChallenge()); + } + + private Challenge ActiveChallenge() + { + var challenge = Challenge.Create(new CreateChallengeParams( + _creatorId, ChallengeType.CoopGoal, "Challenge", null, 30, + new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 31), Code)).Value; + challenge.AddParticipant(_creatorId, [Guid.NewGuid()]); + return challenge; + } + + private void StubChallengeLookup(Challenge? challenge) + { + _challengeRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(challenge); + } + + private JoinChallengeCommand Command() => new(_joiner.Id, Code, [_joinerHabitId]); + + [Fact] + public async Task ValidJoin_AddsParticipantAndSaves() + { + var challenge = ActiveChallenge(); + StubChallengeLookup(challenge); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + challenge.Participants.Should().Contain(p => p.UserId == _joiner.Id && p.IsActive); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ValidJoin_GrantsTeamPlayer() + { + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _achievementRepository.Received().AddAsync( + Arg.Is(a => a.AchievementId == "team_player"), Arg.Any()); + } + + [Fact] + public async Task UnknownCode_ReturnsInvalidJoinCode() + { + StubChallengeLookup(null); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.InvalidJoinCode); + } + + [Fact] + public async Task CompletedChallenge_ReturnsChallengeClosed() + { + var challenge = ActiveChallenge(); + challenge.MarkCompleted(); + StubChallengeLookup(challenge); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ChallengeClosed); + } + + [Fact] + public async Task AlreadyActiveParticipant_ReturnsAlreadyJoined() + { + var challenge = ActiveChallenge(); + challenge.AddParticipant(_joiner.Id, [_joinerHabitId]); + StubChallengeLookup(challenge); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.AlreadyJoinedChallenge); + } + + [Fact] + public async Task BlockedByCreator_ReturnsInvalidJoinCode() + { + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_creatorId, _joiner.Id).Value); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.InvalidJoinCode); + } + + [Fact] + public async Task FullChallenge_ReturnsChallengeFull() + { + var challenge = ActiveChallenge(); + for (var i = challenge.GetActiveParticipants().Count; i < AppConstants.MaxChallengeParticipants; i++) + challenge.AddParticipant(Guid.NewGuid(), [Guid.NewGuid()]); + StubChallengeLookup(challenge); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ChallengeFull); + } + + [Fact] + public async Task HabitNotOwned_ReturnsHabitNotFound() + { + _habitRepository.CountAsync(Arg.Any>>(), Arg.Any()).Returns(0); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.HabitNotFound); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Challenges/LeaveChallengeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Challenges/LeaveChallengeCommandHandlerTests.cs new file mode 100644 index 00000000..fccde4b9 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Challenges/LeaveChallengeCommandHandlerTests.cs @@ -0,0 +1,92 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Challenges.Commands; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Challenges; + +public class LeaveChallengeCommandHandlerTests +{ + private readonly IGenericRepository _challengeRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly LeaveChallengeCommandHandler _handler; + + private readonly Guid _creatorId = Guid.NewGuid(); + private readonly Guid _memberId = Guid.NewGuid(); + + public LeaveChallengeCommandHandlerTests() + { + _handler = new LeaveChallengeCommandHandler(_challengeRepository, _unitOfWork); + } + + private Challenge ChallengeWithMembers() + { + var challenge = Challenge.Create(new CreateChallengeParams( + _creatorId, ChallengeType.CoopGoal, "Challenge", null, 30, + new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 31), "ABC23456")).Value; + challenge.AddParticipant(_creatorId, [Guid.NewGuid()]); + challenge.AddParticipant(_memberId, [Guid.NewGuid()]); + return challenge; + } + + private void StubLookup(Challenge? challenge) + { + _challengeRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(challenge); + } + + [Fact] + public async Task ActiveParticipant_LeavesAndSaves() + { + var challenge = ChallengeWithMembers(); + StubLookup(challenge); + + var result = await _handler.Handle(new LeaveChallengeCommand(_memberId, challenge.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + challenge.Participants.Single(p => p.UserId == _memberId).IsActive.Should().BeFalse(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task CreatorLeaves_ChallengeRemainsActive() + { + var challenge = ChallengeWithMembers(); + StubLookup(challenge); + + var result = await _handler.Handle(new LeaveChallengeCommand(_creatorId, challenge.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + challenge.Status.Should().Be(ChallengeStatus.Active); + challenge.Participants.Single(p => p.UserId == _creatorId).IsActive.Should().BeFalse(); + } + + [Fact] + public async Task NonParticipant_ReturnsNotChallengeParticipant() + { + var challenge = ChallengeWithMembers(); + StubLookup(challenge); + + var result = await _handler.Handle(new LeaveChallengeCommand(Guid.NewGuid(), challenge.Id), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.NotChallengeParticipant); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ChallengeNotFound_ReturnsChallengeNotFound() + { + StubLookup(null); + + var result = await _handler.Handle(new LeaveChallengeCommand(_memberId, Guid.NewGuid()), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ChallengeNotFound); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs index 05fd2425..1e192527 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Orbit.Application.Challenges.Services; using Orbit.Application.Habits.Commands; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -23,6 +24,7 @@ public class LogHabitCommandHandlerTests private readonly IUserDateService _userDateService = Substitute.For(); private readonly IUserStreakService _userStreakService = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); + private readonly IChallengeProgressService _challengeProgressService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly MediatR.IMediator _mediator = Substitute.For(); @@ -35,7 +37,7 @@ public class LogHabitCommandHandlerTests public LogHabitCommandHandlerTests() { var repos = new LogHabitRepositories(_habitRepo, _habitLogRepo, _goalRepo, _userRepo); - var services = new LogHabitServices(_userDateService, _userStreakService, _gamificationService, _mediator); + var services = new LogHabitServices(_userDateService, _userStreakService, _gamificationService, _challengeProgressService, _mediator); _handler = new LogHabitCommandHandler( repos, services, _unitOfWork, _cache, _logger); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs index 7bf6ec10..5762453f 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Orbit.Application.Challenges.Services; using Orbit.Application.Habits.Commands; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -25,6 +26,7 @@ public class LogHabitLinkedGoalTests private readonly IUserDateService _userDateService = Substitute.For(); private readonly IUserStreakService _userStreakService = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); + private readonly IChallengeProgressService _challengeProgressService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly MemoryCache _cache = new(new MemoryCacheOptions()); private readonly MediatR.IMediator _mediator = Substitute.For(); @@ -36,7 +38,7 @@ public class LogHabitLinkedGoalTests public LogHabitLinkedGoalTests() { var repos = new LogHabitRepositories(_habitRepo, _habitLogRepo, _goalRepo, _userRepo); - var services = new LogHabitServices(_userDateService, _userStreakService, _gamificationService, _mediator); + var services = new LogHabitServices(_userDateService, _userStreakService, _gamificationService, _challengeProgressService, _mediator); _handler = new LogHabitCommandHandler( repos, services, _unitOfWork, _cache, Substitute.For>()); diff --git a/tests/Orbit.Application.Tests/Queries/Challenges/GetChallengeDetailQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Challenges/GetChallengeDetailQueryHandlerTests.cs new file mode 100644 index 00000000..32985d91 --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Challenges/GetChallengeDetailQueryHandlerTests.cs @@ -0,0 +1,127 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Challenges.Queries; +using Orbit.Application.Common; +using Orbit.Application.Tests.Social; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Queries.Challenges; + +public class GetChallengeDetailQueryHandlerTests +{ + private readonly IGenericRepository _challengeRepository = Substitute.For>(); + private readonly IGenericRepository _habitLogRepository = Substitute.For>(); + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IUserDateService _userDateService = Substitute.For(); + private readonly GetChallengeDetailQueryHandler _handler; + + private readonly User _creator = SocialTestHelpers.OptedInUser("Creator"); + private readonly User _member = SocialTestHelpers.OptedInUser("Member"); + private readonly Guid _habitA = Guid.NewGuid(); + private readonly Guid _habitB = Guid.NewGuid(); + + private static readonly DateOnly Today = new(2026, 3, 15); + private static readonly DateOnly PeriodStart = new(2026, 3, 1); + private static readonly DateOnly PeriodEnd = new(2026, 3, 31); + + public GetChallengeDetailQueryHandlerTests() + { + _handler = new GetChallengeDetailQueryHandler( + _challengeRepository, _habitLogRepository, _userRepository, _userDateService); + + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); + SocialTestHelpers.StubUsers(_userRepository, _creator, _member); + } + + private Challenge BuildChallenge(ChallengeType type, int? target, DateOnly? periodEnd) + { + var challenge = Challenge.Create(new CreateChallengeParams( + _creator.Id, type, "Challenge", null, target, PeriodStart, periodEnd, "ABC23456")).Value; + challenge.AddParticipant(_creator.Id, [_habitA]); + challenge.AddParticipant(_member.Id, [_habitB]); + return challenge; + } + + private void StubChallenge(Challenge challenge) + { + _challengeRepository.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { challenge }.AsReadOnly()); + } + + private void StubLogs(params HabitLog[] logs) + { + _habitLogRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(logs.ToList().AsReadOnly()); + } + + [Fact] + public async Task CoopGoal_ReachingTarget_ReportsCompleteWithSummedProgress() + { + StubChallenge(BuildChallenge(ChallengeType.CoopGoal, target: 2, PeriodEnd)); + StubLogs(HabitLog.Create(_habitA, Today, 1), HabitLog.Create(_habitB, Today, 1)); + + var result = await _handler.Handle(new GetChallengeDetailQuery(_creator.Id, Guid.NewGuid()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.CurrentProgress.Should().Be(2); + result.Value.IsComplete.Should().BeTrue(); + result.Value.Participants.Should().HaveCount(2); + } + + [Fact] + public async Task CoopGoal_BelowTarget_ReportsIncomplete() + { + StubChallenge(BuildChallenge(ChallengeType.CoopGoal, target: 5, PeriodEnd)); + StubLogs(HabitLog.Create(_habitA, Today, 1)); + + var result = await _handler.Handle(new GetChallengeDetailQuery(_creator.Id, Guid.NewGuid()), CancellationToken.None); + + result.Value.CurrentProgress.Should().Be(1); + result.Value.IsComplete.Should().BeFalse(); + } + + [Fact] + public async Task StreakTogether_AdvancesWhileAllParticipantsLog() + { + StubChallenge(BuildChallenge(ChallengeType.StreakTogether, target: null, periodEnd: null)); + StubLogs( + HabitLog.Create(_habitA, Today, 1), HabitLog.Create(_habitB, Today, 1), + HabitLog.Create(_habitA, Today.AddDays(-1), 1), HabitLog.Create(_habitB, Today.AddDays(-1), 1), + HabitLog.Create(_habitA, Today.AddDays(-2), 1), HabitLog.Create(_habitB, Today.AddDays(-2), 1)); + + var result = await _handler.Handle(new GetChallengeDetailQuery(_member.Id, Guid.NewGuid()), CancellationToken.None); + + result.Value.CurrentProgress.Should().Be(3); + result.Value.IsComplete.Should().BeFalse(); + } + + [Fact] + public async Task StreakTogether_ResetsAfterOneParticipantMisses() + { + StubChallenge(BuildChallenge(ChallengeType.StreakTogether, target: null, periodEnd: null)); + StubLogs( + HabitLog.Create(_habitA, Today, 1), HabitLog.Create(_habitB, Today, 1), + HabitLog.Create(_habitA, Today.AddDays(-1), 1)); + + var result = await _handler.Handle(new GetChallengeDetailQuery(_creator.Id, Guid.NewGuid()), CancellationToken.None); + + result.Value.CurrentProgress.Should().Be(1); + } + + [Fact] + public async Task NonParticipant_ReturnsChallengeNotFound() + { + StubChallenge(BuildChallenge(ChallengeType.CoopGoal, target: 2, PeriodEnd)); + StubLogs(); + + var result = await _handler.Handle(new GetChallengeDetailQuery(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ChallengeNotFound); + } +} diff --git a/tests/Orbit.Application.Tests/Services/ChallengeProgressServiceTests.cs b/tests/Orbit.Application.Tests/Services/ChallengeProgressServiceTests.cs new file mode 100644 index 00000000..d5f65002 --- /dev/null +++ b/tests/Orbit.Application.Tests/Services/ChallengeProgressServiceTests.cs @@ -0,0 +1,129 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Challenges.Services; +using Orbit.Application.Gamification.Services; +using Orbit.Application.Tests.Social; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Services; + +public class ChallengeProgressServiceTests +{ + private readonly IGenericRepository _challengeRepository = Substitute.For>(); + private readonly IGenericRepository _participantRepository = Substitute.For>(); + private readonly IGenericRepository _participantHabitRepository = Substitute.For>(); + private readonly IGenericRepository _habitLogRepository = Substitute.For>(); + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _achievementRepository = Substitute.For>(); + private readonly IGenericRepository _xpAwardLogRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IUserDateService _userDateService = Substitute.For(); + + private readonly ChallengeProgressService _service; + + private readonly User _user = SocialTestHelpers.OptedInUser("Logger"); + private readonly Guid _habitId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 15); + + public ChallengeProgressServiceTests() + { + var repositories = new ChallengeProgressRepositories( + _challengeRepository, _participantRepository, _participantHabitRepository, + _habitLogRepository, _userRepository, _achievementRepository); + _service = new ChallengeProgressService( + repositories, new XpAwarder(_xpAwardLogRepository), _unitOfWork, _userDateService); + + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); + SocialTestHelpers.StubFind(_achievementRepository); + _userRepository.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { _user }.AsReadOnly()); + } + + private Challenge BuildTrackedChallenge(int target) + { + var challenge = Challenge.Create(new CreateChallengeParams( + _user.Id, ChallengeType.CoopGoal, "Challenge", null, target, + PeriodStartUtc: Today.AddDays(-10), PeriodEndUtc: Today.AddDays(10), JoinCode: "ABC23456")).Value; + challenge.AddParticipant(_user.Id, [_habitId]); + + var participant = challenge.Participants.First(); + _participantHabitRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(participant.LinkedHabits.ToList().AsReadOnly()); + _participantRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { participant }.AsReadOnly()); + _challengeRepository.FindTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { challenge }.AsReadOnly()); + return challenge; + } + + private void StubLogs(params HabitLog[] logs) => + _habitLogRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(logs.ToList().AsReadOnly()); + + [Fact] + public async Task LogReachingTarget_MarksChallengeCompletedAndSaves() + { + var challenge = BuildTrackedChallenge(target: 2); + StubLogs(HabitLog.Create(_habitId, Today, 1), HabitLog.Create(_habitId, Today.AddDays(-1), 1)); + + await _service.EvaluateOnHabitLoggedAsync(_user.Id, _habitId, CancellationToken.None); + + challenge.Status.Should().Be(ChallengeStatus.Completed); + challenge.CompletedAtUtc.Should().NotBeNull(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task LogReachingTarget_GrantsMissionAccomplished() + { + BuildTrackedChallenge(target: 1); + StubLogs(HabitLog.Create(_habitId, Today, 1)); + + await _service.EvaluateOnHabitLoggedAsync(_user.Id, _habitId, CancellationToken.None); + + await _achievementRepository.Received().AddAsync( + Arg.Is(a => a.AchievementId == "mission_accomplished"), Arg.Any()); + } + + [Fact] + public async Task LogBelowTarget_DoesNotCompleteOrSave() + { + var challenge = BuildTrackedChallenge(target: 5); + StubLogs(HabitLog.Create(_habitId, Today, 1)); + + await _service.EvaluateOnHabitLoggedAsync(_user.Id, _habitId, CancellationToken.None); + + challenge.Status.Should().Be(ChallengeStatus.Active); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task HabitNotLinkedToAnyChallenge_IsNoOp() + { + _participantHabitRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List().AsReadOnly()); + + await _service.EvaluateOnHabitLoggedAsync(_user.Id, _habitId, CancellationToken.None); + + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task LoggerIsNotAnActiveParticipant_IsNoOp() + { + BuildTrackedChallenge(target: 1); + _participantRepository.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List().AsReadOnly()); + StubLogs(HabitLog.Create(_habitId, Today, 1)); + + await _service.EvaluateOnHabitLoggedAsync(_user.Id, _habitId, CancellationToken.None); + + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Orbit.Domain.Tests/Entities/ChallengeTests.cs b/tests/Orbit.Domain.Tests/Entities/ChallengeTests.cs new file mode 100644 index 00000000..9b3d4fa4 --- /dev/null +++ b/tests/Orbit.Domain.Tests/Entities/ChallengeTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Tests.Entities; + +public class ChallengeTests +{ + private static readonly Guid CreatorId = Guid.NewGuid(); + private static readonly DateOnly PeriodStart = new(2026, 3, 1); + private static readonly DateOnly PeriodEnd = new(2026, 3, 31); + + private static CreateChallengeParams CoopGoalParams(int? targetCount = 30) => + new(CreatorId, ChallengeType.CoopGoal, "March Push-ups", "Together", targetCount, PeriodStart, PeriodEnd, "ABC23456"); + + private static CreateChallengeParams StreakParams(int? targetCount = null, DateOnly? periodEnd = null) => + new(CreatorId, ChallengeType.StreakTogether, "Daily Reading", null, targetCount, PeriodStart, periodEnd, "XYZ78999"); + + [Fact] + public void Create_ValidCoopGoal_ReturnsActiveChallenge() + { + var result = Challenge.Create(CoopGoalParams()); + + result.IsSuccess.Should().BeTrue(); + result.Value.Type.Should().Be(ChallengeType.CoopGoal); + result.Value.Status.Should().Be(ChallengeStatus.Active); + result.Value.TargetCount.Should().Be(30); + result.Value.JoinCode.Should().Be("ABC23456"); + result.Value.Participants.Should().BeEmpty(); + } + + [Fact] + public void Create_CoopGoalWithoutTarget_Fails() + { + var result = Challenge.Create(CoopGoalParams(targetCount: null)); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("CHALLENGE_TARGET_REQUIRED"); + } + + [Fact] + public void Create_CoopGoalWithNonPositiveTarget_Fails() + { + var result = Challenge.Create(CoopGoalParams(targetCount: 0)); + + result.ErrorCode.Should().Be("CHALLENGE_TARGET_REQUIRED"); + } + + [Fact] + public void Create_StreakWithTarget_Fails() + { + var result = Challenge.Create(StreakParams(targetCount: 10)); + + result.ErrorCode.Should().Be("CHALLENGE_TARGET_NOT_ALLOWED"); + } + + [Fact] + public void Create_StreakWithoutTarget_Succeeds() + { + var result = Challenge.Create(StreakParams()); + + result.IsSuccess.Should().BeTrue(); + result.Value.TargetCount.Should().BeNull(); + } + + [Fact] + public void Create_EndBeforeStart_Fails() + { + var invalid = CoopGoalParams() with { PeriodEndUtc = PeriodStart.AddDays(-1) }; + + var result = Challenge.Create(invalid); + + result.ErrorCode.Should().Be("CHALLENGE_PERIOD_INVALID"); + } + + [Fact] + public void Create_BlankTitle_Fails() + { + var invalid = CoopGoalParams() with { Title = " " }; + + var result = Challenge.Create(invalid); + + result.ErrorCode.Should().Be("TITLE_REQUIRED"); + } + + [Fact] + public void AddParticipant_LinksOwnHabitsAndDeduplicates() + { + var challenge = Challenge.Create(CoopGoalParams()).Value; + var habitId = Guid.NewGuid(); + + var participant = challenge.AddParticipant(CreatorId, [habitId, habitId]); + + challenge.Participants.Should().ContainSingle(); + participant.UserId.Should().Be(CreatorId); + participant.IsActive.Should().BeTrue(); + participant.LinkedHabits.Should().ContainSingle(h => h.HabitId == habitId); + } + + [Fact] + public void TryLeave_ActiveParticipant_MarksLeft() + { + var challenge = Challenge.Create(CoopGoalParams()).Value; + challenge.AddParticipant(CreatorId, [Guid.NewGuid()]); + + var left = challenge.TryLeave(CreatorId); + + left.Should().BeTrue(); + challenge.Participants.Single().IsActive.Should().BeFalse(); + challenge.GetActiveParticipants().Should().BeEmpty(); + } + + [Fact] + public void TryLeave_NonParticipant_ReturnsFalse() + { + var challenge = Challenge.Create(CoopGoalParams()).Value; + challenge.AddParticipant(CreatorId, [Guid.NewGuid()]); + + challenge.TryLeave(Guid.NewGuid()).Should().BeFalse(); + } + + [Fact] + public void TryLeave_AlreadyLeft_ReturnsFalse() + { + var challenge = Challenge.Create(CoopGoalParams()).Value; + challenge.AddParticipant(CreatorId, [Guid.NewGuid()]); + challenge.TryLeave(CreatorId); + + challenge.TryLeave(CreatorId).Should().BeFalse(); + } + + [Fact] + public void MarkCompleted_TransitionsOnceThenIsIdempotent() + { + var challenge = Challenge.Create(CoopGoalParams()).Value; + + var first = challenge.MarkCompleted(); + var second = challenge.MarkCompleted(); + + first.Should().BeTrue(); + second.Should().BeFalse(); + challenge.Status.Should().Be(ChallengeStatus.Completed); + challenge.CompletedAtUtc.Should().NotBeNull(); + } +}