From 96cf9d637add90bed8f42dd20b52ed149b546e71 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 20 Mar 2026 14:35:28 -0300 Subject: [PATCH] Security hardening + DRY refactor across API codebase Emergency fixes: mandatory Stripe webhook signature verification, fix inverted HTTPS redirect, set Stripe API key globally at startup. Security: add SecurityHeadersMiddleware, restrict CORS to explicit methods/headers, add 10MB request body limit, validate chat history size and Stripe checkout intervals. DRY: extract ErrorMessages, AppConstants, CacheInvalidationHelper, ResultExtensions, ResultActionResultExtensions, SharedHabitRules. Apply across 30+ command handlers and 5 controllers. Replace goto with flag+continue in BulkCreateHabitsCommand. Add logging to TagsController. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 66 ++++++++++++++++++- src/Orbit.Api/Controllers/ChatController.cs | 13 ++-- src/Orbit.Api/Controllers/HabitsController.cs | 20 ++---- .../Controllers/SubscriptionController.cs | 42 ++++++------ src/Orbit.Api/Controllers/TagsController.cs | 25 +++++-- .../ResultActionResultExtensions.cs | 27 ++++++++ .../Middleware/SecurityHeadersMiddleware.cs | 15 +++++ src/Orbit.Api/Program.cs | 18 ++++- .../Chat/Commands/ProcessUserChatCommand.cs | 13 ++-- src/Orbit.Application/Common/AppConstants.cs | 15 +++++ .../Common/CacheInvalidationHelper.cs | 16 +++++ src/Orbit.Application/Common/ErrorMessages.cs | 13 ++++ .../Common/ResultExtensions.cs | 20 ++++++ .../Commands/BulkCreateHabitsCommand.cs | 24 +++---- .../Commands/BulkDeleteHabitsCommand.cs | 8 +-- .../Habits/Commands/CreateHabitCommand.cs | 16 ++--- .../Habits/Commands/CreateSubHabitCommand.cs | 14 ++-- .../Habits/Commands/DeleteHabitCommand.cs | 12 ++-- .../Habits/Commands/DuplicateHabitCommand.cs | 10 +-- .../Habits/Commands/LogHabitCommand.cs | 19 ++---- .../Habits/Commands/MoveHabitParentCommand.cs | 5 +- .../Habits/Commands/UpdateHabitCommand.cs | 10 +-- .../Habits/Queries/GetDailySummaryQuery.cs | 7 +- .../Habits/Queries/GetHabitByIdQuery.cs | 3 +- .../Habits/Queries/GetHabitLogsQuery.cs | 5 +- .../Habits/Queries/GetHabitMetricsQuery.cs | 5 +- .../Validators/CreateHabitCommandValidator.cs | 17 ++--- .../Habits/Validators/SharedHabitRules.cs | 32 +++++++++ .../Validators/UpdateHabitCommandValidator.cs | 8 +-- .../Commands/CompleteOnboardingCommand.cs | 3 +- .../Commands/DismissMissionsCommand.cs | 3 +- .../Commands/MarkTourCompletedCommand.cs | 3 +- .../Profile/Commands/SetAiMemoryCommand.cs | 3 +- .../Profile/Commands/SetAiSummaryCommand.cs | 3 +- .../Profile/Commands/SetLanguageCommand.cs | 3 +- .../Profile/Commands/SetTimezoneCommand.cs | 3 +- .../Tags/Commands/AssignTagsCommand.cs | 3 +- .../Tags/Commands/DeleteTagCommand.cs | 3 +- .../Tags/Commands/UpdateTagCommand.cs | 3 +- .../Commands/DeleteUserFactCommand.cs | 3 +- .../Commands/UpdateUserFactCommand.cs | 3 +- 41 files changed, 353 insertions(+), 181 deletions(-) create mode 100644 src/Orbit.Api/Extensions/ResultActionResultExtensions.cs create mode 100644 src/Orbit.Api/Middleware/SecurityHeadersMiddleware.cs create mode 100644 src/Orbit.Application/Common/AppConstants.cs create mode 100644 src/Orbit.Application/Common/CacheInvalidationHelper.cs create mode 100644 src/Orbit.Application/Common/ErrorMessages.cs create mode 100644 src/Orbit.Application/Common/ResultExtensions.cs create mode 100644 src/Orbit.Application/Habits/Validators/SharedHabitRules.cs diff --git a/CLAUDE.md b/CLAUDE.md index dd1c552f..adb4d0a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,13 @@ ``` src/ Orbit.Api/ - Controllers, middleware, DI config, Program.cs + Extensions/ - ResultActionResultExtensions (PayGate-aware IActionResult) + Middleware/ - SecurityHeadersMiddleware, ValidationExceptionHandler Orbit.Application/ - Commands, Queries, Validators, DTOs (CQRS) - Common/ - Shared models (PaginatedResponse) + Common/ - PaginatedResponse, ErrorMessages, AppConstants, + CacheInvalidationHelper, ResultExtensions, PayGateService Habits/Services/ - HabitScheduleService (schedule calculation logic) + Habits/Validators/ - SharedHabitRules (shared Create/Update validation) Orbit.Domain/ - Entities, Enums, Interfaces, Value Objects Orbit.Infrastructure/- DbContext, Repositories, AI services, JWT, Migrations tests/ @@ -60,6 +64,22 @@ docker compose up -d --build - Schedule calculations (frequency, days, intervals) live in `HabitScheduleService` -- never on the frontend - **Timezone rule:** All user-facing dates MUST use `IUserDateService.GetUserTodayAsync(userId)` to get the user's timezone-aware "today". NEVER use `DateOnly.FromDateTime(DateTime.UtcNow)` for user-facing logic. `DateTime.UtcNow` is only acceptable for: `CreatedAtUtc` timestamps in entity factories, and cache key generation. The user sets their timezone in their profile (`User.TimeZone`). If no timezone is set, it falls back to UTC. +### DRY Patterns (Application Layer) +- **Error messages:** Use `ErrorMessages.UserNotFound`, `ErrorMessages.HabitNotFound`, etc. from `Common/ErrorMessages.cs`. Never hardcode "not found" strings. +- **Magic numbers:** Use `AppConstants.MaxSubHabits`, `AppConstants.MaxUserFacts`, etc. from `Common/AppConstants.cs`. Never inline limits. +- **Cache invalidation:** Use `CacheInvalidationHelper.InvalidateSummaryCache(cache, userId)` after any habit mutation. Never manually write the 6-line cache removal loop. +- **PayGate propagation:** Use `result.PropagateError()` from `Common/ResultExtensions.cs` to propagate PayGate failures. Never manually check `ErrorCode == "PAY_GATE"` with ternary. +- **PayGate controller responses:** Use `result.ToPayGateAwareResult(v => Ok(v))` from `Extensions/ResultActionResultExtensions.cs` in controllers. Never manually write the 403/PAY_GATE response block. +- **Shared validation:** `SharedHabitRules` in `Habits/Validators/` has `AddTitleRules()` and `AddDaysRules()` shared between Create and Update validators. + +### Security +- **Stripe API key:** Set once globally in `Program.cs` at startup. Never set `StripeConfiguration.ApiKey` per-request in controllers. +- **Webhook verification:** Stripe webhooks MUST verify signatures. Reject if `WebhookSecret` is not configured. +- **Security headers:** `SecurityHeadersMiddleware` adds nosniff, DENY, referrer-policy, XSS headers to all responses. +- **CORS:** Restricted to explicit methods (GET/POST/PUT/DELETE/PATCH) and headers (Authorization, Content-Type). No `AllowAnyHeader()`/`AllowAnyMethod()`. +- **Request size:** 10MB global Kestrel limit. Chat endpoint has its own 20MB multipart limit. +- **Input validation:** Validate Stripe checkout intervals against whitelist. Validate chat history size before JSON deserialization. + ## Working Style ### Plan First @@ -180,12 +200,54 @@ The frontend (orbit-ui) consumes this via BFF and never computes schedules. - **Scheduler:** `ReminderSchedulerService` (BackgroundService) runs every 1 minute, checks habits with `ReminderEnabled && DueTime != null`, sends push + creates in-app notification. `SentReminder` table prevents duplicates per (habitId, date). - **Test endpoint:** `POST /api/notification/test-push` uses PushNotificationService directly for diagnostic push. +## Git Workflow + +Branch protection is enforced on `main`. No direct pushes, no force pushes, no branch deletion. + +### Branching Convention + +- `feature/xxx` -- new features +- `fix/xxx` -- bugfixes +- `chore/xxx` -- maintenance, config, docs + +### Merge Strategy + +- **Squash merge only** -- keeps `main` history linear and clean +- Squash commit uses PR title + PR body +- Head branches auto-delete after merge + +### Workflow + +```bash +# 1. Create branch from main +git checkout main && git pull +git checkout -b feature/my-change + +# 2. Work and commit +git add && git commit -m "description" + +# 3. Push and create PR +git push -u origin feature/my-change +gh pr create --fill + +# 4. Merge via squash +gh pr merge --squash +``` + +### Rules + +- Never push directly to `main` -- always go through a PR +- Never force push to `main` +- Keep PRs focused: one feature or fix per PR +- Branch names should be descriptive: `feature/add-tags-to-habits`, `fix/login-redirect` + ## Logging Convention - All controllers inject `ILogger` and log business events - Format: `logger.LogInformation("Action {Property}", value)` -- structured properties in PascalCase, English only - Auth: log code sends, login success/failure, Google auth - Habits: log create, delete, bulk operations +- Tags: log create, update, delete operations - Email: log send success/failure with status codes (ResendEmailService) -- Payments: log checkout creation +- Payments: log checkout creation, webhook events - Validation: log failed fields and endpoint path - Profile: log timezone/language changes diff --git a/src/Orbit.Api/Controllers/ChatController.cs b/src/Orbit.Api/Controllers/ChatController.cs index 48871770..84d14763 100644 --- a/src/Orbit.Api/Controllers/ChatController.cs +++ b/src/Orbit.Api/Controllers/ChatController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; using Orbit.Application.Chat.Commands; +using Orbit.Domain.Common; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; @@ -44,6 +45,11 @@ public async Task ProcessChat( List? chatHistory = null; if (!string.IsNullOrWhiteSpace(history)) { + if (history.Length > 50_000) + { + return BadRequest(new { error = "Chat history too large" }); + } + try { chatHistory = JsonSerializer.Deserialize>(history, new JsonSerializerOptions @@ -66,11 +72,6 @@ public async Task ProcessChat( var result = await mediator.Send(command, cancellationToken); - if (result.IsSuccess) - return Ok(result.Value); - - return result.ErrorCode == "PAY_GATE" - ? StatusCode(403, new { error = result.Error, code = "PAY_GATE" }) - : BadRequest(new { error = result.Error }); + return result.ToPayGateAwareResult(v => Ok(v)); } } diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index f28b549d..89305914 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -107,13 +107,7 @@ public async Task GetDailySummary( language); var result = await mediator.Send(query, cancellationToken); - - if (result.IsSuccess) - return Ok(result.Value); - - return result.ErrorCode == "PAY_GATE" - ? StatusCode(403, new { error = result.Error, code = "PAY_GATE" }) - : BadRequest(new { error = result.Error }); + return result.ToPayGateAwareResult(v => Ok(v)); } [HttpGet("{id:guid}")] @@ -152,9 +146,7 @@ public async Task CreateHabit( return CreatedAtAction(nameof(GetHabits), new { id = result.Value }, result.Value); } - return result.ErrorCode == "PAY_GATE" - ? StatusCode(403, new { error = result.Error, code = "PAY_GATE" }) - : BadRequest(new { error = result.Error }); + return result.ToPayGateAwareResult(v => CreatedAtAction(nameof(GetHabits), new { id = v }, v)); } [HttpPost("{id:guid}/log")] @@ -252,9 +244,7 @@ public async Task BulkCreate( return Ok(result.Value); } - return result.ErrorCode == "PAY_GATE" - ? StatusCode(403, new { error = result.Error, code = "PAY_GATE" }) - : BadRequest(new { error = result.Error }); + return result.ToPayGateAwareResult(v => Ok(v)); } [HttpDelete("bulk")] @@ -334,9 +324,7 @@ public async Task CreateSubHabit( if (result.IsSuccess) return Created($"/api/habits/{result.Value}", new { id = result.Value }); - return result.ErrorCode == "PAY_GATE" - ? StatusCode(403, new { error = result.Error, code = "PAY_GATE" }) - : BadRequest(new { error = result.Error }); + return result.ToPayGateAwareResult(v => Created($"/api/habits/{v}", new { id = v })); } private static BulkHabitItem MapToBulkHabitItem(BulkHabitItemRequest request) diff --git a/src/Orbit.Api/Controllers/SubscriptionController.cs b/src/Orbit.Api/Controllers/SubscriptionController.cs index 676c8059..32dac616 100644 --- a/src/Orbit.Api/Controllers/SubscriptionController.cs +++ b/src/Orbit.Api/Controllers/SubscriptionController.cs @@ -53,19 +53,24 @@ public async Task CreateCheckout( var countryCode = await geoLocationService.GetCountryCodeAsync(ip, ct); var isBrazil = countryCode == "BR"; - var priceId = (request.Interval?.ToLower(), isBrazil) switch + var allowedIntervals = new[] { "monthly", "semiannual", "yearly" }; + var interval = request.Interval?.ToLower(); + if (string.IsNullOrEmpty(interval) || !allowedIntervals.Contains(interval)) + { + return BadRequest(new { error = "Invalid billing interval" }); + } + + var priceId = (interval, isBrazil) switch { ("yearly", true) => _settings.YearlyPriceIdBrl, ("yearly", false) => _settings.YearlyPriceIdUsd, ("semiannual", true) => _settings.SemiAnnualPriceIdBrl, ("semiannual", false) => _settings.SemiAnnualPriceIdUsd, ("monthly", true) => _settings.MonthlyPriceIdBrl, - (_, false) => _settings.MonthlyPriceIdUsd, - _ => _settings.MonthlyPriceIdBrl + ("monthly", false) => _settings.MonthlyPriceIdUsd, + _ => _settings.MonthlyPriceIdBrl // unreachable after validation }; - StripeConfiguration.ApiKey = _settings.SecretKey; - if (string.IsNullOrEmpty(user.StripeCustomerId)) { var customerService = new CustomerService(); @@ -104,8 +109,6 @@ public async Task CreatePortal(CancellationToken ct) if (string.IsNullOrEmpty(user.StripeCustomerId)) return BadRequest(new { error = "No subscription found" }); - StripeConfiguration.ApiKey = _settings.SecretKey; - var portalService = new Stripe.BillingPortal.SessionService(); var session = await portalService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions { @@ -138,26 +141,23 @@ await payGate.GetAiMessageLimit(user.Id, ct), [HttpPost("webhook")] public async Task HandleWebhook(CancellationToken ct) { - StripeConfiguration.ApiKey = _settings.SecretKey; - var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct); logger.LogInformation("Stripe webhook received, body length: {Length}", json.Length); + if (string.IsNullOrEmpty(_settings.WebhookSecret)) + { + logger.LogCritical("Stripe WebhookSecret is not configured -- rejecting webhook"); + return StatusCode(500); + } + Event stripeEvent; try { - if (!string.IsNullOrEmpty(_settings.WebhookSecret)) - { - stripeEvent = EventUtility.ConstructEvent( - json, - Request.Headers["Stripe-Signature"], - _settings.WebhookSecret, - throwOnApiVersionMismatch: false); - } - else - { - stripeEvent = EventUtility.ParseEvent(json); - } + stripeEvent = EventUtility.ConstructEvent( + json, + Request.Headers["Stripe-Signature"], + _settings.WebhookSecret, + throwOnApiVersionMismatch: false); } catch (StripeException ex) { diff --git a/src/Orbit.Api/Controllers/TagsController.cs b/src/Orbit.Api/Controllers/TagsController.cs index 20c8958e..9a3867f4 100644 --- a/src/Orbit.Api/Controllers/TagsController.cs +++ b/src/Orbit.Api/Controllers/TagsController.cs @@ -10,7 +10,7 @@ namespace Orbit.Api.Controllers; [Authorize] [ApiController] [Route("api/[controller]")] -public class TagsController(IMediator mediator) : ControllerBase +public class TagsController(IMediator mediator, ILogger logger) : ControllerBase { public record CreateTagRequest(string Name, string Color); public record UpdateTagRequest(string Name, string Color); @@ -32,9 +32,12 @@ public async Task CreateTag( var command = new CreateTagCommand(HttpContext.GetUserId(), request.Name, request.Color); var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? Created($"/api/tags/{result.Value}", new { id = result.Value }) - : BadRequest(new { error = result.Error }); + if (result.IsSuccess) + { + logger.LogInformation("Tag created {TagId} by user {UserId}", result.Value, HttpContext.GetUserId()); + return Created($"/api/tags/{result.Value}", new { id = result.Value }); + } + return BadRequest(new { error = result.Error }); } [HttpPut("{id:guid}")] @@ -46,7 +49,12 @@ public async Task UpdateTag( var command = new UpdateTagCommand(HttpContext.GetUserId(), id, request.Name, request.Color); var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess ? NoContent() : BadRequest(new { error = result.Error }); + if (result.IsSuccess) + { + logger.LogInformation("Tag updated {TagId} by user {UserId}", id, HttpContext.GetUserId()); + return NoContent(); + } + return BadRequest(new { error = result.Error }); } [HttpDelete("{id:guid}")] @@ -55,7 +63,12 @@ public async Task DeleteTag(Guid id, CancellationToken cancellati var command = new DeleteTagCommand(HttpContext.GetUserId(), id); var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess ? NoContent() : BadRequest(new { error = result.Error }); + if (result.IsSuccess) + { + logger.LogInformation("Tag deleted {TagId} by user {UserId}", id, HttpContext.GetUserId()); + return NoContent(); + } + return BadRequest(new { error = result.Error }); } [HttpPut("{habitId:guid}/assign")] diff --git a/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs new file mode 100644 index 00000000..85505889 --- /dev/null +++ b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Orbit.Domain.Common; + +namespace Orbit.Api.Extensions; + +public static class ResultActionResultExtensions +{ + public static IActionResult ToPayGateAwareResult(this Result result) + { + if (result.IsSuccess) + return new OkResult(); + + return result.ErrorCode == "PAY_GATE" + ? new ObjectResult(new { error = result.Error, code = "PAY_GATE" }) { StatusCode = 403 } + : new BadRequestObjectResult(new { error = result.Error }); + } + + public static IActionResult ToPayGateAwareResult(this Result result, Func onSuccess) + { + if (result.IsSuccess) + return onSuccess(result.Value); + + return result.ErrorCode == "PAY_GATE" + ? new ObjectResult(new { error = result.Error, code = "PAY_GATE" }) { StatusCode = 403 } + : new BadRequestObjectResult(new { error = result.Error }); + } +} diff --git a/src/Orbit.Api/Middleware/SecurityHeadersMiddleware.cs b/src/Orbit.Api/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 00000000..d74d7078 --- /dev/null +++ b/src/Orbit.Api/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,15 @@ +namespace Orbit.Api.Middleware; + +public class SecurityHeadersMiddleware(RequestDelegate next) +{ + public Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["X-XSS-Protection"] = "0"; + + return next(context); + } +} diff --git a/src/Orbit.Api/Program.cs b/src/Orbit.Api/Program.cs index fd735e80..105db52d 100644 --- a/src/Orbit.Api/Program.cs +++ b/src/Orbit.Api/Program.cs @@ -52,6 +52,11 @@ // --- Stripe --- builder.Services.Configure( builder.Configuration.GetSection(StripeSettings.SectionName)); +var stripeKey = builder.Configuration.GetSection(StripeSettings.SectionName).Get()?.SecretKey; +if (!string.IsNullOrEmpty(stripeKey)) +{ + Stripe.StripeConfiguration.ApiKey = stripeKey; +} // --- Push Notifications (VAPID + FCM) --- builder.Services.Configure( @@ -172,12 +177,18 @@ options.AddDefaultPolicy(policy => { policy.WithOrigins(allowedOrigins) - .AllowAnyHeader() - .AllowAnyMethod() + .WithHeaders("Authorization", "Content-Type") + .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH") .AllowCredentials(); }); }); +// --- Request Size Limit --- +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB global default +}); + // --- Controllers --- builder.Services.AddControllers() .AddJsonOptions(options => @@ -205,6 +216,7 @@ } // --- Pipeline --- +app.UseMiddleware(); app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto @@ -219,7 +231,7 @@ app.UseExceptionHandler(); app.UseCors(); -if (!app.Environment.IsProduction()) +if (app.Environment.IsProduction()) { app.UseHttpsRedirection(); } diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 8f135404..06de901f 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -98,9 +99,7 @@ public async Task> Handle( // Check AI message limits var messageGate = await payGate.CanSendAiMessage(request.UserId, cancellationToken); if (messageGate.IsFailure) - return messageGate.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(messageGate.Error) - : Result.Failure(messageGate.Error); + return messageGate.PropagateError(); // 1c. Retrieve user's facts as context for the AI (skip if memory disabled) IReadOnlyList userFacts = []; @@ -370,9 +369,7 @@ public async Task> Handle( // Check habit limit var habitGate = await payGate.CanCreateHabits(userId, 1, ct); if (habitGate.IsFailure) - return habitGate.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure<(Guid? Id, string? Name)>(habitGate.Error) - : Result.Failure<(Guid? Id, string? Name)>(habitGate.Error); + return habitGate.PropagateError<(Guid? Id, string? Name)>(); var dueDate = action.DueDate ?? await userDateService.GetUserTodayAsync(userId, ct); @@ -397,9 +394,7 @@ public async Task> Handle( { var subGate = await payGate.CanCreateSubHabits(userId, ct); if (subGate.IsFailure) - return subGate.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure<(Guid? Id, string? Name)>(subGate.Error) - : Result.Failure<(Guid? Id, string? Name)>(subGate.Error); + return subGate.PropagateError<(Guid? Id, string? Name)>(); foreach (var sub in action.SubHabits) { var childResult = Habit.Create( diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs new file mode 100644 index 00000000..60fd5d7e --- /dev/null +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -0,0 +1,15 @@ +namespace Orbit.Application.Common; + +public static class AppConstants +{ + public const int MaxSubHabits = 20; + public const int MaxHabitTitleLength = 200; + public const int MaxHabitDepth = 5; + public const int MaxTagsPerHabit = 5; + public const int MaxUserFacts = 50; + public const int MaxRangeDays = 366; + public const int DefaultReminderMinutes = 15; + public const int DefaultFreeMaxHabits = 10; + public const int DefaultFreeAiMessages = 20; + public const int DefaultProAiMessages = 500; +} diff --git a/src/Orbit.Application/Common/CacheInvalidationHelper.cs b/src/Orbit.Application/Common/CacheInvalidationHelper.cs new file mode 100644 index 00000000..639bfeab --- /dev/null +++ b/src/Orbit.Application/Common/CacheInvalidationHelper.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Orbit.Application.Common; + +public static class CacheInvalidationHelper +{ + public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + for (int i = -1; i <= 1; i++) + { + cache.Remove($"summary:{userId}:{today.AddDays(i):yyyy-MM-dd}:en"); + cache.Remove($"summary:{userId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); + } + } +} diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs new file mode 100644 index 00000000..950fff78 --- /dev/null +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -0,0 +1,13 @@ +namespace Orbit.Application.Common; + +public static class ErrorMessages +{ + public const string UserNotFound = "User not found."; + public const string HabitNotFound = "Habit not found."; + public const string ParentHabitNotFound = "Parent habit not found."; + public const string TargetParentNotFound = "Target parent habit not found."; + public const string TagNotFound = "Tag not found."; + public const string FactNotFound = "Fact not found."; + public const string NoPermission = "You don't have permission to delete this habit."; + public const string HabitNotOwned = "Habit does not belong to this user."; +} diff --git a/src/Orbit.Application/Common/ResultExtensions.cs b/src/Orbit.Application/Common/ResultExtensions.cs new file mode 100644 index 00000000..84abc334 --- /dev/null +++ b/src/Orbit.Application/Common/ResultExtensions.cs @@ -0,0 +1,20 @@ +using Orbit.Domain.Common; + +namespace Orbit.Application.Common; + +public static class ResultExtensions +{ + public static Result PropagateError(this Result source) + { + return source.ErrorCode == "PAY_GATE" + ? Result.PayGateFailure(source.Error) + : Result.Failure(source.Error); + } + + public static Result PropagateError(this Result source) + { + return source.ErrorCode == "PAY_GATE" + ? Result.PayGateFailure(source.Error) + : Result.Failure(source.Error); + } +} diff --git a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs index 8066e886..9c9566b5 100644 --- a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -46,9 +47,7 @@ public async Task> Handle(BulkCreateHabitsCommand reque var parentCount = request.Habits.Count; var habitGate = await payGate.CanCreateHabits(request.UserId, parentCount, cancellationToken); if (habitGate.IsFailure) - return habitGate.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(habitGate.Error) - : Result.Failure(habitGate.Error); + return habitGate.PropagateError(); // Check sub-habit access if any items have sub-habits var hasSubHabits = request.Habits.Any(h => h.SubHabits is { Count: > 0 }); @@ -56,9 +55,7 @@ public async Task> Handle(BulkCreateHabitsCommand reque { var subGate = await payGate.CanCreateSubHabits(request.UserId, cancellationToken); if (subGate.IsFailure) - return subGate.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(subGate.Error) - : Result.Failure(subGate.Error); + return subGate.PropagateError(); } var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); @@ -103,6 +100,7 @@ public async Task> Handle(BulkCreateHabitsCommand reque await habitRepository.AddAsync(parentHabit, cancellationToken); // Create child habits if any + var subHabitFailed = false; if (item.SubHabits is { Count: > 0 }) { foreach (var subItem in item.SubHabits) @@ -129,7 +127,8 @@ public async Task> Handle(BulkCreateHabitsCommand reque Title: item.Title, Error: $"Sub-habit '{subItem.Title}' failed: {childResult.Error}", Field: "SubHabits")); - goto NextItem; // Skip to next top-level item + subHabitFailed = true; + break; } // Explicitly add child habit @@ -137,6 +136,8 @@ public async Task> Handle(BulkCreateHabitsCommand reque } } + if (subHabitFailed) continue; + // Success results.Add(new BulkCreateItemResult( Index: i, @@ -152,8 +153,6 @@ public async Task> Handle(BulkCreateHabitsCommand reque Title: item.Title, Error: ex.Message)); } - - NextItem: ; // Label for goto } // Save all successful entities and commit @@ -166,12 +165,7 @@ public async Task> Handle(BulkCreateHabitsCommand reque throw; } - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(new BulkCreateResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs index e5a6e813..80fe33c2 100644 --- a/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -75,12 +76,7 @@ public async Task> Handle(BulkDeleteHabitsCommand reque // Save all successful deletions once await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(new BulkDeleteResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index d61b923b..2700729b 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -35,18 +36,14 @@ public async Task> Handle(CreateHabitCommand request, CancellationT // Check habit limit var gateCheck = await payGate.CanCreateHabits(request.UserId, 1, cancellationToken); if (gateCheck.IsFailure) - return gateCheck.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(gateCheck.Error) - : Result.Failure(gateCheck.Error); + return gateCheck.PropagateError(); // Check sub-habit access if creating with sub-habits if (request.SubHabits is { Count: > 0 }) { var subGateCheck = await payGate.CanCreateSubHabits(request.UserId, cancellationToken); if (subGateCheck.IsFailure) - return subGateCheck.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(subGateCheck.Error) - : Result.Failure(subGateCheck.Error); + return subGateCheck.PropagateError(); } var dueDate = request.DueDate ?? await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); @@ -101,12 +98,7 @@ public async Task> Handle(CreateHabitCommand request, CancellationT await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(habit.Id); } diff --git a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs index 089c6a1c..a2ae150e 100644 --- a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -25,16 +26,14 @@ public async Task> Handle(CreateSubHabitCommand request, Cancellati // Sub-habits are a Pro feature var gateCheck = await payGate.CanCreateSubHabits(request.UserId, cancellationToken); if (gateCheck.IsFailure) - return gateCheck.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(gateCheck.Error) - : Result.Failure(gateCheck.Error); + return gateCheck.PropagateError(); var parent = await habitRepository.FindOneTrackedAsync( h => h.Id == request.ParentHabitId && h.UserId == request.UserId, cancellationToken: cancellationToken); if (parent is null) - return Result.Failure("Parent habit not found."); + return Result.Failure(ErrorMessages.ParentHabitNotFound); // Enforce max nesting depth from config var maxDepth = await appConfigService.GetAsync("MaxHabitDepth", 5, cancellationToken); @@ -61,12 +60,7 @@ public async Task> Handle(CreateSubHabitCommand request, Cancellati await habitRepository.AddAsync(childResult.Value, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(childResult.Value.Id); } diff --git a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs index 66f83de0..5b428ad6 100644 --- a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,20 +19,15 @@ public async Task Handle(DeleteHabitCommand request, CancellationToken c var habit = await habitRepository.GetByIdAsync(request.HabitId, cancellationToken); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); if (habit.UserId != request.UserId) - return Result.Failure("You don't have permission to delete this habit."); + return Result.Failure(ErrorMessages.NoPermission); habitRepository.Remove(habit); await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs index b0fdfb32..ae0cef7b 100644 --- a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -25,7 +26,7 @@ public async Task> Handle(DuplicateHabitCommand request, Cancellati var original = allHabits.FirstOrDefault(h => h.Id == request.HabitId); if (original is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); // Check plan limits var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); @@ -54,12 +55,7 @@ public async Task> Handle(DuplicateHabitCommand request, Cancellati await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(rootCopy.Value.Id); } diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 46aa1699..f5d8466b 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -27,10 +28,10 @@ public async Task> Handle(LogHabitCommand request, CancellationToke cancellationToken); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); if (habit.UserId != request.UserId) - return Result.Failure("Habit does not belong to this user."); + return Result.Failure(ErrorMessages.HabitNotOwned); var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); @@ -49,12 +50,7 @@ public async Task> Handle(LogHabitCommand request, CancellationToke await unitOfWork.SaveChangesAsync(cancellationToken); - var utcToday = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{habit.UserId}:{utcToday.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{habit.UserId}:{utcToday.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, habit.UserId); return Result.Success(unlogResult.Value.Id); } @@ -71,12 +67,7 @@ public async Task> Handle(LogHabitCommand request, CancellationToke await unitOfWork.SaveChangesAsync(cancellationToken); - var utcDate = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{habit.UserId}:{utcDate.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{habit.UserId}:{utcDate.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, habit.UserId); return Result.Success(logResult.Value.Id); } diff --git a/src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs b/src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs index f9942639..4698e14b 100644 --- a/src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs +++ b/src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -23,7 +24,7 @@ public async Task Handle(MoveHabitParentCommand request, CancellationTok cancellationToken); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); // Promote to top-level if (request.ParentId is null) @@ -42,7 +43,7 @@ public async Task Handle(MoveHabitParentCommand request, CancellationTok cancellationToken: cancellationToken); if (parent is null) - return Result.Failure("Target parent habit not found."); + return Result.Failure(ErrorMessages.TargetParentNotFound); // Prevent circular references: walk up from the target parent to ensure // we don't encounter the habit being moved diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index 5bba8b65..59c9fd50 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -36,7 +37,7 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c cancellationToken: cancellationToken); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); var result = habit.Update( request.Title, @@ -66,12 +67,7 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c await unitOfWork.SaveChangesAsync(cancellationToken); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - for (int i = -1; i <= 1; i++) - { - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:en"); - cache.Remove($"summary:{request.UserId}:{today.AddDays(i):yyyy-MM-dd}:pt-BR"); - } + CacheInvalidationHelper.InvalidateSummaryCache(cache, request.UserId); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs index 9e060e28..d83d8f8d 100644 --- a/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -29,13 +30,11 @@ public async Task> Handle( // Check pay gate var gateCheck = await payGate.CanUseDailySummary(request.UserId, cancellationToken); if (gateCheck.IsFailure) - return gateCheck.ErrorCode == "PAY_GATE" - ? Result.PayGateFailure(gateCheck.Error) - : Result.Failure(gateCheck.Error); + return gateCheck.PropagateError(); var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); if (!user.AiSummaryEnabled) return Result.Failure("AI summary is disabled."); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs index 0c9abf3d..492f4dbe 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -33,7 +34,7 @@ public async Task> Handle(GetHabitByIdQuery request, var habit = allHabits.FirstOrDefault(h => h.Id == request.HabitId); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); var lookup = allHabits.ToLookup(h => h.ParentHabitId); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs index ad6a1d91..1c99e066 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitLogsQuery.cs @@ -1,8 +1,9 @@ using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; -using Microsoft.EntityFrameworkCore; namespace Orbit.Application.Habits.Queries; @@ -27,7 +28,7 @@ public async Task>> Handle(GetHabitLogsQu var found = habit.FirstOrDefault(); if (found is null) - return Result.Failure>("Habit not found."); + return Result.Failure>(ErrorMessages.HabitNotFound); var logs = found.Logs .OrderByDescending(l => l.Date) diff --git a/src/Orbit.Application/Habits/Queries/GetHabitMetricsQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitMetricsQuery.cs index 9597604a..d87648d3 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitMetricsQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitMetricsQuery.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -23,11 +24,11 @@ public async Task> Handle(GetHabitMetricsQuery request, Can var habit = habits.FirstOrDefault(); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); var today = GetUserToday(user); var logDates = habit.Logs.Select(l => l.Date).Distinct().ToHashSet(); diff --git a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs index e0243e68..f5a04c2b 100644 --- a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Orbit.Application.Common; using Orbit.Application.Habits.Commands; namespace Orbit.Application.Habits.Validators; @@ -10,26 +11,22 @@ public CreateHabitCommandValidator() RuleFor(x => x.UserId) .NotEmpty(); - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(200); + SharedHabitRules.AddTitleRules(RuleFor(x => x.Title)); RuleFor(x => x.FrequencyQuantity) .GreaterThan(0) .When(x => x.FrequencyQuantity is not null); - RuleFor(x => x.Days) - .Must((command, days) => days is null || days.Count == 0 || command.FrequencyQuantity == 1) - .WithMessage("Days can only be specified when frequency quantity is 1"); + SharedHabitRules.AddDaysRules(this, x => x.Days, x => x.FrequencyQuantity); RuleFor(x => x.SubHabits) - .Must(subs => subs is null || subs.Count <= 20) - .WithMessage("A habit can have at most 20 sub-habits"); + .Must(subs => subs is null || subs.Count <= AppConstants.MaxSubHabits) + .WithMessage($"A habit can have at most {AppConstants.MaxSubHabits} sub-habits"); RuleForEach(x => x.SubHabits) .NotEmpty() .WithMessage("Sub-habit title must not be empty") - .MaximumLength(200) - .WithMessage("Sub-habit title must not exceed 200 characters"); + .MaximumLength(AppConstants.MaxHabitTitleLength) + .WithMessage($"Sub-habit title must not exceed {AppConstants.MaxHabitTitleLength} characters"); } } diff --git a/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs new file mode 100644 index 00000000..e036dee7 --- /dev/null +++ b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using Orbit.Application.Common; + +namespace Orbit.Application.Habits.Validators; + +public static class SharedHabitRules +{ + public static void AddTitleRules(IRuleBuilder rule) + { + rule.NotEmpty().MaximumLength(AppConstants.MaxHabitTitleLength); + } + + public static void AddFrequencyQuantityRules(IRuleBuilderOptions rule) + { + rule.GreaterThan(0); + } + + public static void AddDaysRules( + AbstractValidator validator, + System.Linq.Expressions.Expression?>> daysExpr, + System.Linq.Expressions.Expression> freqExpr) + { + validator.RuleFor(daysExpr) + .Must((command, days) => + { + if (days is null || days.Count == 0) return true; + var freq = freqExpr.Compile()(command); + return freq == 1; + }) + .WithMessage("Days can only be specified when frequency quantity is 1"); + } +} diff --git a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs index a5f41aed..db7b858f 100644 --- a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs @@ -13,16 +13,12 @@ public UpdateHabitCommandValidator() RuleFor(x => x.HabitId) .NotEmpty(); - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(200); + SharedHabitRules.AddTitleRules(RuleFor(x => x.Title)); RuleFor(x => x.FrequencyQuantity) .GreaterThan(0) .When(x => x.FrequencyQuantity is not null); - RuleFor(x => x.Days) - .Must((command, days) => days is null || days.Count == 0 || command.FrequencyQuantity == 1) - .WithMessage("Days can only be specified when frequency quantity is 1"); + SharedHabitRules.AddDaysRules(this, x => x.Days, x => x.FrequencyQuantity); } } diff --git a/src/Orbit.Application/Profile/Commands/CompleteOnboardingCommand.cs b/src/Orbit.Application/Profile/Commands/CompleteOnboardingCommand.cs index 9033f425..09e6981e 100644 --- a/src/Orbit.Application/Profile/Commands/CompleteOnboardingCommand.cs +++ b/src/Orbit.Application/Profile/Commands/CompleteOnboardingCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(CompleteOnboardingCommand request, Cancellation cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.CompleteOnboarding(); diff --git a/src/Orbit.Application/Profile/Commands/DismissMissionsCommand.cs b/src/Orbit.Application/Profile/Commands/DismissMissionsCommand.cs index 3eabcf76..3426ec6f 100644 --- a/src/Orbit.Application/Profile/Commands/DismissMissionsCommand.cs +++ b/src/Orbit.Application/Profile/Commands/DismissMissionsCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(DismissMissionsCommand request, CancellationTok cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.DismissMissions(); diff --git a/src/Orbit.Application/Profile/Commands/MarkTourCompletedCommand.cs b/src/Orbit.Application/Profile/Commands/MarkTourCompletedCommand.cs index 5d3f5a91..22e6387d 100644 --- a/src/Orbit.Application/Profile/Commands/MarkTourCompletedCommand.cs +++ b/src/Orbit.Application/Profile/Commands/MarkTourCompletedCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(MarkTourCompletedCommand request, CancellationT cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.MarkTourCompleted(request.PageName); diff --git a/src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs b/src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs index dfc4c13f..fae9ddf2 100644 --- a/src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(SetAiMemoryCommand request, CancellationToken c cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.SetAiMemory(request.Enabled); diff --git a/src/Orbit.Application/Profile/Commands/SetAiSummaryCommand.cs b/src/Orbit.Application/Profile/Commands/SetAiSummaryCommand.cs index 317b2bc4..ab738431 100644 --- a/src/Orbit.Application/Profile/Commands/SetAiSummaryCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetAiSummaryCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(SetAiSummaryCommand request, CancellationToken cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.SetAiSummary(request.Enabled); diff --git a/src/Orbit.Application/Profile/Commands/SetLanguageCommand.cs b/src/Orbit.Application/Profile/Commands/SetLanguageCommand.cs index d2905541..f65820f4 100644 --- a/src/Orbit.Application/Profile/Commands/SetLanguageCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetLanguageCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(SetLanguageCommand request, CancellationToken c cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); user.SetLanguage(request.Language); await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs b/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs index 18724b0b..55f4db1e 100644 --- a/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(SetTimezoneCommand request, CancellationToken c cancellationToken: cancellationToken); if (user is null) - return Result.Failure("User not found."); + return Result.Failure(ErrorMessages.UserNotFound); var result = user.SetTimeZone(request.TimeZone); diff --git a/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs b/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs index 8618111f..50ad8a02 100644 --- a/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs +++ b/src/Orbit.Application/Tags/Commands/AssignTagsCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -30,7 +31,7 @@ public async Task Handle(AssignTagsCommand request, CancellationToken ca cancellationToken); if (habit is null) - return Result.Failure("Habit not found."); + return Result.Failure(ErrorMessages.HabitNotFound); // Load requested tags var tags = await tagRepository.FindAsync( diff --git a/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs b/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs index f31aa48a..590f84f7 100644 --- a/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -20,7 +21,7 @@ public async Task Handle(DeleteTagCommand request, CancellationToken can cancellationToken: cancellationToken); if (tag is null) - return Result.Failure("Tag not found."); + return Result.Failure(ErrorMessages.TagNotFound); tagRepository.Remove(tag); await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs b/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs index 59de73a1..9d4cebda 100644 --- a/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -22,7 +23,7 @@ public async Task Handle(UpdateTagCommand request, CancellationToken can cancellationToken: cancellationToken); if (tag is null) - return Result.Failure("Tag not found."); + return Result.Failure(ErrorMessages.TagNotFound); // Check for duplicate name (excluding self) var existing = await tagRepository.FindAsync( diff --git a/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs b/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs index d3cefe72..569ea815 100644 --- a/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs +++ b/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(DeleteUserFactCommand request, CancellationToke cancellationToken: cancellationToken); if (fact is null) - return Result.Failure("Fact not found."); + return Result.Failure(ErrorMessages.FactNotFound); fact.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/UserFacts/Commands/UpdateUserFactCommand.cs b/src/Orbit.Application/UserFacts/Commands/UpdateUserFactCommand.cs index 513835d0..4cca5952 100644 --- a/src/Orbit.Application/UserFacts/Commands/UpdateUserFactCommand.cs +++ b/src/Orbit.Application/UserFacts/Commands/UpdateUserFactCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -18,7 +19,7 @@ public async Task Handle(UpdateUserFactCommand request, CancellationToke cancellationToken: cancellationToken); if (fact is null) - return Result.Failure("Fact not found."); + return Result.Failure(ErrorMessages.FactNotFound); fact.Update(request.FactText, request.Category); await unitOfWork.SaveChangesAsync(cancellationToken);