diff --git a/src/Orbit.Api/Controllers/GamificationController.cs b/src/Orbit.Api/Controllers/GamificationController.cs index eb27367e..f49473ef 100644 --- a/src/Orbit.Api/Controllers/GamificationController.cs +++ b/src/Orbit.Api/Controllers/GamificationController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; namespace Orbit.Api.Controllers; @@ -51,18 +50,4 @@ public async Task GetStreakInfo(CancellationToken cancellationTok ? Ok(result.Value) : BadRequest(new { error = result.Error }); } - - [HttpPost("streak/freeze")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task ActivateStreakFreeze(CancellationToken cancellationToken) - { - var command = new ActivateStreakFreezeCommand(HttpContext.GetUserId()); - var result = await mediator.Send(command, cancellationToken); - - return result.IsSuccess - ? Ok(result.Value) - : BadRequest(new { error = result.Error }); - } } diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index a283f1e0..31863f09 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -188,7 +188,6 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -345,6 +344,7 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/Orbit.Api/Mcp/Tools/GamificationTools.cs b/src/Orbit.Api/Mcp/Tools/GamificationTools.cs index e8ae52d0..60d55c42 100644 --- a/src/Orbit.Api/Mcp/Tools/GamificationTools.cs +++ b/src/Orbit.Api/Mcp/Tools/GamificationTools.cs @@ -2,7 +2,6 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; namespace Orbit.Api.Mcp.Tools; @@ -91,24 +90,6 @@ public async Task GetStreakInfo( (s.RecentFreezeDates.Count > 0 ? $"Recent freeze dates: {string.Join(", ", s.RecentFreezeDates)}" : ""); } - [McpServerTool(Name = "activate_streak_freeze"), Description("Activate a streak freeze to protect the current streak. Limited to 2 per month.")] - public async Task ActivateStreakFreeze( - ClaimsPrincipal user, - CancellationToken cancellationToken = default) - { - var userId = GetUserId(user); - var command = new ActivateStreakFreezeCommand(userId); - var result = await mediator.Send(command, cancellationToken); - - if (result.IsFailure) - return $"Error: {result.Error}"; - - var r = result.Value; - return $"Streak freeze activated for {r.FrozenDate}\n" + - $"Current streak preserved: {r.CurrentStreak} days\n" + - $"Freezes remaining this month: {r.FreezesRemainingThisMonth}"; - } - private static Guid GetUserId(ClaimsPrincipal user) { var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/ai-memory.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/ai-memory.md new file mode 100644 index 00000000..17fb7fb2 --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/ai-memory.md @@ -0,0 +1,28 @@ +--- +key: ai-memory +display_name: AI Memory +related_capabilities: [profile.ai-memory.write, user-facts.read] +related_surfaces: [ai-settings] +version: 1 +derived_from: + - src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs Handle + - src/Orbit.Application/UserFacts/Commands/CreateUserFactCommand.cs Handle + - src/Orbit.Application/Common/AppConstants.cs MaxUserFacts +--- + +# AI Memory + +AI memory lets the assistant remember compact facts about you across conversations, so you don't have to repeat context every time. **AI memory is a Pro feature** — the toggle to turn it on requires Pro. + +## How it works + +When memory is on, the assistant can save short facts it learns about you and recall them in later chats. You control this with a single on/off toggle. + +## Limits + +- Saved facts are capped at **50** (`MaxUserFacts`). Once you reach the cap, you'll need to delete some before new ones can be added. +- **Duplicate facts are rejected** — if a fact with the same text already exists (ignoring case), it won't be saved again. + +## Turning it off + +Turning memory off stops new facts from being stored. It's the switch that controls whether the assistant is allowed to remember anything new. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/freezes.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/freezes.md new file mode 100644 index 00000000..bf35318a --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/freezes.md @@ -0,0 +1,26 @@ +--- +key: freezes +display_name: Streak Freezes +related_capabilities: [gamification.read] +related_surfaces: [gamification] +version: 1 +derived_from: + - src/Orbit.Domain/Entities/User.cs AwardStreakFreezeIfEligible + - src/Orbit.Domain/Entities/User.cs ApplyStreakFreeze + - src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs ProcessUserAsync + - src/Orbit.Application/Common/AppConstants.cs MaxStreakFreezesAccumulated +--- + +# Streak Freezes + +A streak freeze protects your streak on a day you couldn't complete a habit. **Streak freezes are a Pro feature.** + +## Earning freezes + +You earn **1 freeze for every 7 streak-days** (`StreakDaysPerFreeze` = 7). You can bank up to **3** freezes at once (`MaxStreakFreezesAccumulated` = 3); once you're at the cap, new milestones don't add more until one is spent. + +## How freezes are used + +Freezes are **automatic** — there's nothing to tap. When you miss a day on your streak, a banked freeze is spent for you to bridge the gap, so the next completion continues the run instead of starting over. A freeze only **preserves** the streak across a missed day; it does not extend or increase it. + +A freeze is spent automatically only when there's a streak worth protecting and you actually missed the day. It won't be used if your current streak is 0, if you already completed a habit that day, or if you've run out of banked freezes. At most **one** freeze is spent per day, and at most **3** are spent per calendar month (`MaxStreakFreezesPerMonth` = 3) — beyond that, a missed day breaks the streak as usual. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/frequencies.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/frequencies.md new file mode 100644 index 00000000..a3fbdc5f --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/frequencies.md @@ -0,0 +1,39 @@ +--- +key: frequencies +display_name: Habit Frequencies +related_capabilities: [habits.read, habits.write] +related_surfaces: [today] +version: 1 +derived_from: + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsHabitDueOnDate + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetWindowStart + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetWindowEnd + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetRemainingCompletions +--- + +# Habit Frequencies + +A habit's frequency decides which days it is due. Every recurring habit has a unit (Day, Week, Month, or Year), an interval quantity (how many of those units between occurrences), and an anchor date — the habit's due date — that the schedule aligns to. + +## The frequency units + +- **Daily** — due every day, or every N days when the interval is greater than 1 (for example, every 2 days). The interval is counted from the anchor date. +- **Weekly** — due on the same weekday as the anchor, every N weeks (for example, every 2 weeks on Monday). +- **Monthly** — due on the same day-of-month as the anchor, every N months. +- **Yearly** — due on the same month and day as the anchor, every N years. + +The interval quantity is the "every N" part. With a quantity of 1 the habit is due every period; with a quantity of 2 it is due every other period, and so on. A habit is never due before its anchor date, and never after its end date if one is set. + +## Specific weekdays + +A habit can also restrict itself to specific weekdays. When weekdays are chosen, the habit is only due on a matching date if that date's weekday is in the list. This layers on top of the unit and interval. + +## One-time tasks + +A one-time task has no recurring unit. It is due on exactly one date — its due date — and nowhere else. Once completed, it stops appearing. + +## Flexible habits + +A flexible habit doesn't pin you to specific days. Instead it asks for **N completions per window**, where the window is one Day, one Week, one Month, or one Year. Weekly windows run Monday through Sunday (ISO week). Within a window you can log on any days you like until you hit the target. + +Skips make flexible targets more forgiving: each skip in the window reduces the number of completions still required for that window. So if a weekly flexible habit wants 3 completions and you skip once, only 2 completions are needed that week. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/gamification.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/gamification.md new file mode 100644 index 00000000..4bdd3896 --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/gamification.md @@ -0,0 +1,54 @@ +--- +key: gamification +display_name: XP, Levels, and Achievements +related_capabilities: [gamification.read] +related_surfaces: [gamification] +version: 1 +derived_from: + - src/Orbit.Application/Gamification/Services/GamificationService.cs ProcessHabitLogged + - src/Orbit.Application/Gamification/Services/GamificationService.cs ProcessGoalCompleted + - src/Orbit.Application/Gamification/LevelDefinitions.cs All + - src/Orbit.Application/Gamification/Services/GamificationService.cs CheckConsistencyAchievements +--- + +# XP, Levels, and Achievements + +Gamification rewards consistency with experience points (XP), levels, and achievements. **All of gamification — XP, levels, and achievements — is a Pro feature.** On the free plan no XP is earned and no achievements unlock. + +## Earning XP + +- **Logging a habit** earns **10 + your current streak** XP. A habit logged on a 5-day streak gives 15 XP; the longer your streak, the more each completion is worth. +- **Completing a goal** earns **+100** XP. +- Unlocking an achievement also grants that achievement's own XP reward on top. + +## Levels + +Your total XP places you on a level from 1 to 10. The thresholds are: + +| Level | Title | XP required | +|---|---|---| +| 1 | Starter | 0 | +| 2 | Explorer | 100 | +| 3 | Orbiter | 300 | +| 4 | Navigator | 600 | +| 5 | Pilot | 1000 | +| 6 | Captain | 1500 | +| 7 | Commander | 2500 | +| 8 | Admiral | 4000 | +| 9 | Elite | 6000 | +| 10 | Legend | 10000 | + +Level 10 (Legend) is the top — there is no XP-to-next once you reach it. + +## Achievements + +Achievements unlock automatically as you hit milestones: + +- **Consistency** — streaks of 7, 14, 30, 90, 100, and 365 days. +- **Volume** — 10, 50, 100, 500, and 1000 total completions. +- **Perfect runs** — Perfect Day (every scheduled habit done in a day), then Perfect Week (7 consecutive perfect days) and Perfect Month (30 consecutive perfect days). +- **Time of day** — Early Bird (complete a habit before 7am, 10 times) and Night Owl (after 10pm, 10 times). +- **Comeback** — return and log after 7+ days of inactivity. +- **Bad Habit Breaker** — resist a bad habit for 30 consecutive days. + +There are also first-time achievements for creating your first habit and goal, and goal-completion tiers for completing 1, 5, and 10 goals. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/notifications.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/notifications.md new file mode 100644 index 00000000..561043fc --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/notifications.md @@ -0,0 +1,37 @@ +--- +key: notifications +display_name: Reminders and Notifications +related_capabilities: [notifications.read, notifications.write] +related_surfaces: [notifications] +version: 1 +derived_from: + - src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ProcessRelativeReminders + - src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ProcessScheduledReminders + - src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ShouldSendScheduledReminder +--- + +# Reminders and Notifications + +Reminders are sent by a background job that checks roughly **every minute**. There are two kinds, depending on whether a habit has a due time. + +## Relative reminders (habits with a due time) + +If a habit has a specific due **time**, you can set "X minutes before" reminders. The job fires each one when the current local time reaches that many minutes before the due time. A habit can have several relative reminders (for example, 30 minutes before and 10 minutes before). + +## Scheduled reminders (habits without a due time) + +If a habit has no due time, it uses **scheduled** reminders that fire at a time you pick, either: + +- **same-day** — on the day the habit is due, or +- **day-before** — the day before it's due. + +## When reminders fire + +A reminder is only sent for a habit that is: + +- not completed and not a general habit, +- has reminders enabled, +- is actually **due** that day, and +- has **not yet been logged** that day. + +Each distinct reminder is sent **once** — once a given reminder has fired for a habit on a given day, it won't fire again, so you won't be nudged twice for the same thing. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md new file mode 100644 index 00000000..48f4b6ee --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md @@ -0,0 +1,40 @@ +--- +key: paygate +display_name: Free vs Pro +related_capabilities: [subscriptions.read] +related_surfaces: [subscriptions] +version: 1 +derived_from: + - src/Orbit.Application/Common/PayGateService.cs CanCreateHabits + - src/Orbit.Application/Common/PayGateService.cs CanSendAiMessage + - src/Orbit.Application/Common/PayGateService.cs CanUseRetrospective + - src/Orbit.Application/Common/AppConstants.cs DefaultFreeMaxHabits +--- + +# Free vs Pro + +Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the limits and unlocks the advanced features. + +## Limits on the free plan + +- **Habits** are capped at **10** top-level habits. Sub-habits and soft-deleted habits don't count toward the cap. Pro removes the cap. +- **AI messages** are capped at **20** per month. Pro raises this to **500** per month. + +Both plans can also earn a small bonus of extra AI messages from ad rewards, added on top of the plan limit. + +## What Pro unlocks + +Upgrading to Pro unlocks: + +- Goals +- Sub-habits +- The daily AI summary +- AI memory +- Calendar integration +- Premium color schemes +- Streak freezes +- Gamification: XP, levels, and achievements + +## The retrospective is yearly-only + +The **retrospective** is the one feature that needs the **yearly** Pro plan specifically. A monthly Pro subscription does not include it; the yearly plan does. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/schedule-math.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/schedule-math.md new file mode 100644 index 00000000..6f9576f7 --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/schedule-math.md @@ -0,0 +1,36 @@ +--- +key: schedule-math +display_name: Schedule and Overdue Math +related_capabilities: [habits.read] +related_surfaces: [today] +version: 1 +derived_from: + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsMonthlyMatch + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsYearlyMatch + - src/Orbit.Application/Habits/Services/HabitScheduleService.cs HasMissedPastOccurrence + - src/Orbit.Application/Common/AppConstants.cs DefaultOverdueWindowDays +--- + +# Schedule and Overdue Math + +A few scheduling rules surprise people because the calendar isn't uniform. Here is exactly how Orbit handles the tricky cases. + +## Monthly habits clamp to the last valid day + +A monthly habit fires on the same day-of-month as its anchor (due) date. When a month is too short for that day, it clamps to the **last valid day** of that month instead of drifting. A habit anchored on the 31st fires on March 31 — never March 28 — and on the last day of shorter months. This keeps "the 31st" meaning the end of the month rather than slipping earlier permanently. + +## Yearly leap-day habits + +A yearly habit anchored on **February 29** fires on **February 28** in non-leap years, then returns to February 29 when a leap year comes around again. + +## Intervals align off the anchor date + +The "every N" interval (every 2 weeks, every 3 months, and so on) is measured from the anchor date, not from the current date. The anchor is the fixed reference point the whole schedule lines up against. + +## Overdue is DueDate-authoritative + +A habit is **overdue when its due date has fallen before today**. The due date rests on the oldest unresolved occurrence; logging or skipping advances it past today. This single signal — due date earlier than today — is what marks a recurring habit overdue everywhere in the app. + +Bad habits are never overdue (there's no "must do" expectation to miss), and flexible habits use their window instead of an overdue date. + +The default overdue lookback window is **7** days (`DefaultOverdueWindowDays`): the day view surfaces unresolved occurrences from up to a week back so a missed day doesn't silently disappear. diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md new file mode 100644 index 00000000..aa9e5358 --- /dev/null +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md @@ -0,0 +1,39 @@ +--- +key: streaks +display_name: Streaks +related_capabilities: [gamification.read] +related_surfaces: [gamification, today] +version: 1 +derived_from: + - src/Orbit.Infrastructure/Services/UserStreakService.cs ComputeCurrentStreak + - src/Orbit.Infrastructure/Services/UserStreakService.cs LoadStreakDataAsync + - src/Orbit.Infrastructure/Services/UserStreakService.cs CalendarFallback + - src/Orbit.Application/Common/AppConstants.cs MaxStreakLookbackDays +--- + +# Streaks + +Your streak counts how many consecutive **scheduled days** you stayed active. A scheduled day is a day where one of your recurring habits was due. The day counts toward the streak if either: + +- you completed at least one eligible habit that day (a real completion, not a skip), or +- a streak freeze covered that day. + +The streak is measured by walking backwards from today. If today has no completion yet, the count starts from yesterday so an unfinished today never breaks the run. Days where nothing was scheduled are simply skipped over — they neither extend nor break the streak. The first scheduled day you missed (no completion and no freeze) is where the streak stops. + +## What counts as a completion + +A completion is any log with a value greater than zero on a habit that is not deleted and not a bad habit. Skips (a zero value) do not count. Bad habits never **add** scheduled days to your streak, but completing a regular habit on the same day still counts normally — bad habits just don't create the "must do something today" expectation. + +## Which habits create scheduled days + +Expected (scheduled) days come only from your recurring habits that are not bad habits, not general habits, and not flexible habits. One-time tasks that you've already finished stop contributing expected days going forward. So a missed flexible-habit window or a skipped general habit will not break your streak. + +## Brand-new users + +If you have no recurring habits at all yet, the streak falls back to simple **calendar-day adjacency**: completing a habit on back-to-back calendar days builds the streak, so you aren't penalized before you've set up any schedule. + +## Lookback limit + +Streak calculation looks back at most **365** days (`MaxStreakLookbackDays`). Activity older than a year does not extend the current streak. + +The longest streak is tracked separately by scanning your full scheduled-day history for the longest unbroken run; it never decreases when the current streak resets. diff --git a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs index 1283864d..3b5ef23d 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs @@ -4,7 +4,6 @@ using Orbit.Application.ApiKeys.Commands; using Orbit.Application.ApiKeys.Queries; using Orbit.Application.Chat.Tools; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Application.Referrals.Queries; using Orbit.Application.Subscriptions.Commands; @@ -67,26 +66,6 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel } } -public class ActivateStreakFreezeTool(IMediator mediator) : IAiTool -{ - public string Name => "activate_streak_freeze"; - public string Description => "Activate one available streak freeze for the user."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new { } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new ActivateStreakFreezeCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Activated streak freeze", Payload: result.Value) - : new ToolResult(false, EntityId: userId.ToString(), Error: result.Error); - } -} - public class GetReferralOverviewTool(IMediator mediator) : IAiTool { public string Name => "get_referral_overview"; diff --git a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs b/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs deleted file mode 100644 index 41d932a6..00000000 --- a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs +++ /dev/null @@ -1,99 +0,0 @@ -using MediatR; -using Microsoft.EntityFrameworkCore; -using Orbit.Application.Common; -using Orbit.Domain.Common; -using Orbit.Domain.Entities; -using Orbit.Domain.Interfaces; - -namespace Orbit.Application.Gamification.Commands; - -public record StreakFreezeResponse( - int FreezesRemainingThisMonth, - DateOnly FrozenDate, - int CurrentStreak, - int StreakFreezesAccumulated); - -public record ActivateStreakFreezeCommand(Guid UserId) : IRequest>; - -public class ActivateStreakFreezeCommandHandler( - IGenericRepository userRepository, - IGenericRepository streakFreezeRepository, - IGenericRepository habitLogRepository, - IGenericRepository habitRepository, - IUserStreakService userStreakService, - IUserDateService userDateService, - IUnitOfWork unitOfWork) : IRequestHandler> -{ - public async Task> Handle(ActivateStreakFreezeCommand request, CancellationToken cancellationToken) - { - var user = await userRepository.FindOneTrackedAsync( - u => u.Id == request.UserId, - cancellationToken: cancellationToken); - - if (user is null) - return Result.Failure(ErrorMessages.UserNotFound, ErrorCodes.UserNotFound); - - if (!user.HasProAccess) - return Result.PayGateFailure("Streak freezes are a Pro feature. Upgrade to unlock!"); - - var existingStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken); - if (existingStreak is null) - return Result.Failure(ErrorMessages.UserNotFound, ErrorCodes.UserNotFound); - - var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - - if (existingStreak.CurrentStreak <= 0) - return Result.Failure(ErrorMessages.NoActiveStreak, ErrorCodes.NoActiveStreak); - - if (user.StreakFreezesAccumulated <= 0) - return Result.Failure(ErrorMessages.StreakFreezeNotAvailable, ErrorCodes.StreakFreezeNotAvailable); - - var userHabits = await habitRepository.FindAsync(h => h.UserId == request.UserId, cancellationToken); - var habitIds = userHabits.Select(h => h.Id).ToList(); - - if (habitIds.Count > 0) - { - var todayLogs = await habitLogRepository.FindAsync( - l => habitIds.Contains(l.HabitId) && l.Date == today && l.Value > 0, - cancellationToken); - - if (todayLogs.Count > 0) - return Result.Failure("You already completed a habit today. No freeze needed!"); - } - - var existingFreeze = await streakFreezeRepository.FindAsync( - sf => sf.UserId == request.UserId && sf.UsedOnDate == today, - cancellationToken); - - if (existingFreeze.Count > 0) - return Result.Failure(ErrorMessages.AlreadyUsedStreakFreezeToday, ErrorCodes.AlreadyUsedStreakFreezeToday); - - var monthStart = new DateOnly(today.Year, today.Month, 1); - var monthEnd = monthStart.AddMonths(1); - var freezesThisMonth = await streakFreezeRepository.FindAsync( - sf => sf.UserId == request.UserId && sf.UsedOnDate >= monthStart && sf.UsedOnDate < monthEnd, - cancellationToken); - - if (freezesThisMonth.Count >= AppConstants.MaxStreakFreezesPerMonth) - return Result.Failure(ErrorMessages.StreakFreezeMonthlyLimit, ErrorCodes.StreakFreezeMonthlyLimit); - - var consume = user.ConsumeStreakFreeze(); - if (consume.IsFailure) - return Result.Failure(consume.Error!, ErrorCodes.StreakFreezeNotAvailable); - - var freeze = StreakFreeze.Create(request.UserId, today); - await streakFreezeRepository.AddAsync(freeze, cancellationToken); - - await unitOfWork.SaveChangesAsync(cancellationToken); - var updatedStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken); - await unitOfWork.SaveChangesAsync(cancellationToken); - - var freezesRemaining = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - (freezesThisMonth.Count + 1)); - - return Result.Success(new StreakFreezeResponse( - freezesRemaining, - today, - updatedStreak?.CurrentStreak ?? 0, - user.StreakFreezesAccumulated)); - } -} diff --git a/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs b/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs deleted file mode 100644 index 210fa775..00000000 --- a/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; -using Orbit.Application.Gamification.Commands; - -namespace Orbit.Application.Gamification.Validators; - -public class ActivateStreakFreezeCommandValidator : AbstractValidator -{ - public ActivateStreakFreezeCommandValidator() - { - RuleFor(x => x.UserId).NotEmpty(); - } -} diff --git a/src/Orbit.Application/Orbit.Application.csproj b/src/Orbit.Application/Orbit.Application.csproj index 10fc2b56..e333bde0 100644 --- a/src/Orbit.Application/Orbit.Application.csproj +++ b/src/Orbit.Application/Orbit.Application.csproj @@ -4,6 +4,10 @@ + + + + diff --git a/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs b/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs new file mode 100644 index 00000000..b044ca0f --- /dev/null +++ b/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs @@ -0,0 +1,22 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class SentStreakFreezeAlert : Entity +{ + public Guid UserId { get; private set; } + public DateOnly FrozenDate { get; private set; } + public DateTime SentAtUtc { get; private set; } + + private SentStreakFreezeAlert() { } + + public static SentStreakFreezeAlert Create(Guid userId, DateOnly frozenDate) + { + return new SentStreakFreezeAlert + { + UserId = userId, + FrozenDate = frozenDate, + SentAtUtc = DateTime.UtcNow + }; + } +} diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index eba204e7..8e9917c5 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -264,7 +264,6 @@ public static class AgentCapabilityIds public const string CalendarRead = "calendar.read"; public const string CalendarSyncManage = "calendar.sync.manage"; public const string GamificationRead = "gamification.read"; - public const string GamificationWrite = "gamification.write"; public const string ChecklistTemplatesRead = "checklist-templates.read"; public const string ChecklistTemplatesWrite = "checklist-templates.write"; public const string UserFactsRead = "user-facts.read"; @@ -305,7 +304,6 @@ public static class AgentScopes public const string ReadCalendar = "read_calendar"; public const string ManageCalendarSync = "manage_calendar_sync"; public const string ReadGamification = "read_gamification"; - public const string WriteGamification = "write_gamification"; public const string ReadChecklistTemplates = "read_checklist_templates"; public const string WriteChecklistTemplates = "write_checklist_templates"; public const string ReadUserFacts = "read_user_facts"; @@ -344,7 +342,6 @@ public static class AgentScopes ReadCalendar, ManageCalendarSync, ReadGamification, - WriteGamification, ReadChecklistTemplates, WriteChecklistTemplates, ReadUserFacts, diff --git a/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs new file mode 100644 index 00000000..e287d87e --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs @@ -0,0 +1,1601 @@ +// +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("20260604173818_AddSentStreakFreezeAlert")] + partial class AddSentStreakFreezeAlert + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .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.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.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" + }); + }); + + 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.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + 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.ToTable("ChecklistTemplates"); + }); + + 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.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.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("GoalId") + .HasColumnType("uuid"); + + 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.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("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + 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("HabitId") + .HasColumnType("uuid"); + + 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", "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.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.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + 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.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + 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("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .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("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .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.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + 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("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.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .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.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.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.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/20260604173818_AddSentStreakFreezeAlert.cs b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs new file mode 100644 index 00000000..d177294d --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddSentStreakFreezeAlert : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SentStreakFreezeAlerts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FrozenDate = table.Column(type: "date", nullable: false), + SentAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SentStreakFreezeAlerts", x => x.Id); + table.ForeignKey( + name: "FK_SentStreakFreezeAlerts_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SentStreakFreezeAlerts_UserId_FrozenDate", + table: "SentStreakFreezeAlerts", + columns: new[] { "UserId", "FrozenDate" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SentStreakFreezeAlerts"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 0bb23c38..df386208 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1107,6 +1107,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1531,6 +1554,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index fd1487d4..dd902cc8 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -63,6 +63,10 @@ await context.StreakFreezes .Where(sf => sf.UserId == userId) .ExecuteDeleteAsync(cancellationToken); + await context.SentStreakFreezeAlerts + .Where(a => a.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.ApiKeys .Where(k => k.UserId == userId) .ExecuteDeleteAsync(cancellationToken); diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 1da9700a..2fb81d11 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -35,6 +35,7 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet PushSubscriptions => Set(); public DbSet SentReminders => Set(); public DbSet SentSlipAlerts => Set(); + public DbSet SentStreakFreezeAlerts => Set(); public DbSet Notifications => Set(); public DbSet Goals => Set(); public DbSet GoalProgressLogs => Set(); @@ -102,6 +103,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(a => new { a.HabitId, a.WeekStart }).IsUnique(); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(a => new { a.UserId, a.FrozenDate }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(n => new { n.UserId, n.IsRead }); diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index c457d825..52d69797 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -877,21 +877,6 @@ private static IReadOnlyList BuildCapabilities() "GamificationController.GetStreakInfo" ]), - CreateCapability( - AgentCapabilityIds.GamificationWrite, - "Write Gamification", - "Uses streak-freeze or equivalent game-state mutations.", - "gamification", - AgentScopes.WriteGamification, - AgentRiskClass.Low, - isMutation: true, - isPhaseOneReadOnly: false, - AgentConfirmationRequirement.None, - planRequirement: "Pro", - chatTools: ["activate_streak_freeze"], - mcpTools: ["activate_streak_freeze"], - controllerActions: ["GamificationController.ActivateStreakFreeze"]), - CreateCapability( AgentCapabilityIds.ChecklistTemplatesRead, "Read Checklist Templates", @@ -1226,8 +1211,8 @@ private static IReadOnlyList BuildSurfaces() "Shows streaks, freezes, XP, levels, and achievements.", ["Open streak profile or achievements.", "Review freeze availability.", "Activate a freeze when needed."], ["Freeze activation is a mutation.", "Level and XP are derived state."], - [AgentCapabilityIds.GamificationRead, AgentCapabilityIds.GamificationWrite], - ["GamificationController.GetProfile", "GamificationController.ActivateStreakFreeze"]), + [AgentCapabilityIds.GamificationRead], + ["GamificationController.GetProfile"]), new AppSurface( "referrals", diff --git a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs index cf789987..172936c1 100644 --- a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs +++ b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs @@ -13,6 +13,7 @@ public class BackgroundServiceHealthCheck : IHealthCheck ["GoalDeadlineNotification"] = TimeSpan.FromMinutes(90), ["SlipAlertScheduler"] = TimeSpan.FromMinutes(15), ["HabitDueDateAdvancement"] = TimeSpan.FromMinutes(90), + ["StreakFreezeAutoActivation"] = TimeSpan.FromMinutes(180), ["AccountDeletion"] = TimeSpan.FromHours(72), ["SyncCleanup"] = TimeSpan.FromHours(48) }; diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs new file mode 100644 index 00000000..6d50bb94 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +/// +/// Auto-activates a streak freeze for a Pro user who held an active streak but logged +/// nothing on their fully-elapsed local "yesterday". Inserting a +/// row for the missed date is sufficient to preserve the streak: the presence-based +/// resolver in treats any date carrying a freeze as covered, +/// so the next on-read RecalculateAsync keeps CurrentStreak intact without mutating it here. +/// Idempotency is enforced by two unique guards — the StreakFreeze (UserId, UsedOnDate) index +/// and the SentStreakFreezeAlert (UserId, FrozenDate) index — both re-checked before spending. +/// +public partial class StreakFreezeAutoActivationService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration configuration) : BackgroundService +{ + private readonly TimeSpan _interval = TimeSpan.FromMinutes( + configuration.GetValue("BackgroundServices:StreakFreezeIntervalMinutes", 60)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + LogServiceStarted(logger); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ActivateMissedDayFreezes(stoppingToken); + BackgroundServiceHealthCheck.RecordTick("StreakFreezeAutoActivation"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogServiceError(logger, ex); + } + + await Task.Delay(_interval, stoppingToken); + } + } + finally + { + LogServiceStopped(logger); + } + } + + private async Task ActivateMissedDayFreezes(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var pushService = scope.ServiceProvider.GetRequiredService(); + + // Conservative UTC pre-filter: a user can only have a fully-elapsed missed day if their + // last active date is already before UTC yesterday. The per-user local-yesterday guard + // below is the authoritative check. HasProAccess is computed (not mapped) so it is gated + // in memory per user rather than in SQL. + var utcYesterday = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); + var candidates = await dbContext.Users + .Where(u => u.CurrentStreak > 0 + && u.StreakFreezesAccumulated > 0 + && u.LastActiveDate != null + && u.LastActiveDate < utcYesterday) + .ToListAsync(ct); + + if (candidates.Count == 0) return; + + var candidateIds = candidates.Select(u => u.Id).ToList(); + + var monthFloor = utcYesterday.AddDays(-1).AddMonths(-1); + var freezesByUser = (await dbContext.StreakFreezes + .Where(f => candidateIds.Contains(f.UserId) && f.UsedOnDate >= monthFloor) + .ToListAsync(ct)) + .GroupBy(f => f.UserId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var guardedByUser = (await dbContext.SentStreakFreezeAlerts + .Where(a => candidateIds.Contains(a.UserId) && a.FrozenDate >= monthFloor) + .ToListAsync(ct)) + .GroupBy(a => a.UserId) + .ToDictionary(g => g.Key, g => g.Select(a => a.FrozenDate).ToHashSet()); + + var completionsByUser = await LoadRecentCompletionsAsync(dbContext, candidateIds, monthFloor, ct); + + var anyChanges = false; + foreach (var user in candidates) + { + anyChanges |= await ProcessUserAsync(user, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); + } + + if (anyChanges) + await dbContext.SaveChangesAsync(ct); + } + + private static async Task>> LoadRecentCompletionsAsync( + OrbitDbContext dbContext, List userIds, DateOnly since, CancellationToken ct) + { + var habitOwners = await dbContext.Habits + .Where(h => userIds.Contains(h.UserId) && !h.IsDeleted && !h.IsBadHabit) + .Select(h => new { h.Id, h.UserId }) + .ToListAsync(ct); + + var ownerByHabit = habitOwners.ToDictionary(h => h.Id, h => h.UserId); + var habitIds = habitOwners.Select(h => h.Id).ToList(); + if (habitIds.Count == 0) return new Dictionary>(); + + var logs = await dbContext.HabitLogs + .Where(l => habitIds.Contains(l.HabitId) && l.Value > 0 && l.Date >= since) + .Select(l => new { l.HabitId, l.Date }) + .ToListAsync(ct); + + var completions = new Dictionary>(); + foreach (var log in logs) + { + if (!ownerByHabit.TryGetValue(log.HabitId, out var ownerId)) continue; + if (!completions.TryGetValue(ownerId, out var dates)) + { + dates = []; + completions[ownerId] = dates; + } + dates.Add(log.Date); + } + return completions; + } + + private async Task ProcessUserAsync( + User user, + Dictionary> freezesByUser, + Dictionary> guardedByUser, + Dictionary> completionsByUser, + IPushNotificationService pushService, + OrbitDbContext dbContext, + CancellationToken ct) + { + if (!user.HasProAccess) return false; + + var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id); + var userToday = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz)); + var missedDate = userToday.AddDays(-1); + + // Authoritative local guard: the missed day must be fully elapsed (strictly before today) + // and the user must not already be credited as active on or after it. + if (user.LastActiveDate is null || user.LastActiveDate >= missedDate) return false; + + var existingFreezes = freezesByUser.GetValueOrDefault(user.Id) ?? []; + if (existingFreezes.Any(f => f.UsedOnDate == missedDate)) return false; + + var guardedDates = guardedByUser.GetValueOrDefault(user.Id) ?? []; + if (guardedDates.Contains(missedDate)) return false; + + var completions = completionsByUser.GetValueOrDefault(user.Id) ?? []; + if (completions.Contains(missedDate)) return false; + + var monthStart = new DateOnly(missedDate.Year, missedDate.Month, 1); + var monthEnd = monthStart.AddMonths(1); + var freezesThisMonth = existingFreezes.Count(f => f.UsedOnDate >= monthStart && f.UsedOnDate < monthEnd); + if (freezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return false; + + var consume = user.ConsumeStreakFreeze(); + if (consume.IsFailure) return false; + + var freeze = StreakFreeze.Create(user.Id, missedDate); + dbContext.StreakFreezes.Add(freeze); + existingFreezes.Add(freeze); + freezesByUser[user.Id] = existingFreezes; + + dbContext.SentStreakFreezeAlerts.Add(SentStreakFreezeAlert.Create(user.Id, missedDate)); + + var (title, body) = BuildNotification(user.CurrentStreak, user.Language ?? "en"); + dbContext.Notifications.Add(Notification.Create(user.Id, title, body, StreakUrl)); + await pushService.SendToUserAsync(user.Id, title, body, StreakUrl, ct); + + if (logger.IsEnabled(LogLevel.Information)) + LogFreezeActivated(logger, user.Id, missedDate); + return true; + } + + private const string StreakUrl = "/streak"; + + internal static (string Title, string Body) BuildNotification(int currentStreak, string lang) + { + var isPt = LocaleHelper.IsPortuguese(lang); + return isPt + ? ("Sequência protegida", $"Usamos um congelamento para manter sua sequência de {currentStreak} dias depois de um dia sem registro.") + : ("Streak protected", $"We used a freeze to keep your {currentStreak}-day streak alive after a day with no check-ins."); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "StreakFreezeAutoActivationService started")] + private static partial void LogServiceStarted(ILogger logger); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "StreakFreezeAutoActivationService stopped")] + private static partial void LogServiceStopped(ILogger logger); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Error in streak freeze auto-activation")] + private static partial void LogServiceError(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "Auto-activated streak freeze for user {UserId} on {FrozenDate}")] + private static partial void LogFreezeActivated(ILogger logger, Guid userId, DateOnly frozenDate); +} diff --git a/tests/Orbit.Application.Tests/Chat/FeatureExplanationResourceTests.cs b/tests/Orbit.Application.Tests/Chat/FeatureExplanationResourceTests.cs new file mode 100644 index 00000000..18a5c2e0 --- /dev/null +++ b/tests/Orbit.Application.Tests/Chat/FeatureExplanationResourceTests.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using FluentAssertions; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Chat; + +public class FeatureExplanationResourceTests +{ + private const string Prefix = "Orbit.Application.Chat.Content.FeatureExplanations."; + + private static readonly string[] ExpectedKeys = + [ + "streaks", + "frequencies", + "gamification", + "paygate", + "schedule-math", + "freezes", + "notifications", + "ai-memory", + ]; + + private static Assembly ApplicationAssembly => typeof(AppConstants).Assembly; + + [Fact] + public void EmbedsExactlyTheEightFeatureExplanationFiles() + { + var names = ApplicationAssembly + .GetManifestResourceNames() + .Where(n => n.StartsWith(Prefix, StringComparison.Ordinal)) + .ToList(); + + var expectedNames = ExpectedKeys.Select(key => $"{Prefix}{key}.md"); + + names.Should().BeEquivalentTo(expectedNames); + } + + [Theory] + [InlineData("streaks")] + [InlineData("frequencies")] + [InlineData("gamification")] + [InlineData("paygate")] + [InlineData("schedule-math")] + [InlineData("freezes")] + [InlineData("notifications")] + [InlineData("ai-memory")] + public void EachResourceLoadsWithFrontmatterKeyMatchingItsFilename(string key) + { + using var stream = ApplicationAssembly.GetManifestResourceStream($"{Prefix}{key}.md"); + stream.Should().NotBeNull(); + + using var reader = new StreamReader(stream!); + var content = reader.ReadToEnd(); + + content.Should().StartWith("---"); + content.Should().Contain($"key: {key}"); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs index 95cb2b73..3a375a14 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs @@ -9,7 +9,6 @@ using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.ChecklistTemplates.Commands; using Orbit.Application.ChecklistTemplates.Queries; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Application.Referrals.Queries; using Orbit.Application.Profile.Commands; @@ -37,7 +36,6 @@ public void ToolMetadata_ExposesNamesAndSchemas() var userFactsTool = new GetUserFactsTool(mediator); var deleteUserFactsTool = new DeleteUserFactsTool(mediator); var gamificationTool = new GetGamificationOverviewTool(mediator); - var activateStreakFreezeTool = new ActivateStreakFreezeTool(mediator); var referralTool = new GetReferralOverviewTool(mediator); var subscriptionOverviewTool = new GetSubscriptionOverviewTool(mediator); var manageSubscriptionTool = new ManageSubscriptionTool(mediator); @@ -67,9 +65,6 @@ public void ToolMetadata_ExposesNamesAndSchemas() gamificationTool.IsReadOnly.Should().BeTrue(); JsonSerializer.Serialize(gamificationTool.GetParameterSchema()).Should().Contain("include_achievements"); - activateStreakFreezeTool.Name.Should().Be("activate_streak_freeze"); - JsonSerializer.Serialize(activateStreakFreezeTool.GetParameterSchema()).Should().Contain("properties"); - referralTool.Name.Should().Be("get_referral_overview"); referralTool.IsReadOnly.Should().BeTrue(); @@ -403,34 +398,6 @@ public async Task GetGamificationOverviewTool_ReturnsFailureWhenStreakFails() result.Error.Should().Be("streak_failed"); } - [Fact] - public async Task ActivateStreakFreezeTool_ReturnsSuccess() - { - var mediator = Substitute.For(); - mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new StreakFreezeResponse(1, new DateOnly(2026, 4, 14), 5, 0))); - var tool = new ActivateStreakFreezeTool(mediator); - - var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); - - result.Success.Should().BeTrue(); - result.EntityName.Should().Be("Activated streak freeze"); - } - - [Fact] - public async Task ActivateStreakFreezeTool_ReturnsFailure() - { - var mediator = Substitute.For(); - mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("freeze_failed")); - var tool = new ActivateStreakFreezeTool(mediator); - - var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); - - result.Success.Should().BeFalse(); - result.Error.Should().Be("freeze_failed"); - } - [Fact] public async Task GetReferralOverviewTool_ReturnsFailure() { diff --git a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs deleted file mode 100644 index 64f90a6f..00000000 --- a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs +++ /dev/null @@ -1,208 +0,0 @@ -using FluentAssertions; -using NSubstitute; -using Orbit.Application.Gamification.Commands; -using Orbit.Domain.Entities; -using Orbit.Domain.Models; -using Orbit.Domain.Enums; -using Orbit.Domain.Interfaces; -using System.Linq.Expressions; - -namespace Orbit.Application.Tests.Commands.Gamification; - -public class ActivateStreakFreezeCommandHandlerTests -{ - private readonly IGenericRepository _userRepo = Substitute.For>(); - private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); - private readonly IGenericRepository _habitLogRepo = Substitute.For>(); - private readonly IGenericRepository _habitRepo = Substitute.For>(); - private readonly IUserStreakService _userStreakService = Substitute.For(); - private readonly IUserDateService _userDateService = Substitute.For(); - private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ActivateStreakFreezeCommandHandler _handler; - - private static readonly Guid UserId = Guid.NewGuid(); - private static readonly DateOnly Today = new(2026, 4, 3); - - public ActivateStreakFreezeCommandHandlerTests() - { - _handler = new ActivateStreakFreezeCommandHandler( - _userRepo, _streakFreezeRepo, _habitLogRepo, _habitRepo, _userStreakService, _userDateService, _unitOfWork); - _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); - } - - private static User CreateUserWithStreak(int streak = 5, int accumulatedFreezes = 1) - { - var user = User.Create("Test User", "test@example.com").Value; - user.UpdateStreak(Today.AddDays(-1)); - for (int i = streak - 1; i >= 1; i--) - { - user.UpdateStreak(Today.AddDays(-i)); - } - // Simulate accumulated freezes without requiring the streak to be a multiple of 7. - while (user.StreakFreezesAccumulated < accumulatedFreezes) - { - if (!user.AwardStreakFreezeIfEligible(accumulatedFreezes, 1)) - { - break; - } - } - return user; - } - - [Fact] - public async Task Handle_UserNotFound_ReturnsFailure() - { - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns((User?)null); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("User not found"); - } - - [Fact] - public async Task Handle_NoActiveStreak_ReturnsFailure() - { - var user = User.Create("Test", "test@example.com").Value; - // Streak is 0 by default - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(0, 0, null)); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No active streak"); - } - - [Fact] - public async Task Handle_AlreadyFrozenToday_ReturnsFailure() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(5, 5, Today.AddDays(-1))); - - // User has no habits (no need to check logs) - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // Already has a freeze today - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List { StreakFreeze.Create(UserId, Today) }.AsReadOnly(), - new List().AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already used"); - } - - [Fact] - public async Task Handle_MaxFreezesReached_ReturnsFailure() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(5, 5, Today.AddDays(-1))); - - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // No freeze today but 3 recent freezes (at max) - var recentFreezes = new List - { - StreakFreeze.Create(UserId, Today.AddDays(-1)), - StreakFreeze.Create(UserId, Today.AddDays(-5)), - StreakFreeze.Create(UserId, Today.AddDays(-10)) - }; - - // First call: check existing freeze today (empty) - // Second call: check rolling window (3 freezes) - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List().AsReadOnly(), - recentFreezes.AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("streak freezes this month"); - } - - [Fact] - public async Task Handle_ValidFreeze_ReturnsSuccess() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns( - new UserStreakState(5, 5, Today.AddDays(-1)), - new UserStreakState(5, 5, Today)); - - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // No existing freeze today, no recent freezes - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List().AsReadOnly(), - new List().AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value.FrozenDate.Should().Be(Today); - result.Value.FreezesRemainingThisMonth.Should().Be(2); - - await _streakFreezeRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); - await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); - } -} diff --git a/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs deleted file mode 100644 index 90819a3b..00000000 --- a/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation.TestHelper; -using Orbit.Application.Gamification.Commands; -using Orbit.Application.Gamification.Validators; - -namespace Orbit.Application.Tests.Validators; - -public class ActivateStreakFreezeCommandValidatorTests -{ - private readonly ActivateStreakFreezeCommandValidator _validator = new(); - - private static ActivateStreakFreezeCommand ValidCommand() => new( - UserId: Guid.NewGuid()); - - [Fact] - public void Validate_ValidCommand_NoErrors() - { - var result = _validator.TestValidate(ValidCommand()); - result.ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Validate_EmptyUserId_HasError() - { - var result = _validator.TestValidate(ValidCommand() with { UserId = Guid.Empty }); - result.ShouldHaveValidationErrorFor(x => x.UserId); - } -} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs index 2f5f1545..fa877ab5 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc; using NSubstitute; using Orbit.Api.Controllers; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Common; @@ -102,28 +101,4 @@ public async Task GetStreakInfo_Failure_ReturnsBadRequest() result.Should().BeOfType(); } - - // --- ActivateStreakFreeze --- - - [Fact] - public async Task ActivateStreakFreeze_Success_ReturnsOk() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(default(StreakFreezeResponse)!)); - - var result = await _controller.ActivateStreakFreeze(CancellationToken.None); - - result.Should().BeOfType(); - } - - [Fact] - public async Task ActivateStreakFreeze_Failure_ReturnsBadRequest() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("No freeze available")); - - var result = await _controller.ActivateStreakFreeze(CancellationToken.None); - - result.Should().BeOfType(); - } } diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs index 265a6199..0ddc59bb 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs @@ -3,7 +3,6 @@ using MediatR; using NSubstitute; using Orbit.Api.Mcp.Tools; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Common; @@ -125,29 +124,4 @@ public async Task GetStreakInfo_Failure_ReturnsError() result.Should().StartWith("Error: "); } - - [Fact] - public async Task ActivateStreakFreeze_Success_ReturnsActivatedMessage() - { - var response = new StreakFreezeResponse(1, new DateOnly(2026, 4, 3), 15, 0); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(response)); - - var result = await _tools.ActivateStreakFreeze(_user); - - result.Should().Contain("Streak freeze activated"); - result.Should().Contain("streak preserved: 15 days"); - result.Should().Contain("Freezes remaining this month: 1"); - } - - [Fact] - public async Task ActivateStreakFreeze_Failure_ReturnsError() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("No freezes remaining")); - - var result = await _tools.ActivateStreakFreeze(_user); - - result.Should().StartWith("Error: "); - } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs new file mode 100644 index 00000000..6356f456 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs @@ -0,0 +1,278 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +/// +/// Tests the pure pieces of StreakFreezeAutoActivationService: the localized notification +/// copy, the interval default, the SentStreakFreezeAlert guard entity, and the eligibility +/// predicate (replicated here and asserted across include/exclude cases). The background +/// loop and DB interactions are integration concerns. +/// +public class StreakFreezeAutoActivationServiceTests +{ + private static readonly BindingFlags PrivateStatic = + BindingFlags.NonPublic | BindingFlags.Static; + + // --- Notification copy --- + + [Fact] + public void BuildNotification_English_MentionsStreakLengthAndFreeze() + { + var (title, body) = InvokeBuildNotification(14, "en"); + + title.Should().Be("Streak protected"); + body.Should().Contain("14-day"); + body.Should().Contain("freeze"); + } + + [Fact] + public void BuildNotification_Portuguese_UsesPortugueseCopy() + { + var (title, body) = InvokeBuildNotification(14, "pt-BR"); + + title.Should().Be("Sequência protegida"); + body.Should().Contain("14 dias"); + body.Should().Contain("congelamento"); + } + + [Fact] + public void BuildNotification_UnknownLanguage_FallsBackToEnglish() + { + var (title, _) = InvokeBuildNotification(3, "fr"); + + title.Should().Be("Streak protected"); + } + + // --- Interval default --- + + [Fact] + public void IntervalDefault_Is60Minutes() + { + // The service reads BackgroundServices:StreakFreezeIntervalMinutes with a default of 60. + // GetValue is invoked at field init; assert the documented default constant directly + // by constructing with empty configuration. + var defaultMinutes = GetConfiguredIntervalMinutesDefault(); + defaultMinutes.Should().Be(60); + } + + // --- SentStreakFreezeAlert entity --- + + [Fact] + public void SentStreakFreezeAlert_Create_SetsFieldsCorrectly() + { + var userId = Guid.NewGuid(); + var frozenDate = new DateOnly(2026, 6, 3); + + var alert = SentStreakFreezeAlert.Create(userId, frozenDate); + + alert.UserId.Should().Be(userId); + alert.FrozenDate.Should().Be(frozenDate); + alert.SentAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + // --- Local-yesterday computation --- + + [Fact] + public void MissedDate_IsLocalToday_MinusOneDay() + { + var userToday = new DateOnly(2026, 6, 4); + var missedDate = userToday.AddDays(-1); + + missedDate.Should().Be(new DateOnly(2026, 6, 3)); + } + + // --- Eligibility predicate (replicates ProcessUserAsync guards) --- + + private static bool IsEligible(EligibilityCase c) + { + if (!c.HasProAccess) return false; + if (c.LastActiveDate is null || c.LastActiveDate >= c.MissedDate) return false; + if (c.HasFreezeOnMissedDate) return false; + if (c.HasGuardOnMissedDate) return false; + if (c.HasCompletionOnMissedDate) return false; + if (c.FreezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return false; + if (c.StreakFreezesAccumulated <= 0) return false; + return true; + } + + private sealed record EligibilityCase + { + public bool HasProAccess { get; init; } = true; + public DateOnly MissedDate { get; init; } = new(2026, 6, 3); + public DateOnly? LastActiveDate { get; init; } = new(2026, 6, 2); + public bool HasFreezeOnMissedDate { get; init; } + public bool HasGuardOnMissedDate { get; init; } + public bool HasCompletionOnMissedDate { get; init; } + public int FreezesThisMonth { get; init; } + public int StreakFreezesAccumulated { get; init; } = 2; + } + + [Fact] + public void Eligibility_AllConditionsMet_IsEligible() + { + IsEligible(new EligibilityCase()).Should().BeTrue(); + } + + [Fact] + public void Eligibility_NotPro_Excluded() + { + IsEligible(new EligibilityCase { HasProAccess = false }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_LastActiveOnMissedDate_Excluded() + { + // User was credited active on the "missed" day -> not actually missed. + IsEligible(new EligibilityCase { LastActiveDate = new DateOnly(2026, 6, 3) }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_LastActiveAfterMissedDate_Excluded() + { + IsEligible(new EligibilityCase { LastActiveDate = new DateOnly(2026, 6, 4) }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_NoLastActiveDate_Excluded() + { + IsEligible(new EligibilityCase { LastActiveDate = null }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_FreezeAlreadyOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasFreezeOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_GuardAlreadyOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasGuardOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_CompletionOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasCompletionOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_MonthlyCapReached_Excluded() + { + IsEligible(new EligibilityCase { FreezesThisMonth = AppConstants.MaxStreakFreezesPerMonth }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_NoInventory_Excluded() + { + IsEligible(new EligibilityCase { StreakFreezesAccumulated = 0 }).Should().BeFalse(); + } + + // --- Completion loading (LoadRecentCompletionsAsync excludes soft-deleted habits) --- + + [Fact] + public async Task LoadRecentCompletions_SoftDeletedHabitLog_DoesNotMarkDateActive() + { + // A soft-deleted habit's completion log must not count as activity: otherwise it would + // falsely cover the missed date and suppress the auto-freeze even though no live habit + // was completed. Mirrors UserStreakService, which also excludes IsDeleted habits. + var userId = Guid.NewGuid(); + var missedDate = new DateOnly(2026, 6, 3); + var since = missedDate.AddDays(-1); + + await using var context = CreateInMemoryContext(); + + var activeHabit = CreateHabit(userId); + var deletedHabit = CreateHabit(userId); + deletedHabit.SoftDelete(); + context.Habits.AddRange(activeHabit, deletedHabit); + + deletedHabit.Log(missedDate); + context.HabitLogs.AddRange(deletedHabit.Logs); + await context.SaveChangesAsync(); + + var completionsByUser = await InvokeLoadRecentCompletions(context, [userId], since); + + var completionDates = completionsByUser.GetValueOrDefault(userId) ?? []; + completionDates.Should().NotContain(missedDate); + } + + [Fact] + public async Task LoadRecentCompletions_LiveHabitLog_MarksDateActive() + { + var userId = Guid.NewGuid(); + var completedDate = new DateOnly(2026, 6, 3); + var since = completedDate.AddDays(-1); + + await using var context = CreateInMemoryContext(); + + var activeHabit = CreateHabit(userId); + context.Habits.Add(activeHabit); + + activeHabit.Log(completedDate); + context.HabitLogs.AddRange(activeHabit.Logs); + await context.SaveChangesAsync(); + + var completionsByUser = await InvokeLoadRecentCompletions(context, [userId], since); + + completionsByUser.GetValueOrDefault(userId).Should().Contain(completedDate); + } + + // --- Helpers --- + + private static (string Title, string Body) InvokeBuildNotification(int currentStreak, string lang) + { + var method = typeof(StreakFreezeAutoActivationService) + .GetMethod("BuildNotification", PrivateStatic)!; + return ((string, string))method.Invoke(null, [currentStreak, lang])!; + } + + private static OrbitDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OrbitDbContext(options); + } + + private static Habit CreateHabit(Guid userId) => + Habit.Create(new HabitCreateParams( + userId, "Habit", FrequencyUnit.Day, 1, DueDate: new DateOnly(2026, 6, 3))).Value; + + private static async Task>> InvokeLoadRecentCompletions( + OrbitDbContext context, List userIds, DateOnly since) + { + var method = typeof(StreakFreezeAutoActivationService) + .GetMethod("LoadRecentCompletionsAsync", PrivateStatic)!; + var task = (Task>>)method.Invoke( + null, [context, userIds, since, CancellationToken.None])!; + return await task; + } + + private static int GetConfiguredIntervalMinutesDefault() + { + // Construct the service with an empty configuration so the field initializer resolves + // to the hard-coded default, then read back the private _interval field. + var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); + var service = (StreakFreezeAutoActivationService)Activator.CreateInstance( + typeof(StreakFreezeAutoActivationService), + Substitute.For(), + Substitute.For>(), + configuration)!; + var interval = (TimeSpan)typeof(StreakFreezeAutoActivationService) + .GetField("_interval", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(service)!; + return (int)interval.TotalMinutes; + } +}