Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
112 changes: 112 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .github/workflows/test-suite.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ public interface IOrderRepository
Task<Order?> GetByNumberAndEmailAsync(string orderNumber, string email, CancellationToken ct);

Task<IReadOnlyList<Order>> GetForUserAsync(Guid userId, CancellationToken ct);

/// <summary>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.</summary>
Task<IReadOnlyList<Order>> GetRecentAsync(int limit, CancellationToken ct);
}
3 changes: 3 additions & 0 deletions src/WidgetWorks.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,6 +68,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services
services.AddScoped<ConfirmPaymentHandler>();
services.AddScoped<GuestOrderLookupHandler>();
services.AddScoped<ListMyOrdersHandler>();

services.AddScoped<ListRecentOrdersHandler>();
services.AddScoped<GetMyOrderHandler>();
services.AddScoped<GetOrderByIdHandler>();
services.AddScoped<UpdateOrderStatusHandler>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using WidgetWorks.Application.Abstractions;
using WidgetWorks.Application.Orders.ListMine;

namespace WidgetWorks.Application.Orders.ListRecent;

public sealed record ListRecentOrdersQuery(int Limit);

/// <summary>
/// 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.
/// </summary>
public sealed class ListRecentOrdersHandler(IOrderRepository orders)
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;

public async Task<IReadOnlyList<OrderSummary>> 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();
}
}
25 changes: 25 additions & 0 deletions src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,31 @@ await db.ExecuteAsync(
return order;
}

public async Task<IReadOnlyList<Order>> GetRecentAsync(int limit, CancellationToken ct)
{
using var db = await factory.OpenAsync(ct);
var list = (await db.QueryAsync<Order>(
$"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<OrderItem>(
$"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<IReadOnlyList<Order>> GetForUserAsync(Guid userId, CancellationToken ct)
{
using var db = await factory.OpenAsync(ct);
Expand Down
10 changes: 10 additions & 0 deletions src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ public sealed class SeedOptions
public string DemoCustomerEmail { get; set; } = string.Empty;

public string DemoCustomerPassword { get; set; } = string.Empty;

/// <summary>
/// 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.
/// </summary>
public string DemoManagerEmail { get; set; } = string.Empty;

public string DemoManagerPassword { get; set; } = string.Empty;
}

public sealed class DbSeeder(IDbConnectionFactory factory, IPasswordHasher hasher, TimeProvider clock)
Expand All @@ -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);
}

Expand Down
Loading
Loading