From efde0f952b766e941349da9b85aa950f0604c5d6 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Mon, 4 May 2026 17:42:44 -0500 Subject: [PATCH] Updated plan to fix issues --- .plans/otel-integration.md | 74 +++++++++++++++++++++ CLAUDE.md | 81 +++++++++++++++++++++++ build-budget-image.ps1 => build-image.ps1 | 0 deploy.ps1 | 2 + update-budget-site.ps1 | 5 -- update-container.ps1 | 1 + 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 .plans/otel-integration.md create mode 100644 CLAUDE.md rename build-budget-image.ps1 => build-image.ps1 (100%) create mode 100644 deploy.ps1 delete mode 100644 update-budget-site.ps1 create mode 100644 update-container.ps1 diff --git a/.plans/otel-integration.md b/.plans/otel-integration.md new file mode 100644 index 0000000..bb753d1 --- /dev/null +++ b/.plans/otel-integration.md @@ -0,0 +1,74 @@ +# OTel Integration — Budget + +Wire up OpenTelemetry in Budget.Api so logs and traces flow through the OTel Collector +to Seq. Depends on the infrastructure plan (`docker/.plans/otel-seq-infrastructure.md`) +being applied first. + +## Packages to add (Budget.Api.csproj) + +Verify latest stable versions on NuGet before adding — do not guess. + +``` +OpenTelemetry.Extensions.Hosting +OpenTelemetry.Instrumentation.AspNetCore +OpenTelemetry.Instrumentation.Http +OpenTelemetry.Instrumentation.EntityFrameworkCore +OpenTelemetry.Exporter.OpenTelemetryProtocol +``` + +## Phase 1 — Wire up OTel in Program.cs + +Add after `var builder = WebApplication.CreateBuilder(args);`: + +```csharp +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; +}); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource + .AddService(serviceName: "budget", serviceVersion: "1.0.0")) + .WithLogging() + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter()); +``` + +The `AddOtlpExporter()` call with no arguments reads the endpoint from environment variables +(`OTEL_EXPORTER_OTLP_ENDPOINT`), keeping config out of code. + +## Phase 2 — docker-compose environment variables + +Add to the `budget` service in `docker/docker-compose.yaml`: + +```yaml + - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + - OTEL_EXPORTER_OTLP_PROTOCOL=grpc + - OTEL_SERVICE_NAME=budget +``` + +`otel-collector` resolves via the `telemetry` network added in the infrastructure plan. + +## Phase 3 — Verification + +1. Build and push a new Budget image +2. `docker compose up -d budget` +3. Make a few API calls (create a transaction, fetch accounts) +4. In Seq, search `@ServiceName = 'budget'` — you should see structured log events + including the OIDC discovery retry loop logs already present in Program.cs +5. Confirm DB spans appear for EF Core queries + +## Notes + +- Budget already has structured logging calls in the OIDC startup retry loop + (`startupLogger.LogInformation`, `LogWarning`). These will automatically become + structured events in Seq with no code changes. +- The `ErrorHandlingMiddleware` and `KnownUserMiddleware` are good candidates to add + structured properties (e.g. `userId`, `endpoint`) to log calls, making Seq searches + more useful. +- `AddHttpClientInstrumentation()` will trace outgoing calls to the Auth server (OIDC + discovery, token introspection), which is useful for diagnosing auth latency. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1060716 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Backend (ASP.NET Core) +```bash +# Run API (must listen on port 5000 for the Vite proxy to work) +cd src/Budget.Api +ASPNETCORE_URLS=http://localhost:5000 dotnet run + +# Build +dotnet build src/Budget.Api/Budget.Api.csproj + +# Add an EF Core migration +dotnet ef migrations add --project src/Budget.Infrastructure --startup-project src/Budget.Api + +# Remove last migration (before it has been applied) +dotnet ef migrations remove --project src/Budget.Infrastructure --startup-project src/Budget.Api +``` + +### Frontend (Vite/React) +```bash +cd src/Budget.Client +npm run dev # dev server at http://localhost:5173 +npm run build # tsc + vite build (what CI and Docker use) +npm run lint # eslint +``` + +### Docker +```powershell +.\build-budget-image.ps1 # builds multi-stage image and pushes to docker.stwaddle.com/budget:latest +``` + +## Architecture + +The app is a single Docker container: the .NET API serves the compiled React SPA as static files and also handles all `/api` requests. In development the two processes run separately; Vite proxies `/api` to `http://localhost:5000`. + +### Project layout +``` +src/ + Budget.Core/ # Domain models, DTOs, FrequencyCalculator — no EF, no ASP.NET + Budget.Infrastructure/# EF Core DbContext, migrations, BudgetAuthorizationService + Budget.Api/ # ASP.NET Core controllers, middleware, Program.cs + Budget.Client/ # Vite/React SPA +``` + +### Data model +- **Budget** (owner, name) → owns **Incomes**, **Outgos**, **BudgetShares** +- All entities use **soft delete** (`IsDeleted` / `DeletedAt`) with a global EF query filter — hard deletes are not used +- **Budget** uses PostgreSQL's `xmin` shadow property as an EF concurrency token +- **BudgetShare** links a budget to another user by email; `IsPending = true` until the target user logs in (resolved by `KnownUserMiddleware`) +- **Frequency** is a C# enum (`Monthly`, `Biweekly`, `Annually`, etc.); `FrequencyCalculator` converts any frequency to monthly/annual equivalents +- The 50/30/20 rule is hardcoded in `SummaryController`: Need=50%, Want=30%, Save=20% + +### API conventions +- All controllers require `[Authorize(Roles = "admin,user")]` — tokens that lack a `role` claim matching either value get a 403 +- `RoleClaimType = "role"` and `NameClaimType = "sub"` are set in JWT options; role checks work with `User.IsInRole()` +- `BudgetAuthorizationService` centralises read/write/owner checks against the budget's owner and any accepted shares — always call `CanReadAsync` / `CanWriteAsync` / `IsOwnerAsync` before touching budget data +- The `TryGetUserId` helper pattern (returns an `IActionResult?` error or out-params the user id) is used consistently across all controllers +- **Enum serialisation**: `JsonStringEnumConverter` is registered globally, so all C# enums are sent/received as strings (e.g. `"Monthly"`, not `5`) +- Write endpoints use the `"writes"` rate-limit policy (30 req/min per user); reads use the global limiter (120 req/min) +- EF migrations run automatically via `MigrateAsync()` at startup + +### Frontend conventions +- **All server state** goes through TanStack Query (`src/api/`). Query keys follow the pattern `['budgets']`, `['budgets', id]`, `['budgets', id, 'summary']`, etc. +- **All forms** use `react-hook-form` + `zod` schemas defined in `src/schemas/index.ts`. Never use uncontrolled inputs outside of RHF. +- **CSS design system** lives entirely in `src/index.css` as CSS custom properties — no Tailwind, no CSS modules. Use the existing variables (`--color-primary`, `--green-*`, etc.) rather than hardcoded hex values. `App.css` is intentionally near-empty. +- **Icons** come from `lucide-react` (tree-shaken per-import). Use `btn-icon` class for icon-only buttons; always add a `title` attribute for accessibility. +- **Inline table editing**: rows show read-only data; clicking a cell enters edit mode in-place using a `useState(editing)` toggle. New rows use a separate input row appended to `` (not a modal). +- **Sorting**: `SortableHeader` component and `SortState` type in `src/components/SortableHeader.tsx`. Sorting is client-side only and derives a view from the drag-ordered `displayItems` array — clearing sort restores drag order. Drag handles are disabled while a sort is active. +- **Frequency calculations** for real-time previews are in `src/utils/frequency.ts`, mirroring the C# `FrequencyCalculator` exactly. +- **Auth**: `react-oidc-context` wraps the whole app; `AuthGuard` protects all budget routes; `TokenSync` wires the OIDC access token into the API client on every auth state change. After OIDC callback, navigation uses `window.location.replace('/')` (not `history.replaceState`) so React Router picks up the URL change. + +### Auth / OIDC +- Authority: `https://auth.stwaddle.com/` (OpenIddict-based, at `src.Auth.Server` in a sibling repo) +- API audience claim: `budget-api` (must match `AUTH__AUDIENCE` env var) +- Scope: `budget_api` (underscore, not hyphen) +- Roles `admin` and `user` are granted per-user on the client registration in the auth server admin panel. A user with no role gets `access_denied` at the authorize endpoint. +- Frontend OIDC config is baked into the build via `VITE_*` env vars; override locally in `src/Budget.Client/.env.local` diff --git a/build-budget-image.ps1 b/build-image.ps1 similarity index 100% rename from build-budget-image.ps1 rename to build-image.ps1 diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..388ef54 --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,2 @@ +.\build-image.ps1 +.\update-container.ps1 \ No newline at end of file diff --git a/update-budget-site.ps1 b/update-budget-site.ps1 deleted file mode 100644 index a75cd2f..0000000 --- a/update-budget-site.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -$image = "docker.stwaddle.com/budget:latest" -docker build -t $image . -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -docker push $image -ssh stwaddle_com "cd stwaddlecom; ./update-budget.sh" \ No newline at end of file diff --git a/update-container.ps1 b/update-container.ps1 new file mode 100644 index 0000000..c60d2e4 --- /dev/null +++ b/update-container.ps1 @@ -0,0 +1 @@ +ssh stwaddle_com "cd stwaddlecom; docker compose pull budget; docker compose down budget; docker compose up -d budget" \ No newline at end of file