diff --git a/.github/workflows/orderservice-deploy.yml b/.github/workflows/orderservice-deploy.yml new file mode 100644 index 0000000..c87bc50 --- /dev/null +++ b/.github/workflows/orderservice-deploy.yml @@ -0,0 +1,84 @@ +name: Build and Deploy Order Service + +# Builds the Order Service image and rolls it out to the Container App. +# Infrastructure (the app itself, its env vars/secrets) is owned by the +# separate orderservice-iac.yml pipeline — this workflow only ships the image. +# The Container App must already exist (provisioned by the IaC pipeline). + +on: + push: + branches: [main] + paths: + - "OrderService/**" + - ".github/workflows/orderservice-deploy.yml" + pull_request: + branches: [main] + paths: + - "OrderService/**" + - ".github/workflows/orderservice-deploy.yml" + workflow_dispatch: + +# Required for OIDC federated identity — no client secrets stored +permissions: + id-token: write + contents: read + +env: + RESOURCE_GROUP: ewu-deliverybotsystem-rg + ACR_NAME: DeliverybotCR + ACR_LOGIN_SERVER: deliverybotcr.azurecr.io + CONTAINER_APP_NAME: deliverybot-order-service + IMAGE_NAME: orderservice + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + # 1. Check out the code + - name: Checkout repository + uses: actions/checkout@v4 + + # 2. Run tests — pipeline fails here if any test fails + - name: Run tests + run: dotnet test OrderService/OrderService.Tests/OrderService.Tests.csproj --configuration Release + + # 3. Log into Azure using OIDC (no passwords — GitHub proves its identity via token) + - name: Azure Login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # 4. Build and push the Docker image to the shared ACR + - name: Log in to Azure Container Registry + run: az acr login --name "$ACR_NAME" + + - name: Build and push Docker image + run: | + IMAGE_TAG="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${{ github.sha }}" + echo "Building: $IMAGE_TAG" + docker build -t "$IMAGE_TAG" -f OrderService/OrderService/Dockerfile OrderService + docker push "$IMAGE_TAG" + echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV" + + # 5. Roll out the new image. Env vars/secrets are owned by Terraform, so + # this only updates the running image tag. + - name: Update Container App image + run: | + az containerapp update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --image "$IMAGE_TAG" + + # 6. Print the live URL + - name: Print deployment URL + run: | + FQDN=$(az containerapp show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query properties.configuration.ingress.fqdn -o tsv) + echo "========================================" + echo " Order Service live at: https://${FQDN}" + echo " Place Order: POST https://${FQDN}/api/orders" + echo "========================================" diff --git a/.github/workflows/orderservice-iac.yml b/.github/workflows/orderservice-iac.yml new file mode 100644 index 0000000..61096ad --- /dev/null +++ b/.github/workflows/orderservice-iac.yml @@ -0,0 +1,76 @@ +name: Order Service - Infrastructure + +# Provisions the Order Service Container App with Terraform. Runs `plan` on PRs +# (for review) and `apply` only on merge to main. Auth uses the OIDC federated +# identity Phil configured — no client secrets stored. + +on: + push: + branches: [main] + paths: + - "Iac/order-service/**" + - ".github/workflows/orderservice-iac.yml" + pull_request: + branches: [main] + paths: + - "Iac/order-service/**" + - ".github/workflows/orderservice-iac.yml" + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + TFSTATE_STORAGE_ACCOUNT: dbstfstate01 + TFSTATE_CONTAINER: tfstate + +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./Iac/order-service + env: + ARM_USE_OIDC: "true" + ARM_USE_AZUREAD: "true" + ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + TF_VAR_sql_connection_string: "Server=tcp:jacob-orderservice-sql2.database.windows.net,1433;Initial Catalog=OrderServiceDb;Authentication=Active Directory Managed Identity;" + TF_VAR_eventhub_connection_string: ${{ secrets.AZURE_EVENTHUB_CONNECTION_STRING }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Azure Login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # Idempotent — safe to run every time. + - name: Ensure TF state container exists + run: | + az storage container create \ + --name "$TFSTATE_CONTAINER" \ + --account-name "$TFSTATE_STORAGE_ACCOUNT" \ + --auth-mode login \ + --only-show-errors + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.9.5" + + - name: Terraform Init + run: terraform init -input=false + + - name: Terraform Plan + run: terraform plan -input=false -out=tfplan + + # Apply only on merge to main — PRs stop at plan for review. + - name: Terraform Apply + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: terraform apply -input=false tfplan diff --git a/Iac/order-service/README.md b/Iac/order-service/README.md new file mode 100644 index 0000000..f6ed65b --- /dev/null +++ b/Iac/order-service/README.md @@ -0,0 +1,86 @@ +# Order Service — Infrastructure (Terraform) + +Provisions the **Order Service** Azure Container App (`deliverybot-order-service`). + +It reuses the team's shared infrastructure (resource group, Container App +Environment, and ACR) via `data` sources, and only owns the Order Service app +itself. The app is created through the reusable [`container-app`](./modules/container-app) +module. + +## Layout + +``` +order-service/ +├── providers.tf # terraform + azurerm + remote state (dbstfstate01) +├── main.tf # data sources for shared infra + module call +├── variables.tf +├── outputs.tf +└── modules/ + └── container-app/ # reusable Azure Container App module +``` + +## What it creates + +- `azurerm_container_app.deliverybot-order-service` with: + - a **system-assigned managed identity** + - **ACR pull** via the `acr-password` secret + `registry` block (admin creds) + - external **ingress** on port 8080 + - env vars: `ASPNETCORE_ENVIRONMENT`, `BotNetApi__BaseUrl` + - secret-backed env vars: `ConnectionStrings__DefaultConnection`, + `EventHub__ConnectionString` + +The **image tag is owned by the CD pipeline**, not Terraform — the module sets +an initial `:latest` image and `ignore_changes` on it so `terraform apply` +doesn't revert the running revision the pipeline deployed. + +## Required inputs (sensitive — supplied by the pipeline, never committed) + +| Variable | Source | +|---|---| +| `sql_connection_string` | built from the SQL server/db + Managed Identity auth | +| `eventhub_connection_string` | `AZURE_EVENTHUB_CONNECTION_STRING` GitHub secret | + +Pass them as `TF_VAR_sql_connection_string` / `TF_VAR_eventhub_connection_string`. + +## Usage + +```bash +cd Iac/order-service +terraform init +terraform plan +terraform apply +``` + +Auth is via OIDC (`azure/login@v2`) in CI; locally, `az login` works with +`use_oidc` disabled or `ARM_*` env vars set. + +## Importing the existing app + +The `deliverybot-order-service` Container App was originally created by hand. +Before the first `apply`, import it so Terraform adopts it instead of trying to +create a duplicate: + +```bash +terraform import \ + module.order_service_app.azurerm_container_app.this \ + /subscriptions//resourceGroups/ewu-deliverybotsystem-rg/providers/Microsoft.App/containerApps/deliverybot-order-service +``` + +Then run `terraform plan` and reconcile any diff (e.g. tags) before applying. + +## Open decision — SQL server + +The running app's connection string points at `jacob-orderservice-sql2`, a +server created manually and **not** in any Terraform. The shared root Iac +defines `OrderServiceDb` on `deliverybotsystem-sql` instead. Decide whether to: +- consolidate onto the shared `deliverybotsystem-sql`, or +- add `jacob-orderservice-sql2` to Terraform. + +Either way, the chosen server's connection string is passed via +`sql_connection_string`; this stack does not yet manage the database itself. + +## Follow-up + +The deploy workflow currently sets env vars with `az containerapp update +--set-env-vars`. Once Terraform owns the env vars, the workflow should be +trimmed to **only update the image tag**, to avoid drift between the two. diff --git a/Iac/order-service/imports.tf b/Iac/order-service/imports.tf new file mode 100644 index 0000000..0b15cb4 --- /dev/null +++ b/Iac/order-service/imports.tf @@ -0,0 +1,14 @@ +# One-time adoption of the pre-existing Container App into Terraform state. +# +# The deliverybot-order-service app was originally created by hand, so the +# first `terraform apply` must IMPORT it instead of trying to create a +# duplicate (azurerm refuses to create over an existing resource). This import +# block lets CI's service principal do that automatically on the first apply — +# no manual out-of-band `terraform import` needed. +# +# SAFE TO DELETE after the first successful apply has run in CI (the resource +# will already be in remote state; the block then becomes a no-op). +import { + to = module.order_service_app.azurerm_container_app.this + id = "${data.azurerm_resource_group.rg.id}/providers/Microsoft.App/containerApps/${var.container_app_name}" +} diff --git a/Iac/order-service/main.tf b/Iac/order-service/main.tf new file mode 100644 index 0000000..b9eeafe --- /dev/null +++ b/Iac/order-service/main.tf @@ -0,0 +1,55 @@ +# Order Service infrastructure. +# +# Reuses the team's shared resource group, Container App Environment, and ACR +# (all created by the root Iac). This stack only owns the Order Service +# Container App itself, provisioned through the reusable container-app module. + +data "azurerm_resource_group" "rg" { + name = var.resource_group_name +} + +data "azurerm_container_app_environment" "env" { + name = var.container_app_environment_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +data "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = data.azurerm_resource_group.rg.name +} + +module "order_service_app" { + source = "./modules/container-app" + + name = var.container_app_name + resource_group_name = data.azurerm_resource_group.rg.name + container_app_environment_id = data.azurerm_container_app_environment.env.id + + # Pull from the shared ACR using admin credentials (same pattern as the + # BotNet API and Robot Simulator apps). + acr_login_server = data.azurerm_container_registry.acr.login_server + acr_username = data.azurerm_container_registry.acr.admin_username + acr_password = data.azurerm_container_registry.acr.admin_password + + container_name = "orderservice" + image = "${data.azurerm_container_registry.acr.login_server}/${var.image_name}:latest" + target_port = 8080 + + # Secrets are referenced by env vars below. + secrets = { + "sql-connection-string" = var.sql_connection_string + "eventhub-connection-string" = var.eventhub_connection_string + } + + env_vars = { + "ASPNETCORE_ENVIRONMENT" = "Production" + "BotNetApi__BaseUrl" = var.botnet_api_url + } + + secret_env_vars = { + "ConnectionStrings__DefaultConnection" = "sql-connection-string" + "EventHub__ConnectionString" = "eventhub-connection-string" + } + + tags = var.tags +} diff --git a/Iac/order-service/modules/container-app/main.tf b/Iac/order-service/modules/container-app/main.tf new file mode 100644 index 0000000..7997b5a --- /dev/null +++ b/Iac/order-service/modules/container-app/main.tf @@ -0,0 +1,89 @@ +# Reusable Azure Container App module. +# +# Encapsulates the team's standard Container App shape: a system-assigned +# identity, ACR pull via an admin-password secret, external ingress, and a +# single container with configurable plain + secret-backed env vars. + +resource "azurerm_container_app" "this" { + name = var.name + resource_group_name = var.resource_group_name + container_app_environment_id = var.container_app_environment_id + revision_mode = "Single" + tags = var.tags + + identity { + type = "SystemAssigned" + } + + # ACR pull credential (admin user), stored as a secret. + secret { + name = "acr-password" + value = var.acr_password + } + + # Caller-supplied secrets (e.g. SQL / Event Hub connection strings). + # Iterate the (non-sensitive) secret names and look up the sensitive values, + # so the sensitive map isn't used directly as a for_each argument — Terraform + # rejects that. + dynamic "secret" { + for_each = nonsensitive(toset(keys(var.secrets))) + content { + name = secret.value + value = var.secrets[secret.value] + } + } + + registry { + server = var.acr_login_server + username = var.acr_username + password_secret_name = "acr-password" + } + + ingress { + external_enabled = true + target_port = var.target_port + + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + template { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + + container { + name = var.container_name + image = var.image + cpu = var.cpu + memory = var.memory + + # Plain environment variables. + dynamic "env" { + for_each = var.env_vars + content { + name = env.key + value = env.value + } + } + + # Environment variables backed by a secret. + dynamic "env" { + for_each = var.secret_env_vars + content { + name = env.key + secret_name = env.value + } + } + } + } + + lifecycle { + ignore_changes = [ + # The CD pipeline deploys new image tags per commit; let it own the + # running image instead of reverting to the value above on every apply. + template[0].container[0].image, + ] + } +} diff --git a/Iac/order-service/modules/container-app/outputs.tf b/Iac/order-service/modules/container-app/outputs.tf new file mode 100644 index 0000000..40d5dfd --- /dev/null +++ b/Iac/order-service/modules/container-app/outputs.tf @@ -0,0 +1,19 @@ +output "name" { + description = "Name of the Container App." + value = azurerm_container_app.this.name +} + +output "fqdn" { + description = "Ingress FQDN of the Container App." + value = azurerm_container_app.this.ingress[0].fqdn +} + +output "url" { + description = "Public HTTPS URL of the Container App." + value = "https://${azurerm_container_app.this.ingress[0].fqdn}" +} + +output "identity_principal_id" { + description = "Principal ID of the system-assigned managed identity." + value = azurerm_container_app.this.identity[0].principal_id +} diff --git a/Iac/order-service/modules/container-app/variables.tf b/Iac/order-service/modules/container-app/variables.tf new file mode 100644 index 0000000..7753654 --- /dev/null +++ b/Iac/order-service/modules/container-app/variables.tf @@ -0,0 +1,95 @@ +variable "name" { + description = "Name of the Container App." + type = string +} + +variable "resource_group_name" { + description = "Resource group the Container App lives in." + type = string +} + +variable "container_app_environment_id" { + description = "ID of the Container App Environment to deploy into." + type = string +} + +variable "acr_login_server" { + description = "Login server of the ACR images are pulled from (e.g. deliverybotcr.azurecr.io)." + type = string +} + +variable "acr_username" { + description = "ACR admin username used to authenticate image pulls." + type = string +} + +variable "acr_password" { + description = "ACR admin password. Stored as a Container App secret named 'acr-password'." + type = string + sensitive = true +} + +variable "container_name" { + description = "Name of the container inside the app." + type = string +} + +variable "image" { + description = "Initial image reference. The image tag is owned by the CD pipeline after creation (see lifecycle.ignore_changes)." + type = string +} + +variable "target_port" { + description = "Container port that ingress routes to." + type = number + default = 8080 +} + +variable "cpu" { + description = "vCPU allocated to the container." + type = number + default = 0.5 +} + +variable "memory" { + description = "Memory allocated to the container." + type = string + default = "1Gi" +} + +variable "min_replicas" { + description = "Minimum number of replicas (0 allows scale-to-zero)." + type = number + default = 0 +} + +variable "max_replicas" { + description = "Maximum number of replicas." + type = number + default = 3 +} + +variable "secrets" { + description = "Map of Container App secret name => secret value. Reference these from secret_env_vars." + type = map(string) + default = {} + sensitive = true +} + +variable "env_vars" { + description = "Map of plain environment variable name => value." + type = map(string) + default = {} +} + +variable "secret_env_vars" { + description = "Map of environment variable name => secret name (the secret must exist in `secrets`)." + type = map(string) + default = {} +} + +variable "tags" { + description = "Tags applied to the Container App." + type = map(string) + default = {} +} diff --git a/Iac/order-service/outputs.tf b/Iac/order-service/outputs.tf new file mode 100644 index 0000000..5f88eb5 --- /dev/null +++ b/Iac/order-service/outputs.tf @@ -0,0 +1,14 @@ +output "container_app_name" { + description = "Name of the provisioned Order Service Container App." + value = module.order_service_app.name +} + +output "order_service_url" { + description = "Public HTTPS URL of the Order Service." + value = module.order_service_app.url +} + +output "managed_identity_principal_id" { + description = "Principal ID of the app's system-assigned identity — grant this AcrPull and a SQL user." + value = module.order_service_app.identity_principal_id +} diff --git a/Iac/order-service/providers.tf b/Iac/order-service/providers.tf new file mode 100644 index 0000000..81fe52e --- /dev/null +++ b/Iac/order-service/providers.tf @@ -0,0 +1,35 @@ +# Provider + state backend for the Order Service Container App. +# +# Auth: provided by the GitHub Actions workflow via `azure/login@v2` (OIDC) +# and the ARM_USE_OIDC / ARM_USE_AZUREAD env vars — no secrets stored here. +# State: lives in the team's pre-existing storage account `dbstfstate01` +# under a unique key so it doesn't collide with the other features' state. + +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + backend "azurerm" { + resource_group_name = "ewu-deliverybotsystem-rg" + storage_account_name = "dbstfstate01" + container_name = "tfstate" + key = "order-service.tfstate" + use_oidc = true + use_azuread_auth = true + } +} + +provider "azurerm" { + features {} + use_oidc = true + + # The CI service principal is scoped to the resource group and can't register + # subscription-level resource providers. They're already registered, so skip. + resource_provider_registrations = "none" +} diff --git a/Iac/order-service/variables.tf b/Iac/order-service/variables.tf new file mode 100644 index 0000000..3e6f765 --- /dev/null +++ b/Iac/order-service/variables.tf @@ -0,0 +1,58 @@ +variable "resource_group_name" { + description = "Resource group that hosts the team's DeliveryBot resources." + type = string + default = "ewu-deliverybotsystem-rg" +} + +variable "container_app_environment_name" { + description = "Existing shared Container App Environment (created by the root Iac)." + type = string + default = "managedEnvironment-ewudeliverybots-aa2f" +} + +variable "acr_name" { + description = "Existing shared Azure Container Registry the image is pulled from." + type = string + default = "DeliverybotCR" +} + +variable "container_app_name" { + description = "Name of the Order Service Container App." + type = string + default = "deliverybot-order-service" +} + +variable "image_name" { + description = "Repository name of the Order Service image in ACR (tag is managed by the CD pipeline)." + type = string + default = "orderservice" +} + +variable "botnet_api_url" { + description = "Base URL of the BotNet API the Order Service calls to select a bot." + type = string + default = "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" +} + +variable "sql_connection_string" { + description = "Connection string for OrderServiceDb. Uses Managed Identity auth — passed in from the CD pipeline, never committed." + type = string + sensitive = true +} + +variable "eventhub_connection_string" { + description = "Connection string for the robot-input Event Hub — passed in from the CD pipeline, never committed." + type = string + sensitive = true +} + +variable "tags" { + description = "Common tags applied to Order Service resources." + type = map(string) + default = { + project = "DeliveryBot" + component = "order-service" + owner = "npcjake" + issue = "#43" + } +} diff --git a/OrderService/OrderService.Tests/.gitignore b/OrderService/OrderService.Tests/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/OrderService/OrderService.Tests/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/OrderService/OrderService.Tests/OrderService.Tests.csproj b/OrderService/OrderService.Tests/OrderService.Tests.csproj new file mode 100644 index 0000000..565854f --- /dev/null +++ b/OrderService/OrderService.Tests/OrderService.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/OrderService/OrderService.Tests/OrderServiceTests.cs b/OrderService/OrderService.Tests/OrderServiceTests.cs new file mode 100644 index 0000000..d0e6637 --- /dev/null +++ b/OrderService/OrderService.Tests/OrderServiceTests.cs @@ -0,0 +1,210 @@ +// Unit tests for Order Service business logic. +// Covers: item mapping by order type, bot assignment status, and geocoding fallback. +// Uses EF Core InMemory + fake HTTP handler — no real database or external APIs needed. +using System.Net; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using OrderService.Data; +using OrderService.DTOs; + +namespace OrderService.Tests; + +public sealed class OrderServiceTests +{ + // ── Setup helpers ────────────────────────────────────────────────────────── + + private static (Services.OrderService svc, OrderDbContext db) CreateService( + Func httpHandler, + Dictionary configValues) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new OrderDbContext(options); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var factory = new FakeHttpClientFactory(new FakeHttpMessageHandler(httpHandler)); + var logger = NullLogger.Instance; + + return (new Services.OrderService(db, factory, config, logger), db); + } + + private static PlaceOrderDto MakeOrder(string orderType = "Food Order") => new() + { + CustomerName = "Jane", + Phone = "555-1234", + DeliveryAddress = "123 Main St, Spokane WA", + OrderType = orderType + }; + + private static HttpResponseMessage Json(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage BotListJson(bool hasAvailableBot) => + Json(hasAvailableBot + ? """[{"id":1,"name":"bot-001","isOnline":true,"isServicingCustomer":false}]""" + : "[]"); + + private static Dictionary Config(string botUrl = "http://fake-bot-api") => + new() + { + ["BotNetApi:BaseUrl"] = botUrl, + ["EventHub:ConnectionString"] = "", + ["EventHub:Name"] = "robot-input" + }; + + // Dispatches to different responses based on whether the request is to Nominatim or BotNetApi + private static HttpResponseMessage DispatchByUrl( + HttpRequestMessage req, + string geocodeJson, + bool botAvailable) + { + return req.RequestUri!.Host.Contains("nominatim") + ? Json(geocodeJson) + : BotListJson(botAvailable); + } + + // ── MapOrderTypeToItems tests (verified through PlaceOrderAsync result) ──── + + [Fact] + public async Task PlaceOrder_FoodOrder_CreatesFoodItem() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var result = await svc.PlaceOrderAsync(MakeOrder("Food Order")); + + Assert.Contains(result.Items, i => i.ItemId == "food"); + } + + [Fact] + public async Task PlaceOrder_BeverageOrder_CreatesBeverageItem() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var result = await svc.PlaceOrderAsync(MakeOrder("Beverage Order")); + + Assert.Contains(result.Items, i => i.ItemId == "beverage"); + } + + [Fact] + public async Task PlaceOrder_SmallPackage_CreatesPackageItem() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var result = await svc.PlaceOrderAsync(MakeOrder("Small Package")); + + Assert.Contains(result.Items, i => i.ItemId == "package"); + } + + [Fact] + public async Task PlaceOrder_UnknownOrderType_DefaultsToFoodItem() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + var result = await svc.PlaceOrderAsync(MakeOrder("Mystery Order")); + + Assert.Contains(result.Items, i => i.ItemId == "food"); + } + + // ── Bot selection / order status tests ──────────────────────────────────── + + [Fact] + public async Task PlaceOrder_AssignedStatus_WhenBotIsAvailable() + { + var (svc, _) = CreateService( + req => DispatchByUrl(req, "[]", botAvailable: true), + Config()); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Assigned", result.Status); + Assert.Equal("bot-001", result.AssignedBotId); + } + + [Fact] + public async Task PlaceOrder_PendingStatus_WhenNoBotsAvailable() + { + var (svc, _) = CreateService( + req => DispatchByUrl(req, "[]", botAvailable: false), + Config()); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Pending", result.Status); + Assert.Null(result.AssignedBotId); + } + + [Fact] + public async Task PlaceOrder_PendingStatus_WhenBotApiNotConfigured() + { + var (svc, _) = CreateService(_ => Json("[]"), Config(botUrl: "")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal("Pending", result.Status); + Assert.Null(result.AssignedBotId); + } + + // ── Geocoding fallback tests ─────────────────────────────────────────────── + + [Fact] + public async Task PlaceOrder_UsesDefaultCoords_WhenGeocodingReturnsEmpty() + { + var (svc, _) = CreateService( + req => DispatchByUrl(req, "[]", botAvailable: false), + Config(botUrl: "")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + // Fallback is downtown Spokane + Assert.Equal(47.6588, result.Destination!.Latitude); + Assert.Equal(-117.4260, result.Destination!.Longitude); + } + + [Fact] + public async Task PlaceOrder_UsesDefaultCoords_WhenGeocodingFails() + { + var (svc, _) = CreateService( + req => req.RequestUri!.Host.Contains("nominatim") + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : BotListJson(false), + Config(botUrl: "")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal(47.6588, result.Destination!.Latitude); + Assert.Equal(-117.4260, result.Destination!.Longitude); + } + + [Fact] + public async Task PlaceOrder_UsesGeocodedCoords_WhenGeocodingSucceeds() + { + const string nominatimJson = """[{"lat":"47.6700","lon":"-117.4100"}]"""; + + var (svc, _) = CreateService( + req => DispatchByUrl(req, nominatimJson, botAvailable: false), + Config(botUrl: "")); + + var result = await svc.PlaceOrderAsync(MakeOrder()); + + Assert.Equal(47.6700, result.Destination!.Latitude, precision: 4); + Assert.Equal(-117.4100, result.Destination!.Longitude, precision: 4); + } + + // ── Fakes ───────────────────────────────────────────────────────────────── + + private sealed class FakeHttpMessageHandler(Func respond) + : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(respond(request)); + } + + private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => + new(handler, disposeHandler: false); + } +} diff --git a/OrderService/OrderService/.gitignore b/OrderService/OrderService/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/OrderService/OrderService/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/OrderService/OrderService/Controllers/OrdersController.cs b/OrderService/OrderService/Controllers/OrdersController.cs new file mode 100644 index 0000000..b4a1df5 --- /dev/null +++ b/OrderService/OrderService/Controllers/OrdersController.cs @@ -0,0 +1,44 @@ +// Handles incoming HTTP requests for the Order Service. +// Routes: POST /api/orders, GET /api/orders/{id}, GET /api/orders?customerId= +// No business logic here — just receives requests and delegates to OrderService. +using Microsoft.AspNetCore.Mvc; +using OrderService.DTOs; +using OrderService.Services; + +namespace OrderService.Controllers; + +[ApiController] +[Route("api/orders")] +public class OrdersController : ControllerBase +{ + private readonly IOrderService _orderService; + + public OrdersController(IOrderService orderService) + { + _orderService = orderService; + } + + // POST /api/orders — called by the customer web app when the order form is submitted + [HttpPost] + public async Task PlaceOrder([FromBody] PlaceOrderDto dto) + { + var order = await _orderService.PlaceOrderAsync(dto); + return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); + } + + // GET /api/orders/{id} — customer checks their order status by order ID + [HttpGet("{id:guid}")] + public async Task GetOrder(Guid id) + { + var order = await _orderService.GetOrderAsync(id); + return order is null ? NotFound() : Ok(order); + } + + // GET /api/orders?customerId=xxx — returns full order history for a customer + [HttpGet] + public async Task GetOrderHistory([FromQuery] string customerId) + { + var orders = await _orderService.GetOrderHistoryAsync(customerId); + return Ok(orders); + } +} diff --git a/OrderService/OrderService/DTOs/OrderResponseDto.cs b/OrderService/OrderService/DTOs/OrderResponseDto.cs new file mode 100644 index 0000000..b6549b4 --- /dev/null +++ b/OrderService/OrderService/DTOs/OrderResponseDto.cs @@ -0,0 +1,27 @@ +// Defines the shape of the response sent back to the customer after placing an order. +// Includes the order ID, assigned bot, status, and geocoded destination coordinates. +namespace OrderService.DTOs; + +public class OrderResponseDto +{ + public Guid Id { get; set; } + public string CustomerId { get; set; } = string.Empty; + public string? AssignedBotId { get; set; } + public string Status { get; set; } = string.Empty; + public string DeliveryAddress { get; set; } = string.Empty; + public GpsLocationDto? Destination { get; set; } + public List Items { get; set; } = []; + public DateTime CreatedAt { get; set; } +} + +public class OrderItemDto +{ + public string ItemId { get; set; } = string.Empty; + public int Quantity { get; set; } +} + +public class GpsLocationDto +{ + public double Latitude { get; set; } + public double Longitude { get; set; } +} diff --git a/OrderService/OrderService/DTOs/PlaceOrderDto.cs b/OrderService/OrderService/DTOs/PlaceOrderDto.cs new file mode 100644 index 0000000..bbe49ce --- /dev/null +++ b/OrderService/OrderService/DTOs/PlaceOrderDto.cs @@ -0,0 +1,24 @@ +// Defines the shape of the request body when a customer places an order. +// Matches the fields in the customer web app order form. +using System.ComponentModel.DataAnnotations; + +namespace OrderService.DTOs; + +public class PlaceOrderDto +{ + [Required] + public string CustomerName { get; set; } = string.Empty; + + [Required] + public string Phone { get; set; } = string.Empty; + + public string RestaurantOrStore { get; set; } = string.Empty; + + [Required] + public string DeliveryAddress { get; set; } = string.Empty; + + [Required] + public string OrderType { get; set; } = "Food Order"; + + public string DeliveryNotes { get; set; } = string.Empty; +} diff --git a/OrderService/OrderService/Data/OrderDbContext.cs b/OrderService/OrderService/Data/OrderDbContext.cs new file mode 100644 index 0000000..4f25689 --- /dev/null +++ b/OrderService/OrderService/Data/OrderDbContext.cs @@ -0,0 +1,35 @@ +// Bridge between the Order Service and Azure SQL. +// Tells EF Core what tables exist and how they relate to each other. +using Microsoft.EntityFrameworkCore; +using OrderService.Models; + +namespace OrderService.Data; + +public class OrderDbContext : DbContext +{ + public OrderDbContext(DbContextOptions options) : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(o => o.Id); + entity.Property(o => o.CustomerId).IsRequired().HasMaxLength(100); + entity.Property(o => o.DeliveryAddress).HasMaxLength(500); + entity.Property(o => o.Status).HasConversion(); + entity.HasMany(o => o.Items) + .WithOne(i => i.Order) + .HasForeignKey(i => i.OrderId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(i => i.Id); + entity.Property(i => i.ItemId).IsRequired().HasMaxLength(100); + }); + } +} diff --git a/OrderService/OrderService/Dockerfile b/OrderService/OrderService/Dockerfile new file mode 100644 index 0000000..d8b84d3 --- /dev/null +++ b/OrderService/OrderService/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["OrderService/OrderService.csproj", "OrderService/"] +RUN dotnet restore "OrderService/OrderService.csproj" + +COPY . . +WORKDIR "/src/OrderService" +RUN dotnet build "OrderService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "OrderService.csproj" -c Release -o /app/publish --no-restore + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "OrderService.dll"] diff --git a/OrderService/OrderService/Migrations/20260525191716_InitialCreate.Designer.cs b/OrderService/OrderService/Migrations/20260525191716_InitialCreate.Designer.cs new file mode 100644 index 0000000..e59d0c1 --- /dev/null +++ b/OrderService/OrderService/Migrations/20260525191716_InitialCreate.Designer.cs @@ -0,0 +1,107 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OrderService.Data; + +#nullable disable + +namespace OrderService.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260525191716_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedBotId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DestinationLatitude") + .HasColumnType("float"); + + b.Property("DestinationLongitude") + .HasColumnType("float"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ItemId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.HasOne("OrderService.Models.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OrderService/OrderService/Migrations/20260525191716_InitialCreate.cs b/OrderService/OrderService/Migrations/20260525191716_InitialCreate.cs new file mode 100644 index 0000000..d98a74e --- /dev/null +++ b/OrderService/OrderService/Migrations/20260525191716_InitialCreate.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderService.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CustomerId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AssignedBotId = table.Column(type: "nvarchar(max)", nullable: true), + DestinationLatitude = table.Column(type: "float", nullable: false), + DestinationLongitude = table.Column(type: "float", nullable: false), + Status = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrderItems", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + ItemId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Quantity = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderItems", x => x.Id); + table.ForeignKey( + name: "FK_OrderItems_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrderItems_OrderId", + table: "OrderItems", + column: "OrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrderItems"); + + migrationBuilder.DropTable( + name: "Orders"); + } + } +} diff --git a/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.Designer.cs b/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.Designer.cs new file mode 100644 index 0000000..37d9828 --- /dev/null +++ b/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.Designer.cs @@ -0,0 +1,112 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OrderService.Data; + +#nullable disable + +namespace OrderService.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260525192642_AddDeliveryAddress")] + partial class AddDeliveryAddress + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedBotId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeliveryAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationLatitude") + .HasColumnType("float"); + + b.Property("DestinationLongitude") + .HasColumnType("float"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ItemId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.HasOne("OrderService.Models.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.cs b/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.cs new file mode 100644 index 0000000..3e194fc --- /dev/null +++ b/OrderService/OrderService/Migrations/20260525192642_AddDeliveryAddress.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OrderService.Migrations +{ + /// + public partial class AddDeliveryAddress : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeliveryAddress", + table: "Orders", + type: "nvarchar(500)", + maxLength: 500, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeliveryAddress", + table: "Orders"); + } + } +} diff --git a/OrderService/OrderService/Migrations/OrderDbContextModelSnapshot.cs b/OrderService/OrderService/Migrations/OrderDbContextModelSnapshot.cs new file mode 100644 index 0000000..fcb22a8 --- /dev/null +++ b/OrderService/OrderService/Migrations/OrderDbContextModelSnapshot.cs @@ -0,0 +1,109 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OrderService.Data; + +#nullable disable + +namespace OrderService.Migrations +{ + [DbContext(typeof(OrderDbContext))] + partial class OrderDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedBotId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeliveryAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationLatitude") + .HasColumnType("float"); + + b.Property("DestinationLongitude") + .HasColumnType("float"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ItemId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("OrderService.Models.OrderItem", b => + { + b.HasOne("OrderService.Models.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OrderService.Models.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OrderService/OrderService/Models/Order.cs b/OrderService/OrderService/Models/Order.cs new file mode 100644 index 0000000..e98c354 --- /dev/null +++ b/OrderService/OrderService/Models/Order.cs @@ -0,0 +1,33 @@ +// Represents a single order in the database. +// One order can have many OrderItems (stored in a separate table). +using System.ComponentModel.DataAnnotations; + +namespace OrderService.Models; + +public class Order +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + // Stored as "CustomerName:Phone" since there's no login system + [Required] + [MaxLength(100)] + public string CustomerId { get; set; } = string.Empty; + + // Set after round-robin selection from BotNetApi — null if no bot was available + public string? AssignedBotId { get; set; } + + // Original address typed by the customer + [MaxLength(500)] + public string DeliveryAddress { get; set; } = string.Empty; + + // GPS coordinates geocoded from DeliveryAddress — sent to the robot simulator + public double DestinationLatitude { get; set; } + public double DestinationLongitude { get; set; } + + public OrderStatus Status { get; set; } = OrderStatus.Pending; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public List Items { get; set; } = []; +} diff --git a/OrderService/OrderService/Models/OrderItem.cs b/OrderService/OrderService/Models/OrderItem.cs new file mode 100644 index 0000000..a4e4aad --- /dev/null +++ b/OrderService/OrderService/Models/OrderItem.cs @@ -0,0 +1,19 @@ +// Represents a single item within an order. +// Linked to Order via OrderId foreign key. one order can have multiple items. +using System.ComponentModel.DataAnnotations; + +namespace OrderService.Models; + +public class OrderItem +{ + public int Id { get; set; } + + public Guid OrderId { get; set; } + public Order Order { get; set; } = null!; + + [Required] + [MaxLength(100)] + public string ItemId { get; set; } = string.Empty; + + public int Quantity { get; set; } +} diff --git a/OrderService/OrderService/Models/OrderStatus.cs b/OrderService/OrderService/Models/OrderStatus.cs new file mode 100644 index 0000000..a480304 --- /dev/null +++ b/OrderService/OrderService/Models/OrderStatus.cs @@ -0,0 +1,13 @@ +// Defines the possible states an order can be in. +// Status moves forward as the bot picks up and delivers the order. +namespace OrderService.Models; + +public enum OrderStatus +{ + Pending, + Assigned, + InTransit, + Delivered, + Cancelled, + Failed +} diff --git a/OrderService/OrderService/OrderService.csproj b/OrderService/OrderService/OrderService.csproj new file mode 100644 index 0000000..3c3650d --- /dev/null +++ b/OrderService/OrderService/OrderService.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + orderservice-deliverybot + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/OrderService/OrderService/Program.cs b/OrderService/OrderService/Program.cs new file mode 100644 index 0000000..92f3db6 --- /dev/null +++ b/OrderService/OrderService/Program.cs @@ -0,0 +1,58 @@ +// Application startup — wires together the database, services, HTTP clients, and Swagger. +// Runs EF Core migrations automatically on startup so tables are always up to date. +using Microsoft.EntityFrameworkCore; +using OrderService.Data; +using OrderService.Services; + +var builder = WebApplication.CreateBuilder(args); + +// ── Database ─────────────────────────────────────────────────────────────────── +// Uses Managed Identity in Azure — no password needed +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// ── Services ─────────────────────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); +// Nominatim requires a User-Agent header or it rejects requests +builder.Services.AddHttpClient("Nominatim", client => +{ + client.DefaultRequestHeaders.UserAgent.ParseAdd("DeliveryBotSystem/1.0"); +}); + +// ── Controllers ──────────────────────────────────────────────────────────────── +builder.Services.AddControllers() + .AddJsonOptions(opts => + opts.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); + +// ── Swagger / OpenAPI ────────────────────────────────────────────────────────── +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// ── Auto-migrate on startup ──────────────────────────────────────────────────── +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + try + { + db.Database.Migrate(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "Database migration failed on startup."); + } +} + +// ── Middleware ───────────────────────────────────────────────────────────────── +// Swagger UI is enabled in all environments so the deployed API can be explored +// from the browser at /swagger. +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseHttpsRedirection(); +app.MapControllers(); + +app.Run(); diff --git a/OrderService/OrderService/Properties/launchSettings.json b/OrderService/OrderService/Properties/launchSettings.json new file mode 100644 index 0000000..b0ae7ee --- /dev/null +++ b/OrderService/OrderService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "OrderService": { + "commandName": "Project", + "applicationUrl": "http://localhost:5050", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/OrderService/OrderService/Services/IOrderService.cs b/OrderService/OrderService/Services/IOrderService.cs new file mode 100644 index 0000000..21734e3 --- /dev/null +++ b/OrderService/OrderService/Services/IOrderService.cs @@ -0,0 +1,12 @@ +// Defines the contract for the Order Service. +// Any class implementing this interface must provide these three methods. +using OrderService.DTOs; + +namespace OrderService.Services; + +public interface IOrderService +{ + Task PlaceOrderAsync(PlaceOrderDto dto); + Task GetOrderAsync(Guid id); + Task> GetOrderHistoryAsync(string customerId); +} diff --git a/OrderService/OrderService/Services/OrderService.cs b/OrderService/OrderService/Services/OrderService.cs new file mode 100644 index 0000000..38574cc --- /dev/null +++ b/OrderService/OrderService/Services/OrderService.cs @@ -0,0 +1,262 @@ +// Core logic for the Order Service. +// Handles: geocoding delivery addresses, selecting an available bot from BotNetApi, +// saving orders to the database, and publishing order assignment events to Azure Event Hub. +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using OrderService.Data; +using OrderService.DTOs; +using OrderService.Models; + +namespace OrderService.Services; + +public class OrderService : IOrderService +{ + private readonly OrderDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OrderService( + OrderDbContext db, + IHttpClientFactory httpClientFactory, + IConfiguration config, + ILogger logger) + { + _db = db; + _httpClientFactory = httpClientFactory; + _config = config; + _logger = logger; + } + + public async Task PlaceOrderAsync(PlaceOrderDto dto) + { + // 1. Geocode the delivery address to GPS coordinates + var (latitude, longitude) = await GeocodeAddressAsync(dto.DeliveryAddress); + + // 2. Pick an available bot from BotNetApi + var botId = await SelectBotAsync(); + + // 3. Map the form's order type to item IDs the simulator understands + var items = MapOrderTypeToItems(dto.OrderType); + + // 4. Build and save the order + var customerId = $"{dto.CustomerName}:{dto.Phone}"; + var order = new Order + { + CustomerId = customerId, + AssignedBotId = botId, + DeliveryAddress = dto.DeliveryAddress, + DestinationLatitude = latitude, + DestinationLongitude = longitude, + Status = botId is not null ? OrderStatus.Assigned : OrderStatus.Pending, + Items = items.Select(i => new OrderItem + { + ItemId = i.ItemId, + Quantity = i.Quantity + }).ToList() + }; + + _db.Orders.Add(order); + await _db.SaveChangesAsync(); + + // 5. Publish RobotOrderAssignment event to Event Hub + if (botId is not null) + await PublishOrderAssignmentAsync(order, botId); + + _logger.LogInformation( + "Order placed. OrderId={OrderId} CustomerId={CustomerId} BotId={BotId} Address={Address}", + order.Id, order.CustomerId, order.AssignedBotId, order.DeliveryAddress); + + return ToResponseDto(order); + } + + public async Task GetOrderAsync(Guid id) + { + var order = await _db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); + return order is null ? null : ToResponseDto(order); + } + + public async Task> GetOrderHistoryAsync(string customerId) + { + var orders = await _db.Orders + .Include(o => o.Items) + .Where(o => o.CustomerId == customerId) + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(); + + return orders.Select(ToResponseDto); + } + + // Calls OpenStreetMap Nominatim to convert a text address to GPS coordinates. + // Falls back to downtown Spokane if geocoding fails so orders still go through. + private async Task<(double Latitude, double Longitude)> GeocodeAddressAsync(string address) + { + const double defaultLat = 47.6588; + const double defaultLon = -117.4260; + + try + { + var client = _httpClientFactory.CreateClient("Nominatim"); + var encoded = Uri.EscapeDataString(address); + var response = await client.GetAsync( + $"https://nominatim.openstreetmap.org/search?q={encoded}&format=json&limit=1"); + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var results = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (results is null || results.Count == 0) + { + _logger.LogWarning("Geocoding returned no results for address: {Address}. Using default location.", address); + return (defaultLat, defaultLon); + } + + return (double.Parse(results[0].Lat), double.Parse(results[0].Lon)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Geocoding failed for address: {Address}. Using default location.", address); + return (defaultLat, defaultLon); + } + } + + // Maps the UI order type dropdown to item IDs the simulator recognizes + private static List<(string ItemId, int Quantity)> MapOrderTypeToItems(string orderType) => + orderType switch + { + "Beverage Order" => [("beverage", 1)], + "Small Package" => [("package", 1)], + _ => [("food", 1)] // "Food Order" and any unknown type + }; + + // Calls BotNetApi and returns the Name of the first available bot + private async Task SelectBotAsync() + { + var botApiUrl = _config["BotNetApi:BaseUrl"]; + if (string.IsNullOrWhiteSpace(botApiUrl)) + { + _logger.LogWarning("BotNetApi:BaseUrl is not configured. Skipping bot selection."); + return null; + } + + try + { + var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync($"{botApiUrl}/api/bots"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var bots = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + // Pick the first bot that is online and not currently servicing a customer + var available = bots?.FirstOrDefault(b => b.IsOnline && !b.IsServicingCustomer); + // Use Name as the bot ID — simulator tracks bots by name (e.g. "bot-001") + return available?.Name; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to contact BotNetApi for bot selection. Order will be Pending."); + return null; + } + } + + // Publishes a RobotOrderAssignment event to Azure Event Hub + private async Task PublishOrderAssignmentAsync(Order order, string botId) + { + var connectionString = _config["EventHub:ConnectionString"]; + var eventHubName = _config["EventHub:Name"]; + + if (string.IsNullOrWhiteSpace(connectionString) || string.IsNullOrWhiteSpace(eventHubName)) + { + _logger.LogWarning("EventHub is not configured. Skipping event publish."); + return; + } + + try + { + var producer = new Azure.Messaging.EventHubs.Producer.EventHubProducerClient( + connectionString, eventHubName); + + await using (producer) + { + var payload = new + { + eventId = Guid.NewGuid().ToString("N"), + eventType = "RobotOrderAssignment", + schemaVersion = "1.0", + timestampUtc = DateTimeOffset.UtcNow, + botId, + source = "order-service", + isSimulated = false, + data = new + { + orderId = order.Id.ToString(), + botId, + items = order.Items.Select(i => new { itemId = i.ItemId, quantity = i.Quantity }), + destination = new + { + latitude = order.DestinationLatitude, + longitude = order.DestinationLongitude + } + } + }; + + var json = JsonSerializer.Serialize(payload); + var eventData = new Azure.Messaging.EventHubs.EventData(Encoding.UTF8.GetBytes(json)); + await producer.SendAsync([eventData]); + + _logger.LogInformation( + "Published RobotOrderAssignment event. OrderId={OrderId} BotId={BotId}", + order.Id, botId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish order assignment event. OrderId={OrderId}", order.Id); + } + } + + private static OrderResponseDto ToResponseDto(Order order) => new() + { + Id = order.Id, + CustomerId = order.CustomerId, + AssignedBotId = order.AssignedBotId, + Status = order.Status.ToString(), + DeliveryAddress = order.DeliveryAddress, + Destination = new GpsLocationDto + { + Latitude = order.DestinationLatitude, + Longitude = order.DestinationLongitude + }, + Items = order.Items.Select(i => new OrderItemDto + { + ItemId = i.ItemId, + Quantity = i.Quantity + }).ToList(), + CreatedAt = order.CreatedAt + }; + + // Minimal shape of what BotNetApi returns — only fields we need + private sealed class BotDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsOnline { get; set; } + public bool IsServicingCustomer { get; set; } + } + + // Nominatim geocoding response shape + private sealed class NominatimResult + { + public string Lat { get; set; } = string.Empty; + public string Lon { get; set; } = string.Empty; + } +} diff --git a/OrderService/OrderService/appsettings.json b/OrderService/OrderService/appsettings.json new file mode 100644 index 0000000..4973150 --- /dev/null +++ b/OrderService/OrderService/appsettings.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=tcp:jacob-orderservice-sql2.database.windows.net,1433;Initial Catalog=OrderServiceDb;Authentication=Active Directory Managed Identity;" + }, + "BotNetApi": { + "BaseUrl": "https://ewu-deliverybotsystem-api.mangocoast-332176b0.westus2.azurecontainerapps.io" + }, + "EventHub": { + "ConnectionString": "", + "Name": "robot-input" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}