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
101 changes: 101 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

AffirmationGenerator is a fullstack app that serves daily positive affirmations with multi-language support (via DeepL) and per-IP rate limiting (via Redis). It is deployed as a single Docker container to Fly.io.

## Commands

### Frontend (`AffirmationGenerator.Client/`)

```bash
pnpm run dev # Start Vite dev server (port 5173)
pnpm run build # TypeScript check + Vite build
pnpm run lint # ESLint (max-warnings=0 — must be clean)
pnpm run format # Prettier formatting
```

### Backend (`AffirmationGenerator.Server/`)

```bash
dotnet run # Start API (https://localhost:7006) + SPA proxy
dotnet build AffirmationGenerator.slnx
dotnet test AffirmationGenerator.slnx # Run all unit tests
dotnet test --filter "FullyQualifiedName~ClassName" # Run a single test class
```

### Docker

```bash
docker build -t affirmation-generator .
docker run -p 8080:8080 affirmation-generator
```

## Architecture

### Stack
- **Frontend**: React 19 + Vite + TypeScript + Tailwind CSS v4 + DaisyUI + TanStack React Query + Axios
- **Backend**: ASP.NET Core 10.0 with Clean Architecture
- **External services**: affirmations.dev (source), DeepL API (translation), Redis (rate limiting)

### How the pieces connect

In development, `dotnet run` starts the .NET server which launches the Vite dev server as a SPA proxy. API calls from Vite are proxied to `https://localhost:7006`. In production, the multi-stage Docker build compiles the React app and embeds it as static files in `wwwroot`; the .NET server serves both the SPA and the API.

### Backend layer structure (Clean Architecture)

| Layer | Responsibility |
|---|---|
| `Api/` | Controllers, rate limiting policy, HTTP models |
| `Application/` | CQRS-style query handlers, service orchestration |
| `Infrastructure/` | External HTTP clients (Refit), Redis client |
| `Domain/` | `AffirmationLanguage` enum, domain errors |
| `Core/` | `Result<T>` error-handling pattern, shared extensions |

Each layer has its own `DiConfig.cs` for DI registration; all are wired in `Program.cs`.

### API endpoints

| Method | Path | Description |
|---|---|---|
| GET | `/affirmations?targetLanguage={lang}` | Fetch affirmation (returns `{text, remainingCount}`) |
| GET | `/affirmations/remaining` | Remaining quota and `resetInSeconds` |
| GET | `/affirmations/languages` | Supported language codes |
| GET | `/health` | Health check |
| GET | `/swagger` | Swagger UI (dev only) |

### Rate limiting

Fixed-window limiter keyed on client IP (configurable via `Application:ClientOptions:ClientIpHeaderName` for proxy scenarios). Default: 10 requests/day per IP. Counter stored in Redis; falls back to in-memory if Redis is unavailable.

### Error handling

The backend uses a `Result<T>` type (`Core/Result.cs`) — all query handlers return `Result<T>` instead of throwing. Controllers map domain errors to HTTP responses.

## Local Development Setup

1. Set the DeepL API key via user secrets:
```bash
dotnet user-secrets set "Infrastructure:DeepLTranslatorClientOptions:ApiKey" "YOUR_KEY" \
--project AffirmationGenerator.Server
```
2. Redis is optional locally — the app falls back gracefully.
3. Swagger UI is available at `https://localhost:7006/swagger` in dev.

## Configuration Keys

```
Application:ClientOptions:MaxRequestsPerDay # Default: 10
Application:ClientOptions:ClientIpHeaderName # Header name for real IP (e.g. Fly.io)
Infrastructure:DeepLTranslatorClientOptions:ApiKey
Infrastructure:AffirmationClientOptions:BaseUrl
Infrastructure:RedisClientOptions:ConnectionString
```

## CI/CD

- **PR to main** → `build-test.yml` (restore → build → test)
- **Push to main** → `fly-deploy.yml` (build-test + deploy to Fly.io)
- Pre-commit hooks via `dotnet husky` (defined in `.husky/`)
24 changes: 22 additions & 2 deletions AffirmationGenerator.Client/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,27 @@
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnusedLabels": false,
"noUncheckedSideEffectImports": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"erasableSyntaxOnly": true,
"noImplicitOverride": true,
"jsx": "react-jsx",
"target": "ESNext",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["vite.config.ts"]
"include": [
"vite.config.ts"
]
}
14 changes: 7 additions & 7 deletions AffirmationGenerator.Client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import path from "path";
import child_process from "child_process";
import { env } from "process";

const target = env.ASPNETCORE_HTTPS_PORT
? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}`
: env.ASPNETCORE_URLS
? env.ASPNETCORE_URLS.split(";")[0]
const target = env["ASPNETCORE_HTTPS_PORT"]
? `https://localhost:${env["ASPNETCORE_HTTPS_PORT"]}`
: env["ASPNETCORE_URLS"]
? env["ASPNETCORE_URLS"].split(";")[0]
: "https://localhost:7006";

// https://vitejs.dev/config/
Expand All @@ -21,9 +21,9 @@ export default defineConfig(({ command }) => {
// Setup https certificates for local development
if (command === "serve") {
const baseFolder =
env.APPDATA !== undefined && env.APPDATA !== ""
? `${env.APPDATA}/ASP.NET/https`
: `${env.HOME}/.aspnet/https`;
env["APPDATA"] !== undefined && env["APPDATA"] !== ""
? `${env["APPDATA"]}/ASP.NET/https`
: `${env["HOME"]}/.aspnet/https`;

const certificateName = "reactapp1.client";
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
Expand Down
Loading