diff --git a/.env.example b/.env.example index fe41ea9..6cc5913 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,5 @@ Seed__DemoAdminEmail=admin@widgetworks.demo Seed__DemoAdminPassword=DemoAdmin!Change01 Seed__DemoCustomerEmail=demo@widgetworks.demo Seed__DemoCustomerPassword=DemoUser!Change01 +Seed__DemoManagerEmail=manager@widgetworks.demo +Seed__DemoManagerPassword=DemoManager!Change01 diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..b6ac9ab --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,112 @@ +name: Deploy API + +# Fires ONLY for changes that can affect the compiled API. `paths` is an allowlist, so a +# docs-only or web-only commit simply does not match and no deployment runs — the SPA has its +# own workflow, and neither is triggered by markdown. +on: + push: + branches: [main] + paths: + - 'src/**' + - 'tests/**' + - 'Directory.Build.props' + - 'global.json' + - 'WidgetWorks.slnx' + - 'Dockerfile.api' + - 'docker-compose.yml' + - 'scripts/smoke-test.ps1' + - '.github/workflows/deploy-api.yml' + - '.github/workflows/test-suite.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-api + cancel-in-progress: false + +env: + APP_NAME: widgetworks-api-41d09d + RESOURCE_GROUP: rg-widgetworks + +jobs: + # The gate. Backend units, frontend units and the end-to-end smoke test must all pass; + # `needs: tests` below means a single failure stops the deployment entirely. + tests: + name: Tests + uses: ./.github/workflows/test-suite.yml + + deploy: + name: Publish and deploy the API + needs: tests + runs-on: ubuntu-latest + environment: production + # No stored Azure credential: id-token lets the job exchange a short-lived GitHub OIDC + # token for an Azure one. contents:read is all the checkout needs. + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + + # Publish to a directory OUTSIDE the workspace, so the artifact cannot pick up the + # repository. Deploying the repo would serve .cs sources and git history from wwwroot. + - name: Publish (Release) + run: dotnet publish src/WidgetWorks.WebApi/WidgetWorks.WebApi.csproj -c Release -o "${{ runner.temp }}/publish" --nologo + + - name: Audit the publish output + run: | + cd "${{ runner.temp }}/publish" + bad=$(find . \( -name '*.cs' -o -name '*.csproj' -o -name '*.sln*' -o -name '.env' \ + -o -name 'docker-compose*.yml' \) -print) + for d in .git node_modules src web tests docs; do + [ -e "$d" ] && bad="$bad $d/" + done + if [ -n "$(printf '%s' "$bad" | tr -d '[:space:]')" ]; then + echo "::error::Publish output contains files that must never be deployed:" + printf '%s\n' "$bad" + exit 1 + fi + test -f WidgetWorks.WebApi.dll || { echo "::error::app dll missing"; exit 1; } + test -f appsettings.json || { echo "::error::appsettings.json missing"; exit 1; } + echo "Clean: $(find . -type f | wc -l) build-output files" + + - name: Azure login (OIDC — no stored credential) + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy + uses: azure/webapps-deploy@2fdd5c3ebb4e540834e86ecc1f6fdcd5539023ee # v3.0.2 + with: + app-name: ${{ env.APP_NAME }} + package: ${{ runner.temp }}/publish + + # The API degrades to a 503 rather than crash-looping when the database is unreachable, + # so a bad deploy shows up here instead of silently eating the F1 tier's CPU quota. + - name: Health check (stops the app if unhealthy) + run: | + for i in $(seq 1 20); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \ + "https://${{ env.APP_NAME }}.azurewebsites.net/health" || true) + if [ "$code" = "200" ]; then echo "healthy"; exit 0; fi + if [ "$code" = "503" ]; then + echo "::error::App is up but unhealthy (database unreachable). Stopping it to protect CPU quota." + az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}" + exit 1 + fi + echo " $code (attempt $i/20)" + sleep 6 + done + echo "::error::No healthy response; stopping the app to protect CPU quota." + az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}" + exit 1 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..f1afb11 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,74 @@ +name: Deploy web + +# Fires ONLY for changes under web/. `paths` is an allowlist, so a docs-only or API-only commit +# does not match and no deployment runs. The API has its own workflow; neither is triggered by +# markdown. +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/workflows/deploy-web.yml' + - '.github/workflows/test-suite.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-web + cancel-in-progress: true + +jobs: + # The same gate the API deploy uses. The SPA is useless against a broken API, so the + # end-to-end smoke test guards this deployment too, not just the frontend unit tests. + tests: + name: Tests + uses: ./.github/workflows/test-suite.yml + + deploy: + name: Build and deploy the SPA + needs: tests + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: web + env: + # Public build-time config from Actions Variables. Vite inlines both into the bundle, so + # neither may ever be a secret: VITE_* values ship to every browser. + VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + VITE_GOOGLE_CLIENT_ID: ${{ vars.VITE_GOOGLE_CLIENT_ID }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Build + run: npm run build + + # staticwebapp.config.json ships a placeholder because the API hostname is not known at + # authoring time. Without this substitution the CSP blocks every call the SPA makes to its + # own API, and Google sign-in fails silently. + - name: Point the CSP at the API + run: | + test -n "$VITE_API_BASE_URL" || { echo "::error::VITE_API_BASE_URL variable is not set"; exit 1; } + cfg=dist/staticwebapp.config.json + test -f "$cfg" || { echo "::error::$cfg missing — it must live in web/public/"; exit 1; } + sed -i "s|https://REPLACE_API_ORIGIN|${VITE_API_BASE_URL}|" "$cfg" + if grep -q REPLACE_API_ORIGIN "$cfg"; then + echo "::error::CSP placeholder not substituted"; exit 1 + fi + grep -o "connect-src[^;]*" "$cfg" + + # Uploads dist/ only — the built bundle, never the source tree. + - name: Deploy to Static Web Apps + uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + action: upload + app_location: web/dist + skip_app_build: true + skip_api_build: true diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000..ccf9a32 --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,76 @@ +name: Test suite + +# Reusable gate: every deployment calls this and will not proceed unless all three jobs pass. +# Kept in one file so the API and web deploys cannot drift apart on what "tests passed" means. +on: + workflow_call: + +permissions: + contents: read + +jobs: + backend: + name: Backend unit tests (xUnit) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + - name: Test + run: dotnet test WidgetWorks.slnx --configuration Release --nologo + + frontend: + name: Frontend unit tests (Vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install + run: npm ci --no-audit --no-fund + - name: Test + run: npm test + - name: Build (type-check + bundle) + run: npm run build + + smoke: + name: API smoke test (end-to-end) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Prepare env (placeholder values are valid for CI) + run: cp .env.example .env + + - name: Start database + API + run: docker compose up -d --build db api + + - name: Wait for API health + run: | + for i in $(seq 1 80); do + if curl -fsS http://localhost:8080/health >/dev/null 2>&1; then + echo "API healthy"; exit 0 + fi + sleep 3 + done + echo "::error::API did not become healthy in time" + docker compose logs api + exit 1 + + - name: Run smoke test (PowerShell) + shell: pwsh + run: ./scripts/smoke-test.ps1 -BaseUrl http://localhost:8080 + + - name: Dump container logs on failure + if: failure() + run: docker compose logs + + - name: Tear down + if: always() + run: docker compose down -v diff --git a/README.md b/README.md index 832c204..c166af5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ docker compose up --build | What | URL | |---|---| -| Store (SPA) | http://localhost:3000 | +| **Start here** — demo guide / landing page | http://localhost:3000 | +| Store (SPA) | http://localhost:3000/store | +| **Mailpit** — every email the app sends | http://localhost:8025 | | API + Scalar (interactive API UI) | http://localhost:8080/scalar/v1 | | Health | http://localhost:8080/health | @@ -35,10 +37,11 @@ with fast iteration (and the exact port/user-secrets details), see ### Demo accounts (local demo only) -| Role | Email | Password | -|------|-------|----------| -| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` | -| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` | +| Role | Email | Password | What it can do | +|------|-------|----------|----------------| +| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` | Everything — plus retiring a widget and managing users | +| Manager | `manager@widgetworks.demo` | `DemoManager!Change01` | Catalog + order fulfilment; **not** delete or user management | +| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` | Shop, check out, see their own orders | Passwords are set from `.env` / user-secrets at seed time — the one sanctioned, documented "credential" in the repo. The admin has no 2FA by default, so it logs straight in. @@ -134,11 +137,15 @@ src/ WidgetWorks.WebApi Minimal API endpoints, DI, auth wiring tests/WidgetWorks.UnitTests xUnit tests with in-memory fakes + FakeTimeProvider web/ React + TypeScript SPA (Vite) — see web/README.md -scripts/ smoke-test.ps1 and tooling +infra/ Provision.ps1 — idempotent Azure provisioning +scripts/ smoke-test.ps1, deploy helpers, tooling +.github/workflows/ CI, path-scoped deploys, the reusable test suite docs/ handbook + architecture ADRs Dockerfile.api, Dockerfile.web, docker-compose.yml ``` ## License -See [`LICENSE`](LICENSE). +No `LICENSE` file is published yet, so the default applies: **all rights reserved** — the +source is public to read, not licensed for reuse. Adding a license (MIT is the usual choice +for a portfolio project) is a one-file change. diff --git a/SECURITY.md b/SECURITY.md index a15c9e8..c10b531 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,8 +24,9 @@ artifacts are committed to this repository. This is enforced at three layers: ### The one sanctioned exception: demo seed accounts -The seeded **demo admin** (`admin@widgetworks.demo`) and **demo customer** -(`demo@widgetworks.demo`) use documented, throwaway credentials so reviewers can +The seeded **demo admin** (`admin@widgetworks.demo`), **demo manager** +(`manager@widgetworks.demo`) and **demo customer** (`demo@widgetworks.demo`) use +documented, throwaway credentials so reviewers can log in. These are intentionally public, are the only "credentials" in the repo, and are allowlisted in `.gitleaks.toml`. They grant access only to a local, disposable demo database. diff --git a/docker-compose.yml b/docker-compose.yml index 9a4c518..80f7504 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: Google__ClientId: ${Google__ClientId:-} Seed__DemoAdminPassword: ${Seed__DemoAdminPassword:-DemoAdmin!Change01} Seed__DemoCustomerPassword: ${Seed__DemoCustomerPassword:-DemoUser!Change01} + Seed__DemoManagerPassword: ${Seed__DemoManagerPassword:-DemoManager!Change01} ports: - "8080:8080" depends_on: diff --git a/docs/architecture/02-Architecture-and-Technical-Design.md b/docs/architecture/02-Architecture-and-Technical-Design.md index c309756..592db5a 100644 --- a/docs/architecture/02-Architecture-and-Technical-Design.md +++ b/docs/architecture/02-Architecture-and-Technical-Design.md @@ -321,7 +321,7 @@ Recommended default: a **composite** of small strategies so the calculation is v ## 11. Migrations without EF - **DbUp** runs ordered, embedded **`.sql`** scripts on startup, tracking applied scripts in a journal table. Plain SQL keeps us "close to production" and reviewable. -- Seed data ships as idempotent scripts including **two demo accounts** documented in the README: (1) an **immutable admin** (`admin@widgetworks.demo`) managing widgets/inventory/orders, and (2) a **demo customer** (`demo@widgetworks.demo`). Plus categories and sample **widgets**, each with a name, description, product image, price, weight, and opening quantity on hand (`quantity_reserved = 0`). +- Seed data ships as idempotent scripts including **three demo accounts** documented in the README: (1) an **immutable admin** (`admin@widgetworks.demo`) managing widgets/inventory/orders, (2) a **manager** (`manager@widgetworks.demo`) with catalog and order-fulfilment rights but no delete or user management, and (3) a **demo customer** (`demo@widgetworks.demo`). Plus categories and sample **widgets**, each with a name, description, product image, price, weight, and opening quantity on hand (`quantity_reserved = 0`). - Alternative: **FluentMigrator** for C#‑authored migrations with up/down. --- diff --git a/docs/architecture/03-scope-decisions.md b/docs/architecture/03-scope-decisions.md index e83be2f..0055665 100644 --- a/docs/architecture/03-scope-decisions.md +++ b/docs/architecture/03-scope-decisions.md @@ -112,7 +112,8 @@ and (later) cloud/tax-service credentials. DB password, JWT signing key, and any future Avalara/TaxJar key) lives in user-secrets / Actions secrets / OIDC — **never** the repo. - The **only** allowlisted "credentials" are the documented, throwaway **demo - accounts** (`admin@widgetworks.demo`, `demo@widgetworks.demo`). + accounts** (`admin@widgetworks.demo`, `manager@widgetworks.demo`, + `demo@widgetworks.demo`). - Enforcement: pre-commit (gitleaks + detect-private-key + forbidden-artifact guard) → CI gitleaks gate → recommend enabling GitHub native **secret scanning + push protection**. diff --git a/docs/handbook/01-overview.md b/docs/handbook/01-overview.md index 9388797..41b1aca 100644 --- a/docs/handbook/01-overview.md +++ b/docs/handbook/01-overview.md @@ -8,17 +8,25 @@ parts most demos skip, on clean, testable, time-abstracted code. ## What it does +- **Landing guide** — everyone arrives at `/`, a plain-language page that explains the demo, + states up front that **no payment is ever taken**, hands out the three demo accounts with + what each role can and can't do, and links into the store at `/store`. - **Storefront** — browse/search a catalog, product detail, cart (guest or signed-in). - **Checkout** — server-side re-priced totals: subtotal → shipping → per-state US sales - tax → total; guest checkout or registered; Mock or Stripe (test) payment. + tax → total; guest checkout or registered; Mock or Stripe (test) payment, including an + asynchronous BNPL path settled by webhook. - **Accounts** — register, login, JWT access + rotating refresh tokens, **TOTP 2FA** with recovery codes, **Google OIDC** sign-in, password reset, and “secure my account” (rotate a compromised user’s sessions instantly). -- **Admin/Manager** — manage widgets and inventory, and drive order status - (Paid → Shipped → Delivered / Cancelled) with tracking; an **immutable seeded admin** - so the demo always works. +- **Three roles** — **Customer** (shop, own order history), **Manager** (catalog + order + fulfilment), **Administrator** (everything, plus retiring a widget and managing users). + All three are seeded, so every policy in the app is exercisable from the login screen. +- **Admin/Manager** — manage widgets and inventory, browse recent orders and drive their + status (Paid → Shipped → Delivered / Cancelled) with tracking. An Administrator can also + **retire** a widget: deleted outright if it was never ordered, archived if it appears on + one, so order history stays intact. An **immutable seeded admin** keeps the demo working. - **Notifications** — real transactional email (order received / shipped / cancelled, - registration, password reset). + registration, password reset), caught locally by Mailpit. ## Tech stack @@ -30,9 +38,11 @@ parts most demos skip, on clean, testable, time-abstracted code. | Auth | JWT (short-lived access + rotating refresh), per-user **security stamp**, `kid` key rotation, **TOTP 2FA** (Otp.NET), **Google OIDC** | | Time | `TimeProvider` everywhere for deterministic, testable time | | Payments | `IPaymentGateway` — Mock (default) + Stripe test mode | -| Web | **React 18 + TypeScript** (Vite) SPA | -| Run | **Docker Compose** (db + api + web) | +| Web | **React 18 + TypeScript** (Vite 8) SPA, **Vitest** unit tests | +| Run | **Docker Compose** (db + api + web + **Mailpit** mail catcher) | | CI | GitHub Actions — gitleaks, build (warnings-as-errors) + tests, CodeQL, Dependabot, web build | +| CD | Path-scoped deploys (API and web move independently; docs move nothing), each gated on the **whole** test suite | +| Hosting | Azure **App Service F1** (API) + **Static Web Apps** (SPA) + **Key Vault** via managed identity, Postgres on **Neon** — all free tiers ([ch.10](10-deploy-azure-free.md)) | ## Repository layout @@ -45,7 +55,9 @@ src/ tests/ WidgetWorks.UnitTests xUnit tests with in-memory fakes + FakeTimeProvider web/ React + TypeScript SPA (Vite) -scripts/ smoke-test.ps1 and tooling +infra/ Provision.ps1 — idempotent Azure provisioning +scripts/ smoke-test.ps1, deploy helpers, tooling +.github/workflows/ CI, path-scoped deploys, the reusable test suite docs/ handbook (this) + architecture ADRs Dockerfile.api, Dockerfile.web, docker-compose.yml ``` diff --git a/docs/handbook/03-setup-and-run.md b/docs/handbook/03-setup-and-run.md index a087030..195eee3 100644 --- a/docs/handbook/03-setup-and-run.md +++ b/docs/handbook/03-setup-and-run.md @@ -15,30 +15,41 @@ docker compose up --build ``` The first build takes a few minutes (it pulls the .NET SDK image, restores NuGet, and -runs the Vite build). Wait until all three containers are up: +runs the Vite build). Wait until all four containers are up: ```powershell -docker compose ps # db (healthy), api (running), web (running) +docker compose ps # db (healthy), api, web, mailpit (running) ``` Then open: | What | URL | |---|---| -| Store (SPA) | http://localhost:3000 | +| **Start here** — demo guide / landing page | http://localhost:3000 | +| Store (SPA) | http://localhost:3000/store | +| **Mailpit** — every email the app sends | http://localhost:8025 | | API + Scalar (interactive API UI) | http://localhost:8080/scalar/v1 | | Health | http://localhost:8080/health | +The landing page at `/` explains the demo, states that no payment is ever taken, and lists +the accounts below with what each role can do — it's the same guide, in the app. + Migrations and demo seed run automatically on API start. ### Demo accounts -| Role | Email | Password (from `.env`) | -|---|---|---| -| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` | -| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` | +| Role | Email | Password (from `.env`) | What it can do | +|---|---|---|---| +| Administrator (immutable) | `admin@widgetworks.demo` | `DemoAdmin!Change01` | Everything — plus retiring a widget and managing users | +| Manager | `manager@widgetworks.demo` | `DemoManager!Change01` | Catalog (create/edit/restock/hide) and order fulfilment — but **not** delete or user management | +| Customer | `demo@widgetworks.demo` | `DemoUser!Change01` | Shop, check out, and see their own orders | + +All three are seeded on API start from the `Seed__Demo*` keys in `.env`, so every RBAC +policy in the app can be exercised from the login screen. The seeded admin has **no 2FA** by +default, so it logs straight in with email + password. -The seeded admin has **no 2FA** by default, so it logs straight in with email + password. +> These are the **only** credentials that live in the repository — a documented, throwaway +> exception. See [SECURITY.md](../../SECURITY.md). ### Stop / restart @@ -60,6 +71,7 @@ dotnet user-secrets set "Jwt:SigningKey" "$(openssl rand -base64 48)" dotnet user-secrets set "ConnectionStrings:WidgetWorks" "Host=localhost;Port=5432;Database=widgetworks;Username=widgetworks;Password=" dotnet user-secrets set "Seed:DemoAdminPassword" "DemoAdmin!Change01" dotnet user-secrets set "Seed:DemoCustomerPassword" "DemoUser!Change01" +dotnet user-secrets set "Seed:DemoManagerPassword" "DemoManager!Change01" dotnet run # API on http://localhost:5080 (Scalar UI at /scalar/v1) cd ../../web @@ -91,3 +103,10 @@ values and why. `dotnet run --environment Development`. - **Product images blank** — the sample photos come from an external service (picsum); it just needs internet. Everything else works offline. +- **No email in Mailpit** — check `Email__Provider=Smtp`, `Email__Host=mailpit`, + `Email__Port=1025`, and **`Email__UseStartTls=false`**. Port 1025 is plain SMTP; leaving + STARTTLS on is the usual reason nothing arrives. Full recipe in + [Configuration](04-configuration-and-2fa.md#reading-real-mail-locally-mailpit). +- **API up but every request 503** — the database was unreachable at startup, so migrations + were skipped and the app is running degraded on purpose rather than restart-looping. + `/health` says so; fix the connection and restart the API. diff --git a/docs/handbook/05-payments.md b/docs/handbook/05-payments.md index fe3c32f..b49b4d4 100644 --- a/docs/handbook/05-payments.md +++ b/docs/handbook/05-payments.md @@ -37,27 +37,91 @@ subtotal (sum of line items) → + shipping → + sales tax → = total No card numbers ever touch the app or the database — only a payment **token** and, after a charge, the gateway's reference id. That keeps the app out of PCI scope. -## Sales tax & the rate table +## Sales tax — how it's calculated, and what's covered -Sales tax is computed by `StateSalesTaxCalculator` on the **subtotal** (shipping is not -taxed in this model), using the **destination state** from the shipping address: +### One rule, applied server-side -- The state code is normalized (`"ca"` → `"CA"`). -- Its base rate is looked up in the rate table; an **unknown or missing state → 0%**. -- `tax = round(subtotal × rate, 2, away-from-zero)`. -- The result is `(stateCode, rate, amount)`, and the order **snapshots** `tax_state`, - `tax_rate`, and `tax` — so the exact rate charged is preserved on that order forever, - even if the table later changes. +`StateSalesTaxCalculator` needs exactly two inputs: the **destination state** from the +shipping address, and the **subtotal**. -States with **no** state sales tax — **AK, DE, MT, NH, OR** — correctly resolve to **$0**. -(There is no "tax credit" or discount concept: tax is *added* per destination state, and -no-tax states simply yield zero.) +``` +taxable = subtotal ← shipping is NOT taxed in this model +rate = rateTable[trim(upper(stateCode))] ← unlisted, unknown or blank → 0 +tax = round(taxable × rate, 2, AwayFromZero) +total = subtotal + shipping + tax +``` + +The state code is normalized before lookup (`" ca "` → `"CA"`), and the calculator returns +`TaxLine(StateCode, Rate, Amount)` where **`Rate` is a fraction** — `0.0725` means 7.25%. +Rounding is half-**away-from-zero**, the retail convention, rather than .NET's default +banker's rounding: `$6.525` bills as `$6.53`, not `$6.52`. + +None of it trusts the browser. `CheckoutHandler` re-reads unit prices from the database and +recomputes the tax at the moment the order is placed, whatever the client displayed. + +### Worked example + +Three items totalling **$89.97**, shipped Standard: + +| | to **California** | to **Oregon** | to **`""`/unknown** | +|---|---|---|---| +| Subtotal | $89.97 | $89.97 | $89.97 | +| Shipping | $0.00 *(free ≥ $75)* | $0.00 | $0.00 | +| Tax rate | `0.0725` | `0.0000` | `0.0000` | +| Tax | **$6.52** — `round(89.97 × 0.0725)` = `round(6.5228…)` | **$0.00** | **$0.00** | +| **Total** | **$96.49** | **$89.97** | **$89.97** | + +Switch that same order to **Express** and it becomes `89.97 + 22.99 + 6.52 = $119.48` — +the shipping charge rises, the tax does not, because shipping isn't in the taxable base. + +### Who pays it, and what's covered + +- **The buyer pays it.** Tax is *added* to the order total; the store never absorbs, + discounts, or nets it out. There is no exemption, resale-certificate, or tax-credit + concept in this model. +- **Nothing is remitted.** Payments run against the mock gateway (or Stripe **test** mode), + so no money — and therefore no tax — actually moves. The figure exists to exercise the + pricing path, not to satisfy a filing obligation. +- **Coverage is all 50 states + DC**, at the **state base rate only**. Five states levy no + state sales tax and correctly resolve to $0 — **AK, DE, MT, NH, OR**. Anything outside + the table (a Canadian province, a typo, an empty string) resolves to **0%** rather than + failing the order. +- **The order snapshots what it charged** — `tax_state`, `tax_rate` and `tax` are written + onto the order row, so the exact rate applied is preserved on that order forever even if + the table changes later. + +### The rate table + +Compiled in as of **`EffectiveOn` = 2025-07-01** (state base rates, as decimal fractions in +code — shown here as percentages): + +| State | Rate | State | Rate | State | Rate | State | Rate | +|---|---:|---|---:|---|---:|---|---:| +| AK | 0% | ID | 6% | MT | 0% | RI | 7% | +| AL | 4% | IL | 6.25% | NC | 4.75% | SC | 6% | +| AR | 6.5% | IN | 7% | ND | 5% | SD | 4.2% | +| AZ | 5.6% | KS | 6.5% | NE | 5.5% | TN | 7% | +| CA | 7.25% | KY | 6% | NH | 0% | TX | 6.25% | +| CO | 2.9% | LA | 4.45% | NJ | 6.625% | UT | 6.1% | +| CT | 6.35% | MA | 6.25% | NM | 4.875% | VA | 5.3% | +| DC | 6% | MD | 6% | NV | 6.85% | VT | 6% | +| DE | 0% | ME | 5.5% | NY | 4% | WA | 6.5% | +| FL | 6% | MI | 6% | OH | 5.75% | WI | 5% | +| GA | 4% | MN | 6.875% | OK | 4.5% | WV | 6% | +| HI | 4% | MO | 4.225% | OR | 0% | WY | 4% | +| IA | 6% | MS | 7% | PA | 6% | | | + +**Deliberate simplification:** real US sales tax is destination-based across thousands of +local/county/city jurisdictions, with product-category exemptions and economic-nexus rules. +This app uses a single **state-level base rate** as a documented approximation — enough to +demonstrate correct, server-side, snapshotted tax handling — with the seam in place so a +real engine replaces it without touching checkout. ### Where the rates come from — and when they update Rates come from an `ITaxRateProvider`. The default, `StaticStateTaxRateProvider`, is an -**offline, versioned** table of the 50 states + DC base rates compiled into the app. Its -freshness is made explicit by two fields on the rate set: +**offline, versioned** table compiled into the app. Its freshness is made explicit by two +fields on the rate set: - **`EffectiveOn`** — the date the rates are good as of (currently **2025-07-01**), and - **`Source`** — a note on where the numbers came from. @@ -74,11 +138,35 @@ table" therefore means one of two things: checkout**. A live engine is what actually "checks for updates" (per request or on its own schedule); the built-in table intentionally does not. This is the production path (ADR-022). -**Deliberate simplification:** real US sales tax is destination-based across thousands of -local/county/city jurisdictions, with product-category exemptions and economic-nexus rules. -This app uses a single **state-level base rate** as a documented approximation — enough to -show correct, server-side, snapshotted tax handling — with the seam in place so a real engine -replaces it without touching checkout. +### Seeing the numbers without placing an order + +`POST /checkout/quote` runs the **same** shipping and tax calculators without creating an +order, which is how the cart and checkout screens show a live breakdown as you pick a state +or a shipping method: + +```json +{ "subtotal": 89.97, "shippingMethod": "Standard", "shipping": 0.00, + "stateCode": "CA", "taxRate": 0.0725, "tax": 6.52, "total": 96.49, + "itemCount": 3, "isEmpty": false } +``` + +`GET /checkout/tax-info` reports the table's provenance rather than any rate — +`{ effectiveOn, source, stateCount }` — so staleness is visible from outside the app. +`GET /checkout/shipping-methods` lists the methods the calculator accepts. + +### Shipping, for completeness + +`FlatRateShippingCalculator` is the other half of the total, and is tiered rather than flat +despite the name: + +| Method | Charge | +|---|---| +| **Standard** | **free** when subtotal ≥ **$75**; otherwise **$6.99** + **$0.75** per item beyond the first | +| **Express** | **$19.99** + **$1.50** per item beyond the first (no free threshold) | + +`itemCount` is the sum of quantities, not the number of distinct lines — one line of qty 2 +counts as 2, so the surcharge applies. Anything other than `Express` normalizes to +`Standard`, and the result is rounded to 2dp away-from-zero. ## Asynchronous payments (BNPL / redirect) & webhooks diff --git a/docs/handbook/07-testing.md b/docs/handbook/07-testing.md index 8a5a756..477b471 100644 --- a/docs/handbook/07-testing.md +++ b/docs/handbook/07-testing.md @@ -2,10 +2,11 @@ # 7. Testing & the smoke test -Two layers: fast **unit tests** (logic, no I/O) and an **end-to-end smoke test** -(the running API over HTTP). +Three layers: fast **backend unit tests** (logic, no I/O), **frontend unit tests**, and an +**end-to-end smoke test** (the running API over HTTP). All three are the gate: no deployment +runs unless every one of them passes. -## Unit tests +## Backend unit tests `tests/WidgetWorks.UnitTests` (xUnit) run with in-memory fakes and `FakeTimeProvider`, so they’re deterministic and need no database. Coverage includes: @@ -31,6 +32,25 @@ dotnet test CI runs `dotnet build -warnaserror` then `dotnet test` on every code change (see below). +## Frontend unit tests + +`web/**/*.test.ts` (Vitest) cover the logic that isn't worth a browser: + +- **`api/client.test.ts`** — the token-refresh contract. The important case is the + regression test for bug #12: fire several concurrent requests that all get a `401`, and + assert the client issues **exactly one** refresh. Refresh tokens rotate, so a second + concurrent refresh replays a dead token and signs the user out — the test fails loudly if + the single-flight guard is ever removed. +- **`lib/catalog.test.ts`** — catalog filtering/sorting behaviour. + +Run them: + +```bash +cd web && npm test +``` + +`npm run build` (tsc + Vite) runs alongside them in CI, so a type error fails the same gate. + ## End-to-end smoke test `scripts/smoke-test.ps1` drives the **running API** over HTTP and checks real responses. @@ -80,7 +100,31 @@ Sample: | **CodeQL** | code changes (public) | security-extended analysis | | **Web CI** | `web/**` changes | `npm run build` (tsc + Vite) | | **Smoke test** | code changes (docs ignored) | `docker compose up db api` → wait `/health` → run `smoke-test.ps1` | +| **Test suite** | called by both deploys | all three layers at once — backend units, frontend units + build, and the smoke test | +| **Deploy API** | `main`, only for `src/**`, `tests/**`, `Dockerfile.api`, build files | `needs: tests` → publish Release → zip-deploy to App Service | +| **Deploy web** | `main`, only for `web/**` | `needs: tests` → build the SPA → Static Web Apps | **Docs-only changes** (`**.md`, `docs/**`) skip CI / CodeQL / Web CI / Smoke — only the -secret scan runs — so writing documentation never triggers a build. The smoke workflow can -also be run on demand from the Actions tab (`workflow_dispatch`). +secret scan runs — so writing documentation never triggers a build **or a deployment**. The +smoke workflow can also be run on demand from the Actions tab (`workflow_dispatch`). + +### The deployment gate + +`test-suite.yml` is a **reusable** workflow (`on: workflow_call`) with three jobs — backend +units, frontend units, smoke test. Both deploy workflows start with: + +```yaml +jobs: + tests: + uses: ./.github/workflows/test-suite.yml + deploy: + needs: tests +``` + +so a failure in **any** of the three stops the deploy before a single artifact is uploaded. +The web deploy runs the API smoke test too, deliberately: a SPA is useless against a broken +API, so it isn't allowed to ship on frontend tests alone. + +Triggers are **allowlists**, not ignore-lists — the API deploy fires only for paths that can +change the compiled API, the web deploy only for `web/**`. An API change never redeploys the +SPA, a web change never redeploys the API, and a docs change deploys nothing. diff --git a/docs/handbook/08-bugs-and-lessons.md b/docs/handbook/08-bugs-and-lessons.md index e030b6f..e26fa89 100644 --- a/docs/handbook/08-bugs-and-lessons.md +++ b/docs/handbook/08-bugs-and-lessons.md @@ -2,10 +2,14 @@ # 8. Bugs & lessons learned -Real issues hit while building this, how each was found, fixed, and prevented. A recurring -constraint shaped the workflow: the build environment could not install the .NET SDK, so -**code was authored without a local compiler and CI acted as the compiler** — which makes -the discipline below load-bearing rather than optional. +Real issues hit while building this, how each was found, fixed, and prevented. + +Two phases shaped the list. Early on the build environment could not install the .NET SDK, +so **code was authored without a local compiler and CI acted as the compiler** — which makes +the discipline below load-bearing rather than optional. Later, with a local toolchain and a +real deployment, the failures shifted: shells mangling arguments, a platform restarting a +crashing container, an identity provider presenting a subject nobody documented. Rows 1–11 +are from the first phase, 12–32 from the second. ## Bugs @@ -22,6 +26,27 @@ the discipline below load-bearing rather than optional. | 9 | Couldn’t create an “empty” initial commit remotely | pushing via API | The sandbox git proxy blocked pushes; an empty tree is invalid | Create the repo’s first commit locally | Know your tooling’s limits; script the repeatable path | | 10 | Always-on gitleaks failed on a green repo | CI (Secret scan) | Splitting gitleaks into a full-history scan surfaced test JWT keys after an edit dropped the `test-signing-key` allowlist | Restore the dropped allowlist + email rules in `.gitleaks.toml` | Reproduce the exact scan locally before changing scanner scope; a whole-history scan is stricter than a PR-diff one | | 11 | `dotnet run` ignored user-secrets (empty signing key) | running the API on the host | No `launchSettings.json`, so `dotnet run` started in **Production**, where user-secrets aren’t loaded; it also bound `:5000` not `:5080` | Add `Properties/launchSettings.json` pinning Development + `http://localhost:5080` | Commit a run profile so `dotnet run` is deterministic; env vars always load, user-secrets only in Development | +| 12 | Random sign-outs while browsing | reported, then reproduced with a control test (5 concurrent calls → 3× 401) | Every 401 started its **own** refresh. Refresh tokens rotate, so the first call consumed the token and the rest replayed a dead one and were signed out | **Single-flight** the refresh in `api/client.ts`: concurrent callers await one shared in-flight promise | A vitest regression test fires N concurrent 401s and asserts exactly one refresh call | +| 13 | Receipt emails showed raw markup / mojibake | Mailpit | `SmtpEmailSender` set the HTML as both `Body` and an alternate view, and left encoding at the default | Plain text `Body`, HTML as a **single** `AlternateView`, UTF-8 throughout | Read the message in a real mail client (Mailpit), not just "no exception thrown" | +| 14 | No email at all under Docker | nothing arriving in Mailpit | `docker-compose.yml` never passed the SMTP (and payment) keys into the `api` container, so the app fell back to the no-op sender | Pass the credential keys through in compose | Compose env is config too — it drifts from `.env.example` unless checked | +| 15 | A widget named `Widget & Co ` broke the email layout | reviewing the templates | Values were interpolated into HTML unescaped, and money used the current culture | `WebUtility.HtmlEncode` every interpolation; `CultureInfo.InvariantCulture` for money | Treat an email template as untrusted output, exactly like a web page | +| 16 | Container restart-looped on a free tier — burning quota | Azure App Service logs | DbUp **threw** at startup when the database was unreachable, so the host killed and restarted the process forever | `MigrationRunner.TryRun` retries with backoff and returns an outcome; the app boots anyway and `/health` reports **503** | Never let a transient dependency crash startup where the platform bills restarts | +| 17 | Deep links 404'd and Google sign-in was blocked on Static Web Apps | opening `/store` directly on the deployed SPA | No SPA fallback, and no CSP allowance for `accounts.google.com` | `staticwebapp.config.json` with `navigationFallback` + a CSP that allows the Google endpoints and the API origin | Verify a deep link and a third-party script on the **hosted** build, not just the dev server | +| 18 | That config file did nothing | `sed` in the deploy script found no file | It sat in `web/`; Vite only copies static files from `web/public/` | Move it to `web/public/` | Know which files your bundler actually emits | +| 19 | Staff could not find any order | using the admin screen | The page only looked an order up **by GUID**, and nobody has a GUID to hand | Add `GET /admin/orders` and make the list the entry point | A screen that needs an id you can't obtain is unusable, however correct its API | +| 20 | That new list showed `Items: 0` for every order | opening the screen | I "optimized" `GetRecentAsync` to skip loading item rows — but `OrderSummary` derives `itemCount` from them | Load the item rows | Don't drop data a projection depends on; check the projection before trimming the query | +| 21 | Google sign-in rendered an empty slot | the login page | `GoogleButton` returns `null` without a client id, and the id wasn't set for the deployed build | Supply `VITE_GOOGLE_CLIENT_ID` at build time; CSP as in #17 | A silently-null component looks identical to a broken one — prefer a visible fallback | +| 22 | The selected payment method looked unselected | user screenshot, forced dark mode | `.optioncard:hover` and `.optioncard.on` both drew a blue border, so hovering the selected card erased the distinction | Neutral hover; an opaque inset ring for selected | Test selection state under hover **and** forced dark mode | +| 23 | Frontend tests failed to compile | `npm test` | Tests used `.at(-1)`, an ES2022 API, against the project's ES2020 target | A small `last()` helper | Match test code to the project's target; don't move the target for a convenience API | +| 24 | Stylesheet silently dropped a theme override | reviewing the built CSS | Bare custom properties were written directly inside `@media` blocks instead of a selector | Wrap them in `:root{}` | Custom properties need a selector — a media block isn't one | +| 25 | `az webapp config appsettings set` failed: `Jwt__SigningKey was unexpected at this time` | provisioning | The Windows `az` batch shim mis-parses the parentheses in `@Microsoft.KeyVault(...)` | Write the settings to a temp JSON file and pass `--settings @file` | Never pass shell-significant characters as inline CLI args on Windows | +| 26 | Re-running provisioning failed on an existing vault | second run of `Provision.ps1` | `az keyvault create` isn't idempotent | Guard vault, plan and web app with a `show` check first | "Idempotent" has to be proven by running it twice | +| 27 | `RandomNumberGenerator::Fill()` not found | generating the signing key | Windows PowerShell 5.1 is .NET **Framework**; `Fill` is .NET Core+ | `RandomNumberGenerator.Create().GetBytes()` | Target the PowerShell edition the user actually has, not the one you assume | +| 28 | An `az` helper swallowed `-o json` | provisioning output was wrong | A helper parameter named `$Args` collided with PowerShell's automatic `$Args`, so `-o` bound to `-OutVariable` | Drop the param block | `$Args`, `$Input`, `$Error` are reserved — never name a parameter after one | +| 29 | `az role assignment create` built a scope of `C:/Program Files/Git/subscriptions/…` | assigning Key Vault RBAC | Git Bash (MSYS) rewrites leading-`/` arguments into Windows paths | `MSYS_NO_PATHCONV=1`, or run it from PowerShell | Shell-specific argument mangling looks exactly like a broken tool — check the shell first | +| 30 | Azure OIDC login failed with a valid federated credential | deploy workflow | GitHub presented an **immutable** subject (`repo:owner@id/repo@id:environment:production`), not the documented `repo:owner/repo:environment:name` form | Add federated credentials matching the subject actually presented | Read the subject from the failing token/log rather than from the docs | +| 31 | A pinned action didn't exist | deploy workflow, immediately | I pinned `Azure/static-web-apps-deploy` to a SHA I had invented | Verify every pin against the GitHub API | A SHA pin is only safe if the SHA is real — resolve it, don't recall it | +| 32 | Branch protection could never be satisfied | enabling required checks | The rule required a check named `ci.yml`, which no job publishes — and requiring **path-filtered** workflows deadlocks docs-only PRs (they never run, so they never report) | Require only `Secret scan (gitleaks)`, the one check that runs unconditionally | A required check must be one that runs on **every** PR | ## Lessons learned @@ -49,3 +74,27 @@ the discipline below load-bearing rather than optional. - **Config must match the code that binds it, and the run profile must match the docs.** A mismatched example key — or a missing `launchSettings.json` — is a silent misconfiguration; keep them in lockstep and smoke-test the wired providers. +- **Single-flight shared async work.** Anything that *rotates* a credential — a refresh + token, a nonce, a lease — can only be done once at a time. N callers must await one + in-flight promise, not start N races. The bug looked like flaky auth for weeks. +- **Fail soft at startup when the platform bills restarts.** A free tier gives you a quota, + and a crash loop spends it in minutes. Boot degraded, report it on `/health`, and let an + operator see a 503 instead of an invisible restart cycle. +- **Hosting a SPA is its own configuration.** Client-side routes 404 without an explicit + fallback, and a third-party sign-in button dies silently without CSP allowances. Neither + shows up on a dev server — only on the hosted build. +- **Generate deployment config; never transcribe it.** The provisioning script reads back + every id, hostname, and key it needs. Every value a human copies between two consoles is a + future outage. +- **Shell-quoting is a platform detail, not a nuisance.** The same command breaks three + different ways: a Windows batch shim mis-parsing parentheses, MSYS rewriting `/scopes/…` + into `C:/Program Files/…`, and PowerShell binding `-o` to `-OutVariable` because a + parameter was named `$Args`. When a CLI "is broken," suspect the shell first. +- **Verify pins and subjects against the source of truth.** A pinned action SHA that doesn't + exist, and a federated-credential subject that doesn't match what the provider actually + presents, both fail identically to a permissions problem. Resolve the real value — from the + API, or from the failing token — instead of trusting documentation or memory. +- **A required status check must run on every PR.** Gating on a path-filtered workflow + deadlocks any PR that doesn't touch those paths: it never runs, so it never reports. +- **Don't trim data a projection depends on.** Skipping the item rows made the query cheaper + and every order display `0 items`. Read the mapper before optimizing the query. diff --git a/docs/handbook/09-runbook.md b/docs/handbook/09-runbook.md index 03f1589..12eff25 100644 --- a/docs/handbook/09-runbook.md +++ b/docs/handbook/09-runbook.md @@ -42,13 +42,21 @@ hybrid-dev Vite is `:5173`): dotnet user-secrets set "App:BaseUrl" "http://localhost:5173" ``` -### Test with a real inbox UI — Mailpit (optional) +### Test with a real inbox UI — Mailpit -A local SMTP catcher gives you a browser inbox without sending anything externally: +A local SMTP catcher gives you a browser inbox — with the real HTML — without sending +anything externally. **`docker compose` already runs one**, so under Docker you only need to +point the app at it. In `.env`: -```bash -docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit ``` +Email__Provider=Smtp +Email__Host=mailpit +Email__Port=1025 +Email__UseStartTls=false +``` + +Running the API on the host instead (hybrid dev), the same settings go to user-secrets and +the host is `localhost`: ```bash dotnet user-secrets set "Email:Provider" "Smtp" @@ -57,7 +65,8 @@ dotnet user-secrets set "Email:Port" "1025" dotnet user-secrets set "Email:UseStartTls" "false" ``` -Trigger an email and read it at **http://localhost:8025**. +**`UseStartTls` must be `false`** — port 1025 is plain SMTP, and leaving STARTTLS on makes +every send fail. Trigger an email and read it at **http://localhost:8025**. ### Go live — real SMTP diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 0e8bf32..81078ac 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -13,10 +13,10 @@ payments (sync + async/webhook), transactional email, and an order lifecycle. 2. [Architecture](02-architecture.md) — onion/clean layering, request flow, security model, seams. 3. [Setup & run](03-setup-and-run.md) — one-command Docker, hybrid dev, URLs, demo accounts. 4. [Configuration, secrets, email & 2FA](04-configuration-and-2fa.md) — what keys go where/how/why; email + Google setup; how to set up 2FA. -5. [Payments & testing credit cards](05-payments.md) — Mock + Stripe test mode, async/webhooks, testing without charging a card, going live. +5. [Payments, tax & testing credit cards](05-payments.md) — how the total is built (shipping + per-state sales tax, worked examples, the rate table), Mock + Stripe test mode, async/webhooks, testing without charging a card, going live. 6. [Database & schema](06-database.md) — why PostgreSQL, migrations, tables and relationships. 7. [Testing & smoke test](07-testing.md) — unit tests, CI gates, and how to run the end-to-end smoke test. -8. [Bugs & lessons learned](08-bugs-and-lessons.md) — real bugs hit, how found, how fixed, how prevented. +8. [Bugs & lessons learned](08-bugs-and-lessons.md) — 32 real bugs, how each was found, fixed, and prevented — from CI-as-compiler through deployment. 9. [Runbook — testing & going live](09-runbook.md) — step-by-step to test email, payments, and Google locally, and exactly what to change to go live. 10. [Deploying to Azure on free tiers](10-deploy-azure-free.md) — the whole stack for $0: F1 App Service, Static Web Apps, Key Vault with a managed identity, and Postgres on Neon. diff --git a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs index 947cea6..372f362 100644 --- a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs +++ b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs @@ -25,4 +25,8 @@ public interface IOrderRepository Task GetByNumberAndEmailAsync(string orderNumber, string email, CancellationToken ct); Task> GetForUserAsync(Guid userId, CancellationToken ct); + + /// Most recent orders across all customers — the staff view. Capped, not paged: + /// staff want "what came in lately", and an unbounded scan is the wrong default. + Task> GetRecentAsync(int limit, CancellationToken ct); } diff --git a/src/WidgetWorks.Application/DependencyInjection.cs b/src/WidgetWorks.Application/DependencyInjection.cs index 8493342..b11eef3 100644 --- a/src/WidgetWorks.Application/DependencyInjection.cs +++ b/src/WidgetWorks.Application/DependencyInjection.cs @@ -22,6 +22,7 @@ using WidgetWorks.Application.Orders.Admin; using WidgetWorks.Application.Orders.GetMine; using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; using WidgetWorks.Application.Orders.Lookup; using WidgetWorks.Application.Orders.UpdateStatus; using WidgetWorks.Application.Security.SecureAccount; @@ -67,6 +68,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs b/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs new file mode 100644 index 0000000..a76029b --- /dev/null +++ b/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs @@ -0,0 +1,23 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Orders.ListMine; + +namespace WidgetWorks.Application.Orders.ListRecent; + +public sealed record ListRecentOrdersQuery(int Limit); + +/// +/// The staff order list. Without it the admin screen can only look an order up by its GUID, which +/// nobody has to hand — so orders were effectively invisible to Managers and Administrators. +/// +public sealed class ListRecentOrdersHandler(IOrderRepository orders) +{ + private const int DefaultLimit = 50; + private const int MaxLimit = 200; + + public async Task> Handle(ListRecentOrdersQuery query, CancellationToken ct) + { + var limit = query.Limit is < 1 or > MaxLimit ? DefaultLimit : query.Limit; + var list = await orders.GetRecentAsync(limit, ct); + return list.Select(OrderSummary.From).ToList(); + } +} diff --git a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs index cb5425e..55b3620 100644 --- a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs +++ b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs @@ -156,6 +156,31 @@ await db.ExecuteAsync( return order; } + public async Task> GetRecentAsync(int limit, CancellationToken ct) + { + using var db = await factory.OpenAsync(ct); + var list = (await db.QueryAsync( + $"select {OrderColumns} from orders order by created_at desc limit @limit", + new { limit })).ToList(); + if (list.Count == 0) + { + return list; + } + + // The item rows are loaded, not skipped: OrderSummary derives its item count from them, + // so leaving Items empty reported every order as containing nothing. + var ids = list.Select(o => o.Id).ToArray(); + var items = await db.QueryAsync( + $"select {ItemColumns} from order_items where order_id = any(@ids)", new { ids }); + var grouped = items.GroupBy(i => i.OrderId).ToDictionary(g => g.Key, g => g.ToList()); + foreach (var o in list) + { + o.Items = grouped.TryGetValue(o.Id, out var it) ? it : []; + } + + return list; + } + public async Task> GetForUserAsync(Guid userId, CancellationToken ct) { using var db = await factory.OpenAsync(ct); diff --git a/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs b/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs index c7e5574..2d4f022 100644 --- a/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs +++ b/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs @@ -14,6 +14,15 @@ public sealed class SeedOptions public string DemoCustomerEmail { get; set; } = string.Empty; public string DemoCustomerPassword { get; set; } = string.Empty; + + /// + /// The middle role. Without a seeded Manager the demo cannot show what ManageCatalog actually + /// buys you — a Manager may create, edit, restock and hide a widget but not retire one, which + /// is the whole point of the Administrator-only DeleteCatalog policy. + /// + public string DemoManagerEmail { get; set; } = string.Empty; + + public string DemoManagerPassword { get; set; } = string.Empty; } public sealed class DbSeeder(IDbConnectionFactory factory, IPasswordHasher hasher, TimeProvider clock) @@ -31,6 +40,7 @@ public async Task SeedAsync(SeedOptions options, CancellationToken ct) { await UpsertUserAsync(options.DemoAdminEmail, options.DemoAdminPassword, UserRoles.Administrator, isProtected: true, ct); await UpsertUserAsync(options.DemoCustomerEmail, options.DemoCustomerPassword, UserRoles.Customer, isProtected: false, ct); + await UpsertUserAsync(options.DemoManagerEmail, options.DemoManagerPassword, UserRoles.Manager, isProtected: false, ct); await SeedWidgetsAsync(ct); } diff --git a/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs b/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs index 6842541..3d5c1de 100644 --- a/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs +++ b/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs @@ -1,65 +1,73 @@ -using System.Security.Claims; -using WidgetWorks.Application.Orders.Admin; -using WidgetWorks.Application.Orders.GetMine; -using WidgetWorks.Application.Orders.ListMine; -using WidgetWorks.Application.Orders.Lookup; -using WidgetWorks.Application.Orders.UpdateStatus; -using WidgetWorks.WebApi.Authorization; - -namespace WidgetWorks.WebApi.Orders; - -public static class OrderEndpoints -{ - public static void MapOrderEndpoints(this IEndpointRouteBuilder routes) - { - // Guest order tracking by order number + email (anonymous). - routes.MapGet("/orders/lookup", async (string number, string email, GuestOrderLookupHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new GuestOrderLookupQuery(number, email), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - var mine = routes.MapGroup("/orders").RequireAuthorization(); - - mine.MapGet("", async (ClaimsPrincipal principal, ListMyOrdersHandler handler, CancellationToken ct) => - { - if (UserId(principal) is not { } userId) - { - return Results.Unauthorized(); - } - - return Results.Ok(await handler.Handle(new ListMyOrdersQuery(userId), ct)); - }); - - mine.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, GetMyOrderHandler handler, CancellationToken ct) => - { - if (UserId(principal) is not { } userId) - { - return Results.Unauthorized(); - } - - var result = await handler.Handle(new GetMyOrderQuery(userId, id), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - // Admin/manager order management (ManageCatalog covers widgets, inventory, and orders). - var admin = routes.MapGroup("/admin/orders").RequireAuthorization(Policies.ManageCatalog); - - admin.MapGet("/{id:guid}", async (Guid id, GetOrderByIdHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new GetOrderByIdQuery(id), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - admin.MapPost("/{id:guid}/status", async (Guid id, UpdateStatusRequest body, UpdateOrderStatusHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new UpdateOrderStatusCommand(id, body.Status, body.TrackingNumber), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error }); - }); - - static Guid? UserId(ClaimsPrincipal principal) - => Guid.TryParse(principal.FindFirst("sub")?.Value, out var id) ? id : null; - } - - public sealed record UpdateStatusRequest(string Status, string? TrackingNumber); -} +using System.Security.Claims; +using WidgetWorks.Application.Orders.Admin; +using WidgetWorks.Application.Orders.GetMine; +using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; +using WidgetWorks.Application.Orders.Lookup; +using WidgetWorks.Application.Orders.UpdateStatus; +using WidgetWorks.WebApi.Authorization; + +namespace WidgetWorks.WebApi.Orders; + +public static class OrderEndpoints +{ + public static void MapOrderEndpoints(this IEndpointRouteBuilder routes) + { + // Guest order tracking by order number + email (anonymous). + routes.MapGet("/orders/lookup", async (string number, string email, GuestOrderLookupHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new GuestOrderLookupQuery(number, email), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + var mine = routes.MapGroup("/orders").RequireAuthorization(); + + mine.MapGet("", async (ClaimsPrincipal principal, ListMyOrdersHandler handler, CancellationToken ct) => + { + if (UserId(principal) is not { } userId) + { + return Results.Unauthorized(); + } + + return Results.Ok(await handler.Handle(new ListMyOrdersQuery(userId), ct)); + }); + + mine.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, GetMyOrderHandler handler, CancellationToken ct) => + { + if (UserId(principal) is not { } userId) + { + return Results.Unauthorized(); + } + + var result = await handler.Handle(new GetMyOrderQuery(userId, id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + // Admin/manager order management (ManageCatalog covers widgets, inventory, and orders). + var admin = routes.MapGroup("/admin/orders").RequireAuthorization(Policies.ManageCatalog); + + // Staff order list. Summary rows only — open one to load its items. + admin.MapGet("/", async (int? limit, ListRecentOrdersHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new ListRecentOrdersQuery(limit ?? 50), ct); + return Results.Ok(result); + }); + + admin.MapGet("/{id:guid}", async (Guid id, GetOrderByIdHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new GetOrderByIdQuery(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + admin.MapPost("/{id:guid}/status", async (Guid id, UpdateStatusRequest body, UpdateOrderStatusHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new UpdateOrderStatusCommand(id, body.Status, body.TrackingNumber), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error }); + }); + + static Guid? UserId(ClaimsPrincipal principal) + => Guid.TryParse(principal.FindFirst("sub")?.Value, out var id) ? id : null; + } + + public sealed record UpdateStatusRequest(string Status, string? TrackingNumber); +} diff --git a/src/WidgetWorks.WebApi/appsettings.json b/src/WidgetWorks.WebApi/appsettings.json index d46f37f..48a66c9 100644 --- a/src/WidgetWorks.WebApi/appsettings.json +++ b/src/WidgetWorks.WebApi/appsettings.json @@ -15,6 +15,7 @@ }, "Seed": { "DemoAdminEmail": "admin@widgetworks.demo", - "DemoCustomerEmail": "demo@widgetworks.demo" + "DemoCustomerEmail": "demo@widgetworks.demo", + "DemoManagerEmail": "manager@widgetworks.demo" } } diff --git a/tests/WidgetWorks.UnitTests/Fakes.cs b/tests/WidgetWorks.UnitTests/Fakes.cs index 52b0ced..e3c002d 100644 --- a/tests/WidgetWorks.UnitTests/Fakes.cs +++ b/tests/WidgetWorks.UnitTests/Fakes.cs @@ -245,6 +245,9 @@ public Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumbe public Task> GetForUserAsync(Guid userId, CancellationToken ct) => Task.FromResult>(Orders.Where(o => o.UserId == userId).OrderByDescending(o => o.CreatedAt).ToList()); + + public Task> GetRecentAsync(int limit, CancellationToken ct) + => Task.FromResult>(Orders.OrderByDescending(o => o.CreatedAt).Take(limit).ToList()); } public sealed class InMemoryPasswordResetTokenRepository : IPasswordResetTokenRepository diff --git a/web/src/App.tsx b/web/src/App.tsx index 1839483..5bebbac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { CartProvider } from './cart/CartContext' import { Layout } from './components/Layout' import { ProtectedRoute } from './components/ProtectedRoute' import { CatalogPage } from './pages/CatalogPage' +import { DemoGuidePage } from './pages/DemoGuidePage' import { ProductPage } from './pages/ProductPage' import { CartPage } from './pages/CartPage' import { CheckoutPage } from './pages/CheckoutPage' @@ -24,7 +25,10 @@ export default function App() { }> - } /> + {/* The guide is the landing page: a working store is confusing without knowing it is + a demo, that nothing can charge you, and which account to use. */} + } /> + } /> } /> } /> } /> diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 52fdf0e..bdffc7f 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -13,7 +13,7 @@ export function Layout() { const q = params.get('q') ?? '' const cat = params.get('cat') ?? '' - const onCatalog = location.pathname === '/' + const onCatalog = location.pathname === '/store' // The input is local so typing doesn't re-run the catalog query on every // keystroke; the URL (and the fetch) updates when the search is submitted. @@ -25,7 +25,7 @@ export function Layout() { if (nextQ.trim()) sp.set('q', nextQ.trim()) if (nextCat) sp.set('cat', nextCat) const qs = sp.toString() - navigate(qs ? `/?${qs}` : '/') + navigate(qs ? `/store?${qs}` : '/store') } return ( @@ -35,7 +35,8 @@ export function Layout() {
- Free standard shipping on orders over ${FREE_SHIPPING_THRESHOLD} · Demo store + Free standard shipping on orders over ${FREE_SHIPPING_THRESHOLD} ·{' '} + Demo store — read the guide @@ -51,7 +52,7 @@ export function Layout() { {/* Header --------------------------------------------------------- */}
- + WidgetWorks @@ -59,7 +60,7 @@ export function Layout() { - + Shipping to @@ -136,13 +137,13 @@ export function Layout() { {CATEGORIES.map((c) => ( {c.slug ? c.label : 'All widgets'} ))} - Today's deals + Today's deals
@@ -177,10 +178,10 @@ export function Layout() {

Shop

- All widgets - Kits - Mega widgets - Mini widgets + All widgets + Kits + Mega widgets + Mini widgets
@@ -193,13 +194,14 @@ export function Layout() {

Customer service

- Shipping rates - Returns policy + Shipping rates + Returns policy Password help

About the project

+ Demo guide
Source on GitHub Engineering handbook Security policy diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 5083892..3cc7f19 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -5,6 +5,6 @@ import type { ReactNode } from 'react' export function ProtectedRoute({ children, staff }: { children: ReactNode; staff?: boolean }) { const { isAuthenticated, isStaff } = useAuth() if (!isAuthenticated) return - if (staff && !isStaff) return + if (staff && !isStaff) return return <>{children} } diff --git a/web/src/pages/CartPage.tsx b/web/src/pages/CartPage.tsx index 99b9939..5a79d00 100644 --- a/web/src/pages/CartPage.tsx +++ b/web/src/pages/CartPage.tsx @@ -30,7 +30,7 @@ export function CartPage() {

Your cart is empty

Once you add widgets they will show up here, ready for checkout.

- Start shopping + Start shopping
) } @@ -40,7 +40,7 @@ export function CartPage() { return ( <> @@ -144,7 +144,7 @@ export function CartPage() { - Continue shopping + Continue shopping
diff --git a/web/src/pages/CatalogPage.tsx b/web/src/pages/CatalogPage.tsx index ba65e3f..0216b59 100644 --- a/web/src/pages/CatalogPage.tsx +++ b/web/src/pages/CatalogPage.tsx @@ -91,18 +91,18 @@ export function CatalogPage() { From the everyday Standard to the heavy-duty Mega — quality widgets, fast shipping and honest prices, backed by a 30-day return window.

- + Shop the kits - + Deals

Save on Widget Pro kits

Shop kits - + New

Weatherproof Mega widgets

See what's new @@ -111,7 +111,7 @@ export function CatalogPage() {