Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>)
Common/ - PaginatedResponse<T>, 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/
Expand Down Expand Up @@ -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<T>()` 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
Expand Down Expand Up @@ -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 <files> && 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<T>` 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
13 changes: 7 additions & 6 deletions src/Orbit.Api/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -44,6 +45,11 @@ public async Task<IActionResult> ProcessChat(
List<ChatHistoryMessage>? chatHistory = null;
if (!string.IsNullOrWhiteSpace(history))
{
if (history.Length > 50_000)
{
return BadRequest(new { error = "Chat history too large" });
}

try
{
chatHistory = JsonSerializer.Deserialize<List<ChatHistoryMessage>>(history, new JsonSerializerOptions
Expand All @@ -66,11 +72,6 @@ public async Task<IActionResult> 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));
}
}
20 changes: 4 additions & 16 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,7 @@ public async Task<IActionResult> 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}")]
Expand Down Expand Up @@ -152,9 +146,7 @@ public async Task<IActionResult> 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")]
Expand Down Expand Up @@ -252,9 +244,7 @@ public async Task<IActionResult> 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")]
Expand Down Expand Up @@ -334,9 +324,7 @@ public async Task<IActionResult> 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)
Expand Down
42 changes: 21 additions & 21 deletions src/Orbit.Api/Controllers/SubscriptionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,24 @@ public async Task<IActionResult> 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();
Expand Down Expand Up @@ -104,8 +109,6 @@ public async Task<IActionResult> 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
{
Expand Down Expand Up @@ -138,26 +141,23 @@ await payGate.GetAiMessageLimit(user.Id, ct),
[HttpPost("webhook")]
public async Task<IActionResult> 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)
{
Expand Down
25 changes: 19 additions & 6 deletions src/Orbit.Api/Controllers/TagsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TagsController> logger) : ControllerBase
{
public record CreateTagRequest(string Name, string Color);
public record UpdateTagRequest(string Name, string Color);
Expand All @@ -32,9 +32,12 @@ public async Task<IActionResult> 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}")]
Expand All @@ -46,7 +49,12 @@ public async Task<IActionResult> 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}")]
Expand All @@ -55,7 +63,12 @@ public async Task<IActionResult> 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")]
Expand Down
27 changes: 27 additions & 0 deletions src/Orbit.Api/Extensions/ResultActionResultExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<T>(this Result<T> result, Func<T, IActionResult> 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 });
}
}
15 changes: 15 additions & 0 deletions src/Orbit.Api/Middleware/SecurityHeadersMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
18 changes: 15 additions & 3 deletions src/Orbit.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
// --- Stripe ---
builder.Services.Configure<StripeSettings>(
builder.Configuration.GetSection(StripeSettings.SectionName));
var stripeKey = builder.Configuration.GetSection(StripeSettings.SectionName).Get<StripeSettings>()?.SecretKey;
if (!string.IsNullOrEmpty(stripeKey))
{
Stripe.StripeConfiguration.ApiKey = stripeKey;
}

// --- Push Notifications (VAPID + FCM) ---
builder.Services.Configure<VapidSettings>(
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -205,6 +216,7 @@
}

// --- Pipeline ---
app.UseMiddleware<Orbit.Api.Middleware.SecurityHeadersMiddleware>();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
Expand All @@ -219,7 +231,7 @@
app.UseExceptionHandler();
app.UseCors();

if (!app.Environment.IsProduction())
if (app.Environment.IsProduction())
{
app.UseHttpsRedirection();
}
Expand Down
Loading