Compare commits
14 Commits
389446ee21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aafc0efaab | |||
| ecb1d92df3 | |||
| 98a433f53f | |||
| 60e70f7acc | |||
| 1de8fce103 | |||
| ad5406a5d7 | |||
| 1c4cc3c79f | |||
| 4fa35dadc3 | |||
| b82b3939ce | |||
| ac3dcc2f31 | |||
| 69ec754775 | |||
| efde0f952b | |||
| 8d4d7c7ce3 | |||
| bfd5880b9c |
@@ -7,3 +7,6 @@ POSTGRES_PASSWORD=changeme
|
||||
|
||||
# Note: client OIDC values live in src/Budget.Client/.env (committed).
|
||||
# Override locally in src/Budget.Client/.env.local (gitignored).
|
||||
|
||||
# Trusted reverse-proxy networks (comma-separated CIDRs). Default covers the Docker bridge range.
|
||||
# Budget__TrustedProxyNetworks=172.16.0.0/12
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
cache-binary: false
|
||||
|
||||
- name: Determine image name and tags
|
||||
id: meta
|
||||
env:
|
||||
# Pass context values through env so the workflow templater does not
|
||||
# interpolate them into the script body (prevents shell injection via
|
||||
# attacker-controlled ref names). Shell vars below are NOT templated.
|
||||
REF_TYPE: ${{ gitea.ref_type }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
SHA: ${{ gitea.sha }}
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
run: |
|
||||
# Image names must be lowercase; lowercase the full owner/name path.
|
||||
IMAGE="${REGISTRY}/$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
# Reject tags that aren't clean semver-ish refs.
|
||||
case "$REF_NAME" in
|
||||
v[0-9]*) : ;;
|
||||
*) echo "Refusing to build non-version tag: $REF_NAME" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
SHORT_SHA="$(echo "$SHA" | cut -c1-8)"
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
if: steps.meta.outputs.is_release == 'true'
|
||||
env:
|
||||
# The job image (node:20-bullseye) has no docker CLI, so docker/login-action
|
||||
# can't run. buildx reads ~/.docker/config.json directly, so write the auth
|
||||
# there ourselves. Secrets via env keep them out of the templated script.
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.docker"
|
||||
AUTH="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 -w0)"
|
||||
printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$AUTH" > "$HOME/.docker/config.json"
|
||||
|
||||
- name: Build and push release image
|
||||
if: steps.meta.outputs.is_release == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
|
||||
- name: Build dev image (no push)
|
||||
if: steps.meta.outputs.is_release == 'false'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: 'Image tag to deploy (e.g. v1.2.3, or "latest")'
|
||||
required: true
|
||||
default: 'latest'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
# Pass the user-supplied tag as an env var (not interpolated into the
|
||||
# remote script body). The remote script runs as the deploy user, which
|
||||
# has docker access == root on prod, so the tag is validated before use.
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
with:
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: ${{ secrets.PROD_SSH_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
envs: IMAGE_TAG
|
||||
script: |
|
||||
set -eu
|
||||
case "$IMAGE_TAG" in
|
||||
latest|v[0-9]*) : ;;
|
||||
*) echo "Refusing to deploy invalid tag: $IMAGE_TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
IMAGE="gitea.stwaddle.com/stwaddle/budget"
|
||||
cd /srv/stwaddlecom
|
||||
docker pull "${IMAGE}:${IMAGE_TAG}"
|
||||
# Re-tag as :latest so the compose definition (which references :latest) picks it up.
|
||||
docker tag "${IMAGE}:${IMAGE_TAG}" "${IMAGE}:latest"
|
||||
docker compose up -d budget
|
||||
@@ -0,0 +1,138 @@
|
||||
# Security & Resource Hardening Plan
|
||||
**Date:** 2026-05-06
|
||||
**Trigger:** Production host CPU pegged >100% for several hours; Disk I/O high; network normal.
|
||||
**Root cause hypothesis:** C-1 (unconditional OTel exporter) + H-1 (unconditional DB write per request).
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### ~~C-1 — Guard `UseOtlpExporter()` Behind Env Var Check~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** Entire OTel block (logging + tracing + exporter) is now gated on `OTEL_EXPORTER_OTLP_ENDPOINT` being set. AspNetCore instrumentation also filtered to `/api` paths only so static file requests never generate spans. The duplicate `builder.Logging.AddOpenTelemetry()` is inside the same guard, eliminating the double-pipeline issue. Build verified clean.
|
||||
|
||||
---
|
||||
|
||||
### ~~C-2 — Require Auth on `/healthz` (or Remove DB Check)~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** Endpoint removed entirely. Not wired to any orchestrator health check; budget app is lower priority than recipes/journal. `AddHealthChecks()`, `AddDbContextCheck<AppDbContext>()`, `MapHealthChecks()`, and both health-check `using` statements removed from `Program.cs`. Build verified clean.
|
||||
|
||||
---
|
||||
|
||||
### ~~C-3 — Move `UseRateLimiter()` Before `UseStaticFiles()`~~ ✅ DONE
|
||||
**File:** `src/Budget.Api/Program.cs`
|
||||
**Risk:** `UseStaticFiles` short-circuits the pipeline before `UseRateLimiter`, so all SPA asset requests (JS bundles, CSS, `index.html` fallback) bypass rate limiting. Combined with C-1, every unthrottled static file request still generates a telemetry span.
|
||||
|
||||
**Fix:** Reorder middleware so `app.UseRateLimiter()` appears before `app.UseDefaultFiles()` and `app.UseStaticFiles()`.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### ~~H-1 — Throttle `KnownUserMiddleware` DB Writes~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** `KnownUserMiddleware` removed entirely. Replaced with `POST /api/users/me` endpoint in new `UsersController` — same upsert + pending share resolution logic, but now called once on login from `TokenSync.tsx` rather than on every request. `api.post` body param made optional to support the no-body call. Backend and frontend builds verified clean.
|
||||
|
||||
---
|
||||
|
||||
### H-2 — Reduce `BudgetAuthorizationService` to a Single Query
|
||||
**File:** `src/Budget.Infrastructure/Services/BudgetAuthorizationService.cs`
|
||||
**Risk:** `GetAccessAsync` always issues two sequential queries (budget row, then share row). Combined with controller entity loads, a single write endpoint makes 4+ DB round-trips; `GET /summary` makes 5.
|
||||
|
||||
**Fix:** Combine into one projected query:
|
||||
```csharp
|
||||
var result = await db.Budgets
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Id == budgetId)
|
||||
.Select(b => new {
|
||||
b.OwnerUserId,
|
||||
Share = b.Shares.FirstOrDefault(s => s.SharedWithUserId == userId && !s.IsPending)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ~~H-3 — Add Missing Database Indexes~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** Added 5 indexes to `AppDbContext.OnModelCreating`. Migration `20260507024323_AddHardeningIndexes` generated and verified. Replaces single-column `BudgetId` indexes on Incomes/Outgos with composite `(BudgetId, IsDeleted)` versions; adds `BudgetShares.(IsPending, SharedWithEmail)`, `BudgetShares.SharedWithUserId`, and `Budgets.OwnerUserId`. Will be applied automatically at next startup via `MigrateAsync()`.
|
||||
|
||||
---
|
||||
|
||||
### ~~H-4 — Cap `Reorder` Endpoint Input Size~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** Replaced full-array reorder with a move-by-position design. `ReorderIncomesRequest`/`ReorderOutgosRequest` removed and replaced with `MoveIncomeRequest(Guid Id, int NewIndex)` / `MoveOutgoRequest(Guid Id, int NewIndex)`. Server loads items sorted by SortOrder, finds the item, shifts only the affected range, sets the new position. Frontend sends `{ id, newIndex }` from dnd-kit's existing `oldIdx`/`newIdx` — `arrayMove` still used for optimistic local state. Input is inherently bounded to one GUID and one integer.
|
||||
|
||||
---
|
||||
|
||||
### ~~H-5 — Lock Down `ForwardedHeaders` to Known Proxy IP~~ ✅ DONE
|
||||
**Resolved 2026-05-06:** Replaced clear-all with explicit subnet trust. `KnownIPNetworks` (the .NET 10 replacement for deprecated `KnownNetworks`) is cleared and populated with `172.20.0.0/16` — the Docker network subnet for the nginx-proxy stack. Using the subnet rather than a specific container IP avoids breakage if nginx-proxy gets a different IP on restart. Deprecation warning eliminated; build clean with 0 warnings.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1 — OIDC JWKS Fetched Over Plain HTTP
|
||||
**File:** `src/Budget.Api/Program.cs`
|
||||
**Risk:** `MetadataAddress` points to `http://auth:8080/...`. JWKS (token signing keys) arrive over plain HTTP. On a compromised Docker network, an attacker could substitute keys and forge valid JWTs. Low severity on a single-host deployment; higher risk in any multi-host topology.
|
||||
|
||||
**Fix:** Enable internal TLS on the auth service, or pin the signing key in configuration instead of relying on discovery.
|
||||
|
||||
---
|
||||
|
||||
### ~~M-2 — Unknown `Frequency` Enum Crashes Entire Budget~~ ✅ DONE
|
||||
**File:** `src/Budget.Core/Services/FrequencyCalculator.cs`
|
||||
**Risk:** An unrecognised enum integer (data corruption or future migration) throws `ArgumentOutOfRangeException`, making all income/outgo/summary endpoints permanently broken for that budget and generating error spans that amplify OTel export volume.
|
||||
|
||||
**Fix:** Validate enum values at the controller layer with `[EnumDataType(typeof(Frequency))]` on write DTOs. Return a graceful default or 422 in the calculator rather than throwing.
|
||||
|
||||
---
|
||||
|
||||
### ~~M-3 — Missing `AsNoTracking()` on Read Queries~~ ✅ DONE
|
||||
**Files:** `src/Budget.Api/Controllers/SummaryController.cs`, `IncomesController.cs`, `OutgosController.cs`
|
||||
**Risk:** EF change tracking runs on every loaded entity even for pure-read endpoints, wasting CPU/memory. With OTel EF instrumentation active, every tracked entity event adds overhead.
|
||||
|
||||
**Fix:** Add `.AsNoTracking()` to all `List` and `Get` queries that do not write.
|
||||
|
||||
---
|
||||
|
||||
### ~~M-4 — `Thread.Sleep` in Async Startup Loop~~ ✅ DONE
|
||||
**File:** `src/Budget.Api/Program.cs`
|
||||
**Risk:** Synchronous `Thread.Sleep` inside the async OIDC discovery retry loop blocks a thread pool thread for up to 2 minutes during container startup.
|
||||
|
||||
**Fix:**
|
||||
```csharp
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ~~M-5 — `AllowedHosts: "*"` Disables Host Header Validation~~ ✅ DONE
|
||||
**File:** `src/Budget.Api/appsettings.json`
|
||||
**Risk:** Allows HTTP host header injection if the app is ever behind a misconfigured proxy.
|
||||
|
||||
**Fix:** Set to the actual public hostname:
|
||||
```json
|
||||
"AllowedHosts": "budget.stwaddle.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Low / Informational
|
||||
|
||||
| ID | File | Issue | Fix |
|
||||
|----|------|-------|-----|
|
||||
| L-1 | `appsettings.json` | Docker-internal `MetadataAddress` in base config — if the Docker service name `auth` is not resolvable, OIDC discovery silently fails | Move `MetadataAddress` to a Docker Compose env override; base config default should be empty |
|
||||
| L-2 | `Program.cs` | No timeout on startup `MigrateAsync()` — hangs indefinitely if Postgres is unresponsive | Wrap with a `CancellationTokenSource` with a deadline |
|
||||
| L-3 | `ErrorHandlingMiddleware.cs` | Full exception stack traces exported via OTel on every unhandled error — exception storms spike collector disk I/O | Consider `LogWarning` for expected exceptions; reserve `LogError` for truly unexpected ones |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. ~~**C-1** — Guard `UseOtlpExporter()` (likely root cause; deploy ASAP)~~ ✅ Done (2026-05-06)
|
||||
2. ~~**H-1** — Throttle `KnownUserMiddleware` writes (amplifies Disk I/O on every request)~~ ✅ Done (2026-05-06)
|
||||
3. ~~**C-2** — Auth-gate or simplify `/healthz`~~ ✅ Removed entirely (2026-05-06)
|
||||
4. ~~**H-3** — Add missing indexes (single migration, low risk)~~ ✅ Done (2026-05-06)
|
||||
5. ~~**C-3** — Move rate limiter before static files~~ ✅ Done (2026-05-06)
|
||||
6. ~~**H-4** — Cap `Reorder` input~~ ✅ Done — redesigned as move-by-position (2026-05-06)
|
||||
7. ~~**H-5** — Lock down `ForwardedHeaders` to proxy IP~~ ✅ Done (2026-05-06)
|
||||
8. ~~**M-3** — Add `AsNoTracking()` across read controllers~~ ✅ Done (2026-05-06)
|
||||
9. ~~**M-2** — Harden `FrequencyCalculator` enum handling~~ ✅ Done (2026-05-06)
|
||||
10. ~~**M-4** — `Thread.Sleep` → `await Task.Delay` in startup loop~~ ✅ Done (2026-05-06)
|
||||
11. ~~**M-5** — `AllowedHosts: "*"` → `budget.stwaddle.com`~~ ✅ Done (2026-05-06)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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 <Name> --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
|
||||
.\deploy.ps1 # builds image, pushes to docker.stwaddle.com/budget:latest, and deploys to prod via SSH
|
||||
```
|
||||
|
||||
## 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 `<tbody>` (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.
|
||||
- **Drag-and-drop ordering**: `@dnd-kit` is used on income/outgo rows. Row order is persisted to the server. Sort handles are hidden while a column sort is active.
|
||||
- **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.
|
||||
|
||||
### Local development environment
|
||||
Required env vars when running the API locally (set in `appsettings.Development.json` or shell):
|
||||
- `ConnectionStrings__DefaultConnection` — Postgres connection string
|
||||
- `Oidc__Audience` — `budget_api`
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` — optional; omit or set to skip telemetry export
|
||||
|
||||
### Auth / OIDC
|
||||
- Authority: `https://auth.stwaddle.com/` (OpenIddict-based, at `src.Auth.Server` in a sibling repo)
|
||||
- API audience claim: `budget_api` (underscore — must match `Oidc__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`
|
||||
@@ -1,4 +0,0 @@
|
||||
$image = "docker.stwaddle.com/budget:latest"
|
||||
docker build -t $image .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
docker push $image
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
$image = "docker.stwaddle.com/budget:latest"
|
||||
|
||||
docker build -t $image .
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
docker push $image
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
ssh stwaddle_com "cd stwaddlecom && docker compose pull budget && docker compose down budget && docker compose up -d budget"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host "Waiting for container to start..."
|
||||
Start-Sleep -Seconds 8
|
||||
ssh stwaddle_com "docker inspect --format='{{.State.Status}}' stwaddlecom-budget-1"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
@@ -1,40 +0,0 @@
|
||||
services:
|
||||
budget:
|
||||
image: ${IMAGE_REGISTRY:-}budget:latest
|
||||
environment:
|
||||
- VIRTUAL_HOST=${BUDGET_VIRTUAL_HOST:-budget.stwaddle.com}
|
||||
- LETSENCRYPT_HOST=${BUDGET_LETSENCRYPT_HOST:-budget.stwaddle.com}
|
||||
- VIRTUAL_PORT=8080
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
|
||||
- ConnectionStrings__DefaultConnection=Host=apps-db;Port=5432;Database=${POSTGRES_DB:-budget};Username=${POSTGRES_USER:-budget};Password=${POSTGRES_PASSWORD}
|
||||
depends_on:
|
||||
- apps-db
|
||||
- auth
|
||||
networks:
|
||||
- web
|
||||
- apps-internal
|
||||
- auth-public
|
||||
restart: unless-stopped
|
||||
|
||||
apps-db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-budget}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-budget}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- apps-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- apps-internal
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
apps-internal:
|
||||
external: true
|
||||
auth-public:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
apps-db-data:
|
||||
@@ -36,6 +36,7 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes
|
||||
.AsNoTracking()
|
||||
.Where(i => i.BudgetId == budgetId)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ToListAsync();
|
||||
@@ -94,17 +95,20 @@ public class IncomesController(AppDbContext db, BudgetAuthorizationService authz
|
||||
|
||||
[HttpPut("order")]
|
||||
[EnableRateLimiting("writes")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderIncomesRequest req)
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveIncomeRequest req)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = incomes.ToDictionary(i => i.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
{
|
||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var income))
|
||||
income.SortOrder = idx;
|
||||
}
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).OrderBy(i => i.SortOrder).ToListAsync();
|
||||
var item = incomes.FirstOrDefault(i => i.Id == req.Id);
|
||||
if (item is null) return NotFound();
|
||||
var oldIdx = incomes.IndexOf(item);
|
||||
var newIdx = Math.Clamp(req.NewIndex, 0, incomes.Count - 1);
|
||||
if (oldIdx == newIdx) return NoContent();
|
||||
incomes.RemoveAt(oldIdx);
|
||||
incomes.Insert(newIdx, item);
|
||||
for (int i = 0; i < incomes.Count; i++)
|
||||
incomes[i].SortOrder = i;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
|
||||
private async Task<decimal> GetMonthlyIncomeAsync(Guid budgetId)
|
||||
{
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
return incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var monthlyIncome = await GetMonthlyIncomeAsync(budgetId);
|
||||
return Ok(outgos.Select(o => ToDto(o, monthlyIncome)));
|
||||
}
|
||||
@@ -112,17 +112,20 @@ public class OutgosController(AppDbContext db, BudgetAuthorizationService authz)
|
||||
|
||||
[HttpPut("order")]
|
||||
[EnableRateLimiting("writes")]
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] ReorderOutgosRequest req)
|
||||
public async Task<IActionResult> Reorder(Guid budgetId, [FromBody] MoveOutgoRequest req)
|
||||
{
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanWriteAsync(budgetId, userId)) return Forbid();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
var lookup = outgos.ToDictionary(o => o.Id);
|
||||
for (int idx = 0; idx < req.OrderedIds.Count; idx++)
|
||||
{
|
||||
if (lookup.TryGetValue(req.OrderedIds[idx], out var outgo))
|
||||
outgo.SortOrder = idx;
|
||||
}
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).OrderBy(o => o.SortOrder).ToListAsync();
|
||||
var item = outgos.FirstOrDefault(o => o.Id == req.Id);
|
||||
if (item is null) return NotFound();
|
||||
var oldIdx = outgos.IndexOf(item);
|
||||
var newIdx = Math.Clamp(req.NewIndex, 0, outgos.Count - 1);
|
||||
if (oldIdx == newIdx) return NoContent();
|
||||
outgos.RemoveAt(oldIdx);
|
||||
outgos.Insert(newIdx, item);
|
||||
for (int i = 0; i < outgos.Count; i++)
|
||||
outgos[i].SortOrder = i;
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ public class SummaryController(AppDbContext db, BudgetAuthorizationService authz
|
||||
if (TryGetUserId(out var userId) is { } err) return err;
|
||||
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
||||
|
||||
var budget = await db.Budgets.FindAsync(budgetId);
|
||||
var budget = await db.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == budgetId);
|
||||
if (budget is null) return NotFound();
|
||||
|
||||
var incomes = await db.Incomes.Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var outgos = await db.Outgos.Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
||||
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).ToListAsync();
|
||||
|
||||
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
||||
var annualIncome = monthlyIncome * 12m;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Budget.Core.Models;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Budget.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize(Roles = "admin,user")]
|
||||
public class UsersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpPost("me")]
|
||||
public async Task<IActionResult> RegisterMe()
|
||||
{
|
||||
var sub = User.FindFirst("sub")?.Value;
|
||||
var email = User.FindFirst("email")?.Value;
|
||||
var name = User.FindFirst("name")?.Value;
|
||||
|
||||
if (sub is null || email is null || name is null)
|
||||
return Unauthorized();
|
||||
|
||||
var known = await db.KnownUsers.FindAsync(sub);
|
||||
if (known is null)
|
||||
{
|
||||
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
else
|
||||
{
|
||||
known.Email = email;
|
||||
known.Name = name;
|
||||
known.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var pending = await db.BudgetShares
|
||||
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var share in pending)
|
||||
{
|
||||
share.SharedWithUserId = sub;
|
||||
share.IsPending = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
+41
-29
@@ -3,33 +3,36 @@ using Budget.Api.Services;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Budget.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
||||
if (!string.IsNullOrEmpty(otlpEndpoint))
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource
|
||||
.AddService(serviceName: "budget", serviceVersion: "1.0.0"))
|
||||
.WithLogging()
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddAspNetCoreInstrumentation(opts =>
|
||||
opts.Filter = ctx => ctx.Request.Path.StartsWithSegments("/api"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
.AddEntityFrameworkCoreInstrumentation())
|
||||
.UseOtlpExporter();
|
||||
}
|
||||
|
||||
var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? $"Host={builder.Configuration["POSTGRES_HOST"] ?? "localhost"};" +
|
||||
@@ -38,13 +41,32 @@ var connStr = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
$"Username={builder.Configuration["POSTGRES_USER"] ?? "budget"};" +
|
||||
$"Password={builder.Configuration["POSTGRES_PASSWORD"] ?? "changeme"}";
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseNpgsql(connStr));
|
||||
builder.Services.AddSingleton<Budget.Infrastructure.Data.SlowQueryInterceptor>();
|
||||
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
||||
{
|
||||
opt.UseNpgsql(connStr);
|
||||
opt.AddInterceptors(sp.GetRequiredService<Budget.Infrastructure.Data.SlowQueryInterceptor>());
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownNetworks.Clear();
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
|
||||
| ForwardedHeaders.XForwardedProto
|
||||
| ForwardedHeaders.XForwardedHost;
|
||||
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
var trustedNetworks = builder.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12";
|
||||
foreach (var cidr in trustedNetworks.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = cidr.Split('/');
|
||||
var prefix = System.Net.IPAddress.Parse(parts[0]);
|
||||
var prefixLength = parts.Length > 1
|
||||
? int.Parse(parts[1])
|
||||
: (prefix.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 128 : 32);
|
||||
options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength));
|
||||
}
|
||||
});
|
||||
|
||||
var oidc = builder.Configuration.GetSection("Oidc");
|
||||
@@ -109,9 +131,6 @@ builder.Services.AddControllers()
|
||||
.AddJsonOptions(opts =>
|
||||
opts.JsonSerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Block startup until OIDC discovery succeeds (handles auth/app container race).
|
||||
@@ -126,6 +145,8 @@ while (true)
|
||||
jwtOpts.ConfigurationManager!.RequestRefresh();
|
||||
await jwtOpts.ConfigurationManager!.GetConfigurationAsync(CancellationToken.None);
|
||||
startupLogger.LogInformation("OIDC discovery succeeded");
|
||||
startupLogger.LogInformation("Trusted proxy networks: {Networks}",
|
||||
app.Configuration["Budget:TrustedProxyNetworks"] ?? "172.16.0.0/12 (default)");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -136,7 +157,7 @@ while (true)
|
||||
break;
|
||||
}
|
||||
startupLogger.LogWarning(ex, "OIDC discovery failed; retrying in 5 s");
|
||||
Thread.Sleep(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,23 +174,14 @@ app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Authentication must run before the rate limiter so limiter partitions can
|
||||
// read the validated "sub" claim; otherwise every authenticated request falls
|
||||
// back to the per-IP bucket (auth-hardening-review.md, finding B-1).
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<KnownUserMiddleware>();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
ResultStatusCodes =
|
||||
{
|
||||
[HealthStatus.Healthy] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Degraded] = StatusCodes.Status200OK,
|
||||
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
|
||||
}
|
||||
});
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
using Budget.Core.Models;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Budget.Api.Services;
|
||||
|
||||
public class KnownUserMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, AppDbContext db)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var sub = context.User.FindFirst("sub")?.Value;
|
||||
var email = context.User.FindFirst("email")?.Value;
|
||||
var name = context.User.FindFirst("name")?.Value;
|
||||
|
||||
if (sub != null && email != null && name != null)
|
||||
{
|
||||
var known = await db.KnownUsers.FindAsync(sub);
|
||||
if (known is null)
|
||||
{
|
||||
db.KnownUsers.Add(new KnownUser { Id = sub, Email = email, Name = name, LastSeenAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
else
|
||||
{
|
||||
known.Email = email;
|
||||
known.Name = name;
|
||||
known.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// Resolve pending shares for this user's email
|
||||
var pending = await db.BudgetShares
|
||||
.Where(s => s.IsPending && s.SharedWithEmail == email)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var share in pending)
|
||||
{
|
||||
share.SharedWithUserId = sub;
|
||||
share.IsPending = false;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedHosts": "budget.stwaddle.com",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
delete: (path: string) => request<void>('DELETE', path),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { IncomeDto, Frequency } from '../types/index';
|
||||
|
||||
interface CreateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||
interface UpdateIncomeRequest { name: string; frequency: Frequency; amount: number; }
|
||||
interface ReorderRequest { orderedIds: string[]; }
|
||||
interface MoveRequest { id: string; newIndex: number; }
|
||||
|
||||
export function useIncomes(budgetId: string) {
|
||||
return useQuery({
|
||||
@@ -44,7 +44,7 @@ export function useDeleteIncome(budgetId: string) {
|
||||
export function useReorderIncomes(budgetId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
||||
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/incomes/order`, req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'incomes'] }),
|
||||
meta: { errorMessage: 'Failed to save order' },
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ interface CreateOutgoRequest {
|
||||
frequency: Frequency; amount: number; paymentSource: string | null; notes: string | null;
|
||||
}
|
||||
interface UpdateOutgoRequest extends CreateOutgoRequest { id: string; }
|
||||
interface ReorderRequest { orderedIds: string[]; }
|
||||
interface MoveRequest { id: string; newIndex: number; }
|
||||
|
||||
export function useOutgos(budgetId: string) {
|
||||
return useQuery({
|
||||
@@ -61,7 +61,7 @@ export function useDeleteOutgo(budgetId: string) {
|
||||
export function useReorderOutgos(budgetId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: ReorderRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
||||
mutationFn: (req: MoveRequest) => api.put<void>(`/api/budgets/${budgetId}/outgos/order`, req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['budgets', budgetId, 'outgos'] }),
|
||||
meta: { errorMessage: 'Failed to save order' },
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { setTokenProvider } from '../api/client';
|
||||
import { setTokenProvider, api } from '../api/client';
|
||||
|
||||
export function TokenSync() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setTokenProvider(() => auth.user?.access_token ?? null);
|
||||
if (auth.user?.access_token) {
|
||||
api.post<void>('/api/users/me', undefined).catch(() => {});
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -238,9 +238,8 @@ export function IncomePage() {
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = displayItems.findIndex(i => i.id === active.id);
|
||||
const newIdx = displayItems.findIndex(i => i.id === over.id);
|
||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
||||
setDisplayItems(reordered);
|
||||
reorderIncomes.mutate({ orderedIds: reordered.map(i => i.id) });
|
||||
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||
reorderIncomes.mutate({ id: active.id as string, newIndex: newIdx });
|
||||
};
|
||||
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={6} /></>;
|
||||
|
||||
@@ -384,9 +384,8 @@ export function OutgoPage() {
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = displayItems.findIndex(o => o.id === active.id);
|
||||
const newIdx = displayItems.findIndex(o => o.id === over.id);
|
||||
const reordered = arrayMove(displayItems, oldIdx, newIdx);
|
||||
setDisplayItems(reordered);
|
||||
reorderOutgos.mutate({ orderedIds: reordered.map(o => o.id) });
|
||||
setDisplayItems(items => arrayMove(items, oldIdx, newIdx));
|
||||
reorderOutgos.mutate({ id: active.id as string, newIndex: newIdx });
|
||||
};
|
||||
|
||||
if (isLoading) return <><BudgetNav /><LoadingSkeleton rows={5} cols={9} /></>;
|
||||
|
||||
@@ -23,4 +23,4 @@ public record UpdateIncomeRequest(
|
||||
Frequency Frequency,
|
||||
[Range(0, 9_999_999_999_999_999.99)] decimal Amount);
|
||||
|
||||
public record ReorderIncomesRequest([Required] List<Guid> OrderedIds);
|
||||
public record MoveIncomeRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||
|
||||
@@ -36,4 +36,4 @@ public record UpdateOutgoRequest(
|
||||
[MaxLength(100)] string? PaymentSource,
|
||||
[MaxLength(1000)] string? Notes);
|
||||
|
||||
public record ReorderOutgosRequest([Required] List<Guid> OrderedIds);
|
||||
public record MoveOutgoRequest([Required] Guid Id, [Range(0, int.MaxValue)] int NewIndex);
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class FrequencyCalculator
|
||||
Frequency.SemiMonthly => amount * 2m,
|
||||
Frequency.Biweekly => amount * 26m / 12m,
|
||||
Frequency.Weekly => amount * 52m / 12m,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(frequency)),
|
||||
_ => 0m,
|
||||
};
|
||||
|
||||
public static decimal ToAnnually(decimal amount, Frequency frequency) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
b.HasIndex(x => x.OwnerUserId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Income>(b =>
|
||||
@@ -34,6 +35,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
|
||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Outgo>(b =>
|
||||
@@ -45,6 +47,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.Notes).HasMaxLength(1000);
|
||||
b.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
b.HasIndex(x => new { x.BudgetId, x.IsDeleted });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnownUser>(b =>
|
||||
@@ -61,6 +64,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
b.Property(x => x.SharedWithUserId).HasMaxLength(200);
|
||||
b.Property(x => x.SharedWithEmail).IsRequired().HasMaxLength(200);
|
||||
b.HasIndex(x => new { x.BudgetId, x.SharedWithEmail }).IsUnique();
|
||||
b.HasIndex(x => x.SharedWithUserId);
|
||||
b.HasIndex(x => new { x.IsPending, x.SharedWithEmail });
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
});
|
||||
}
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Budget.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Budget.Infrastructure.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260507024323_AddHardeningIndexes")]
|
||||
partial class AddHardeningIndexes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<uint>("xmin")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsPending")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SharedWithEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SharedWithUserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SharedWithUserId");
|
||||
|
||||
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsPending", "SharedWithEmail");
|
||||
|
||||
b.ToTable("BudgetShares");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Frequency")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Incomes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.KnownUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("KnownUsers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<Guid>("BudgetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Frequency")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("PaymentSource")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Outgos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.BudgetShare", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Shares")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Income", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Incomes")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Outgo", b =>
|
||||
{
|
||||
b.HasOne("Budget.Core.Models.Budget", "Budget")
|
||||
.WithMany("Outgos")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Budget.Core.Models.Budget", b =>
|
||||
{
|
||||
b.Navigation("Incomes");
|
||||
|
||||
b.Navigation("Outgos");
|
||||
|
||||
b.Navigation("Shares");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Budget.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHardeningIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Outgos_BudgetId",
|
||||
table: "Outgos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Incomes_BudgetId",
|
||||
table: "Incomes");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||
table: "Outgos",
|
||||
columns: new[] { "BudgetId", "IsDeleted" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||
table: "Incomes",
|
||||
columns: new[] { "BudgetId", "IsDeleted" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||
table: "BudgetShares",
|
||||
columns: new[] { "IsPending", "SharedWithEmail" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetShares_SharedWithUserId",
|
||||
table: "BudgetShares",
|
||||
column: "SharedWithUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Budgets_OwnerUserId",
|
||||
table: "Budgets",
|
||||
column: "OwnerUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Outgos_BudgetId_IsDeleted",
|
||||
table: "Outgos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Incomes_BudgetId_IsDeleted",
|
||||
table: "Incomes");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BudgetShares_IsPending_SharedWithEmail",
|
||||
table: "BudgetShares");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BudgetShares_SharedWithUserId",
|
||||
table: "BudgetShares");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Budgets_OwnerUserId",
|
||||
table: "Budgets");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Outgos_BudgetId",
|
||||
table: "Outgos",
|
||||
column: "BudgetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Incomes_BudgetId",
|
||||
table: "Incomes",
|
||||
column: "BudgetId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
@@ -96,9 +98,13 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SharedWithUserId");
|
||||
|
||||
b.HasIndex("BudgetId", "SharedWithEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsPending", "SharedWithEmail");
|
||||
|
||||
b.ToTable("BudgetShares");
|
||||
});
|
||||
|
||||
@@ -134,7 +140,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId");
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Incomes");
|
||||
});
|
||||
@@ -210,7 +216,7 @@ namespace Budget.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BudgetId");
|
||||
b.HasIndex("BudgetId", "IsDeleted");
|
||||
|
||||
b.ToTable("Outgos");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Budget.Infrastructure.Data;
|
||||
|
||||
public class SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger) : DbCommandInterceptor
|
||||
{
|
||||
private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<DbDataReader> ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return new ValueTask<DbDataReader>(result);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return new ValueTask<int>(result);
|
||||
}
|
||||
|
||||
public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<object?> ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LogIfSlow(command, eventData.Duration);
|
||||
return new ValueTask<object?>(result);
|
||||
}
|
||||
|
||||
private void LogIfSlow(DbCommand command, TimeSpan duration)
|
||||
{
|
||||
if (duration >= Threshold)
|
||||
logger.LogWarning("Slow query ({Duration}ms): {CommandText}",
|
||||
(long)duration.TotalMilliseconds, command.CommandText);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user