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() {
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() {