From 4eca86b4497e32f60c78479d9d0d2c6dd398c347 Mon Sep 17 00:00:00 2001
From: bgard68 <30295154+bgard68@users.noreply.github.com>
Date: Wed, 19 Aug 2026 06:42:53 -0500
Subject: [PATCH 1/2] ci: path-scoped deployments gated on the full test suite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds deploy-api.yml and deploy-web.yml, each triggered by an allowlist of paths
rather than paths-ignore, so scope is explicit: src/tests/solution files deploy
the API, web/** deploys the SPA, and a docs or markdown change matches neither
and deploys nothing.
Both call a new reusable test-suite.yml and declare needs: tests, so nothing
reaches Azure unless backend units, frontend units and the end-to-end smoke test
all pass for that exact commit. The suite lives in one file so the two deploys
cannot drift on what 'tests passed' means. The web deploy is gated on the smoke
test too — a SPA is useless against a broken API.
The API job publishes Release to a directory outside the workspace and audits it
before deploying: any .cs, .csproj, .sln, .env, compose file, or
.git/node_modules/src/web/tests/docs directory fails the run, and the app dll
and appsettings.json must be present. It then polls /health and stops the web
app on a 503, because a crash-looping F1 app silently consumes the 60
CPU-minutes/day allowance.
Azure auth is OIDC — id-token: write and no stored credential. Every action is
pinned to a full commit SHA, each verified to exist against the GitHub API;
one I had written from memory was wrong.
Co-Authored-By: Claude Opus 5
---
.github/workflows/deploy-api.yml | 112 +++++++++++++++++++++++++++++++
.github/workflows/deploy-web.yml | 74 ++++++++++++++++++++
.github/workflows/test-suite.yml | 76 +++++++++++++++++++++
3 files changed, 262 insertions(+)
create mode 100644 .github/workflows/deploy-api.yml
create mode 100644 .github/workflows/deploy-web.yml
create mode 100644 .github/workflows/test-suite.yml
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
From 11fcb99262430603f1780a10641d2d633f3b8df9 Mon Sep 17 00:00:00 2001
From: bgard68 <30295154+bgard68@users.noreply.github.com>
Date: Wed, 19 Aug 2026 07:32:12 -0500
Subject: [PATCH 2/2] feat(web): landing guide, a third seeded role, and a
staff order list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Everyone now lands on a guide at / and the store moves to /store. A working
storefront is confusing without knowing it is a demo, that nothing can charge
you, and which account to use, so the guide answers those first and links into
the store from several places.
The guide leads with the payments question because it is the one that decides
whether a visitor trusts the site: checkout runs a mock gateway, no card details
are collected, no processor is contacted, and a Stripe configuration is
restricted to test mode.
Three roles existed but only two were seeded, so nothing demonstrated what
ManageCatalog actually buys you. DbSeeder now creates a Manager as well, and the
guide spells out for each role what it can and cannot do — the interesting line
being that a Manager may restock and hide a widget but not retire one.
Staff could not find an order at all: admin had only lookup-by-GUID, and nobody
has a GUID to hand. Adds GET /admin/orders with a recent-orders list, and rebuilds
the admin page around selecting from it. The item rows are loaded rather than
skipped — OrderSummary derives its item count from them, and an initial
optimisation reported every order as empty.
Receipts work on the free tier without a mail server: the hosted demo writes
email to the log, so the order detail page carries the same line items, totals,
payment and tracking, and now prints as a receipt. The print stylesheet drops the
chrome and collapses the columns.
Co-Authored-By: Claude Opus 5
---
.env.example | 2 +
docker-compose.yml | 1 +
.../Abstractions/IOrderRepository.cs | 4 +
.../DependencyInjection.cs | 3 +
.../ListRecent/ListRecentOrdersHandler.cs | 23 ++
.../Persistence/OrderRepository.cs | 25 +++
.../Seeding/DbSeeder.cs | 10 +
.../Orders/OrderEndpoints.cs | 138 ++++++------
src/WidgetWorks.WebApi/appsettings.json | 3 +-
tests/WidgetWorks.UnitTests/Fakes.cs | 3 +
web/src/App.tsx | 6 +-
web/src/components/Layout.tsx | 28 +--
web/src/components/ProtectedRoute.tsx | 2 +-
web/src/pages/CartPage.tsx | 6 +-
web/src/pages/CatalogPage.tsx | 12 +-
web/src/pages/CheckoutPage.tsx | 2 +-
web/src/pages/DemoGuidePage.tsx | 201 ++++++++++++++++++
web/src/pages/LoginPage.tsx | 2 +-
web/src/pages/OrderConfirmationPage.tsx | 6 +-
web/src/pages/OrderDetailPage.tsx | 11 +-
web/src/pages/OrdersPage.tsx | 6 +-
web/src/pages/ProductPage.tsx | 8 +-
web/src/pages/RegisterPage.tsx | 2 +-
web/src/pages/admin/AdminOrderPage.tsx | 154 +++++++++-----
web/src/styles.css | 67 +++++-
25 files changed, 568 insertions(+), 157 deletions(-)
create mode 100644 src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs
create mode 100644 web/src/pages/DemoGuidePage.tsx
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/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/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() {