diff --git a/specs/001-create-a-per-user/checklists/requirements.md b/specs/001-create-a-per-user/checklists/requirements.md new file mode 100644 index 0000000..302ad9d --- /dev/null +++ b/specs/001-create-a-per-user/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Per-User Settings Page + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-03-30; no blocking issues found. +- Spec is ready for `/speckit.plan`. \ No newline at end of file diff --git a/specs/001-create-a-per-user/spec.md b/specs/001-create-a-per-user/spec.md new file mode 100644 index 0000000..d38a55e --- /dev/null +++ b/specs/001-create-a-per-user/spec.md @@ -0,0 +1,118 @@ +# Feature Specification: Per-User Settings Page + +**Feature Branch**: `001-create-a-per-user` +**Created**: 2026-03-30 +**Status**: Draft +**Input**: User description: "Create a per user settings page. Allow entry of average car mpg, a yearly goal, location picker for lat and long, oil change price, and mileage rate (in cents)." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Save Personal Ride and Cost Settings (Priority: P1) + +As an authenticated rider, I want a personal settings page where I can enter my average car mpg, yearly goal, oil change price, and mileage rate so the app can reflect my own commuting assumptions instead of generic defaults. + +**Why this priority**: This is the core value of the feature because it gives each rider a place to define the personal values that drive their own targets and cost comparisons. + +**Independent Test**: Can be fully tested by visiting the settings page as a signed-in rider, entering valid values for the numeric fields, saving, and confirming the same values appear when returning to the page. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider opens the settings page for the first time, **When** the page loads, **Then** the rider sees fields for average car mpg, yearly goal, oil change price, and mileage rate. +2. **Given** an authenticated rider enters valid values in the settings fields, **When** the rider saves the page, **Then** the system stores those values for that rider and confirms the save succeeded. +3. **Given** a rider has previously saved settings, **When** the rider returns to the settings page, **Then** the page shows the rider's currently saved values. + +--- + +### User Story 2 - Set a Personal Reference Location (Priority: P2) + +As an authenticated rider, I want to pick a location that stores latitude and longitude so the app can use a rider-specific reference point for location-based features and calculations. + +**Why this priority**: A saved location is part of the requested settings set, but it is secondary to the core need to store the rider's numeric personal settings. + +**Independent Test**: Can be fully tested by opening the settings page, selecting a location through the picker, saving, and verifying the saved location is shown again on a later visit. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider is on the settings page, **When** the rider selects a location from the location picker, **Then** the page captures the associated latitude and longitude values for that rider. +2. **Given** a rider selects a valid location and saves, **When** the rider revisits the settings page later, **Then** the previously selected location remains associated with the rider. + +--- + +### User Story 3 - Update Settings Safely Over Time (Priority: P3) + +As an authenticated rider, I want to update individual settings later without affecting other riders or losing unrelated saved values so my personal assumptions can change over time. + +**Why this priority**: Riders will revisit these values occasionally, but the feature still delivers clear value even before ongoing edits are polished beyond the initial save flow. + +**Independent Test**: Can be fully tested by saving an initial full settings profile, changing one field later, saving again, and verifying only that rider's intended value changed while the remaining saved values stay intact. + +**Acceptance Scenarios**: + +1. **Given** a rider already has saved settings, **When** the rider updates only one setting and saves, **Then** the updated value replaces the previous value and the rider's other saved settings remain unchanged. +2. **Given** two different riders have their own settings, **When** one rider changes and saves a value, **Then** the other rider's settings are unchanged. + +--- + +### Edge Cases + +- A rider has never saved settings before: the page loads with empty values or agreed defaults rather than another rider's data. +- A rider enters a non-numeric, zero, or negative value for a field that requires a positive amount: the system blocks saving that field value and shows a clear validation message. +- A rider provides a mileage rate with decimals in cents: the system preserves the entered precision supported by the product rather than silently rounding to a misleading value. +- A rider changes the selected location before saving: only the final confirmed location is stored. +- A rider leaves some settings blank because they do not know all values yet: the system saves only the completed valid settings and keeps incomplete optional fields unset. +- A rider opens the settings page without being authenticated: personal settings are not exposed. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a settings page for authenticated riders. +- **FR-002**: System MUST store settings separately for each rider so one rider can only view and change their own settings. +- **FR-003**: System MUST allow a rider to enter and save an average car mpg value. +- **FR-004**: System MUST allow a rider to enter and save a yearly goal value representing the rider's annual riding target. +- **FR-005**: System MUST allow a rider to enter and save an oil change price value. +- **FR-006**: System MUST allow a rider to enter and save a mileage rate value expressed in cents per mile. +- **FR-007**: System MUST allow a rider to choose a location and store the resulting latitude and longitude for that rider. +- **FR-008**: System MUST load a rider's existing saved settings when the rider opens the settings page. +- **FR-009**: System MUST require an explicit save action before changed settings are persisted. +- **FR-010**: System MUST validate each entered value before saving and prevent invalid values from being persisted. +- **FR-011**: System MUST show which field values failed validation in language a non-technical rider can understand. +- **FR-012**: System MUST preserve previously saved valid settings when a rider updates only a subset of fields. +- **FR-013**: System MUST support leaving individual settings unset when a rider has not provided a value for that field. +- **FR-014**: System MUST not expose personal settings to unauthenticated users. + +### Key Entities *(include if feature involves data)* + +- **User Settings Profile**: A rider-owned collection of personal settings including average car mpg, yearly goal, oil change price, mileage rate, and an optional saved location. +- **Location Preference**: A saved rider location represented by a selected place and its latitude and longitude coordinates. +- **Settings Field Value**: An individual user-provided value within the settings profile that can be saved, updated, validated, or left unset. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: At least 90% of authenticated riders can complete their first-time settings entry in under 2 minutes. +- **SC-002**: At least 95% of valid settings submissions succeed on the rider's first save attempt. +- **SC-003**: 100% of saved settings remain isolated to the rider who created or updated them. +- **SC-004**: At least 90% of riders who revisit the settings page can update a single field without re-entering unchanged values. + +## Assumptions + +- The settings page is available only after a rider has authenticated. +- The yearly goal is measured in the same ride-distance unit already used elsewhere in the product. +- Average car mpg, yearly goal, oil change price, and mileage rate are user-editable numeric values that must be positive when provided. +- A rider may save a partial settings profile if some requested values are still unknown. +- The saved location represents one rider-selected reference point at a time. diff --git a/specs/009-create-a-per-user/checklists/requirements.md b/specs/009-create-a-per-user/checklists/requirements.md new file mode 100644 index 0000000..302ad9d --- /dev/null +++ b/specs/009-create-a-per-user/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Per-User Settings Page + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-03-30; no blocking issues found. +- Spec is ready for `/speckit.plan`. \ No newline at end of file diff --git a/specs/009-create-a-per-user/contracts/user-settings-api.yaml b/specs/009-create-a-per-user/contracts/user-settings-api.yaml new file mode 100644 index 0000000..9fa5bc3 --- /dev/null +++ b/specs/009-create-a-per-user/contracts/user-settings-api.yaml @@ -0,0 +1,152 @@ +openapi: 3.0.3 +info: + title: BikeTracking User Settings API + version: 1.0.0 + description: Authenticated rider-scoped settings retrieval and save contract. + +paths: + /api/users/me/settings: + get: + summary: Get per-user settings for the authenticated rider + operationId: getUserSettings + responses: + '200': + description: Settings resolved + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + summary: Save per-user settings for the authenticated rider + operationId: saveUserSettings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsUpsertRequest' + responses: + '200': + description: Settings saved + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsResponse' + '400': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + schemas: + UserSettingsUpsertRequest: + type: object + properties: + averageCarMpg: + type: number + nullable: true + exclusiveMinimum: 0 + yearlyGoalMiles: + type: number + nullable: true + exclusiveMinimum: 0 + oilChangePrice: + type: number + nullable: true + exclusiveMinimum: 0 + mileageRateCents: + type: number + nullable: true + exclusiveMinimum: 0 + description: Rate value expressed in cents per mile. + locationLabel: + type: string + nullable: true + maxLength: 200 + latitude: + type: number + nullable: true + minimum: -90 + maximum: 90 + longitude: + type: number + nullable: true + minimum: -180 + maximum: 180 + + UserSettingsView: + type: object + required: + - averageCarMpg + - yearlyGoalMiles + - oilChangePrice + - mileageRateCents + - locationLabel + - latitude + - longitude + - updatedAtUtc + properties: + averageCarMpg: + type: number + nullable: true + yearlyGoalMiles: + type: number + nullable: true + oilChangePrice: + type: number + nullable: true + mileageRateCents: + type: number + nullable: true + locationLabel: + type: string + nullable: true + latitude: + type: number + nullable: true + longitude: + type: number + nullable: true + updatedAtUtc: + type: string + format: date-time + + UserSettingsResponse: + type: object + required: + - hasSettings + - settings + properties: + hasSettings: + type: boolean + settings: + $ref: '#/components/schemas/UserSettingsView' + + ErrorResponse: + type: object + required: + - code + - message + properties: + code: + type: string + message: + type: string + details: + type: array + items: + type: string \ No newline at end of file diff --git a/specs/009-create-a-per-user/data-model.md b/specs/009-create-a-per-user/data-model.md new file mode 100644 index 0000000..4af7363 --- /dev/null +++ b/specs/009-create-a-per-user/data-model.md @@ -0,0 +1,106 @@ +# Data Model: Per-User Settings Page + +**Feature**: Per-User Settings Page (009) +**Branch**: `009-create-a-per-user` +**Date**: 2026-03-30 +**Phase**: Phase 1 - Design & Contracts + +## Overview + +This feature introduces a rider-owned settings profile used to persist personal commuting assumptions and optional location coordinates. The model supports first-time save, full/partial updates, and retrieval for pre-populating the settings page. + +## Entities + +### UserSettingsProfile + +Canonical persisted settings collection for one authenticated rider. + +| Field | Type | Required | Validation | Notes | +|-------|------|----------|------------|-------| +| riderId | integer (int64) | Yes | > 0 | Owner identity; unique profile per rider | +| averageCarMpg | number or null | No | if set, > 0 | User-provided car efficiency assumption | +| yearlyGoalMiles | number or null | No | if set, > 0 | Annual ride-distance goal in app distance unit | +| oilChangePrice | number or null | No | if set, > 0 | Cost assumption for oil change | +| mileageRateCents | number or null | No | if set, > 0 | Reimbursement/valuation rate in cents per mile | +| locationLabel | string or null | No | max length 200 | User-facing selected location text | +| latitude | number or null | No | if set, -90 <= value <= 90 | Coordinate from location picker | +| longitude | number or null | No | if set, -180 <= value <= 180 | Coordinate from location picker | +| updatedAtUtc | string (date-time) | Yes | valid date-time | Last saved timestamp | + +### UserSettingsResponse + +Read contract payload for settings page initialization. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| hasSettings | boolean | Yes | Whether rider has previously saved profile state | +| settings | UserSettingsProfileView | Yes | Current persisted values (nullable per field) | + +### UserSettingsUpsertRequest + +Write contract payload submitted from settings page. + +| Field | Type | Required | Validation | Notes | +|-------|------|----------|------------|-------| +| averageCarMpg | number or null | No | if set, > 0 | | +| yearlyGoalMiles | number or null | No | if set, > 0 | | +| oilChangePrice | number or null | No | if set, > 0 | | +| mileageRateCents | number or null | No | if set, > 0 | Supports cents with decimals if provided | +| locationLabel | string or null | No | max length 200 | | +| latitude | number or null | No | if set, -90 <= value <= 90 | Must pair with longitude when set | +| longitude | number or null | No | if set, -180 <= value <= 180 | Must pair with latitude when set | + +### SettingsFormState (Frontend) + +Typed client model used by the page. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| values | object | Yes | Current editable fields mirroring API request shape | +| errors | map | Yes | Field-level validation messages | +| status | enum | Yes | `idle`, `loading`, `saving`, `saved`, `error` | +| hasLoadedProfile | boolean | Yes | Prevents editing before initial load completes | + +## Relationships + +- One rider has zero or one `UserSettingsProfile`. +- `UserSettingsProfile` belongs exclusively to one authenticated rider (`riderId`). +- `SettingsFormState` mirrors the server model and is reconciled with `UserSettingsResponse` after load/save. + +## State Transitions + +1. Rider opens settings page. +2. Frontend calls `GET /api/users/me/settings`. +3. API returns existing profile values (or empty/null settings state for first-time user). +4. Rider edits one or more fields. +5. Frontend validates input and submits `PUT /api/users/me/settings`. +6. API validates payload and upserts the rider-owned profile. +7. API returns updated profile snapshot; frontend updates `SettingsFormState` to `saved`. + +## Validation Rules + +### API Layer + +- Authenticated rider context is required. +- Numeric settings, when present, must be strictly positive. +- Coordinates, when present, must be in valid ranges and provided as a pair. +- Payload must not allow writing another rider's settings. + +### Frontend Layer + +- Numeric field parsing failures block save and show field-level errors. +- Latitude/longitude pair consistency is enforced before submit. +- Save action is explicit; changes are not persisted on field blur or route change. + +### Database Layer + +- Unique constraint on rider ownership (`riderId`). +- Coordinate bounds and numeric positivity enforced by constraints where supported. +- Non-null `updatedAtUtc` maintained on each save. + +## Failure and Empty-State Behavior + +- If profile read returns no saved values, the page loads with unset fields and remains editable. +- If save fails validation, affected fields are highlighted and prior saved values remain intact. +- If save fails unexpectedly, current form values remain on-screen for correction/retry. +- Unauthenticated requests return unauthorized responses and do not expose profile data. \ No newline at end of file diff --git a/specs/009-create-a-per-user/plan.md b/specs/009-create-a-per-user/plan.md new file mode 100644 index 0000000..2ea7c7d --- /dev/null +++ b/specs/009-create-a-per-user/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: Per-User Settings Page + +**Branch**: `009-create-a-per-user` | **Date**: 2026-03-30 | **Spec**: `/specs/009-create-a-per-user/spec.md` +**Input**: Feature specification from `/specs/009-create-a-per-user/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Add a rider-scoped settings page where authenticated users can view and save personal commuting assumptions: average car mpg, yearly goal, oil change price, mileage rate (in cents), and an optional reference location (latitude/longitude). The feature introduces an additive settings API contract and a typed frontend form workflow that supports first-time save, partial updates, and validation without cross-user data leakage. + +## Technical Context + +**Language/Version**: C# (.NET 10 Minimal API), TypeScript (React 19 + Vite), F# domain project available +**Primary Dependencies**: ASP.NET Core Minimal API, EF Core (SQLite provider), existing auth/session flow, React form patterns, existing frontend routing/navigation +**Storage**: SQLite local-file database via EF Core; additive per-rider settings persistence with optional lat/long fields +**Testing**: `dotnet test BikeTracking.slnx`, frontend `npm run lint`, `npm run build`, `npm run test:unit`, plus `npm run test:e2e` for authenticated cross-layer journey +**Target Platform**: Local-first Aspire web app in DevContainer (React browser client + .NET API) +**Project Type**: Web application (React frontend + Minimal API backend) +**Performance Goals**: Settings read/write API responses remain under constitutional target (<500ms p95); settings page load should render existing values in one request cycle +**Constraints**: Strict rider data isolation, explicit save required, partial update support, three-layer validation discipline (client/API/DB), additive contract without breaking existing endpoints +**Scale/Scope**: One settings page flow, one read endpoint, one upsert endpoint, and corresponding backend/frontend tests for first-save, update, validation, and auth boundaries + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +### Pre-Research Gate Review + +| Gate | Status | Notes | +|------|--------|-------| +| DevContainer-only development | PASS | Planning workflow and artifact generation executed in the current containerized workspace. | +| Clean architecture boundaries | PASS | Feature remains within user-profile/settings vertical slice; no direct cross-module internal coupling required. | +| React + TypeScript consistency | PASS | UI design targets typed React form state, validation feedback, and existing route flow. | +| Data validation depth | PASS | Plan enforces client validation, API DTO validation, and DB constraints for numeric and location values. | +| Contract-first collaboration | PASS | Additive settings API contract is defined before implementation tasks. | +| TDD gated workflow | PASS WITH ACTION | Tasks must include red tests first and user confirmation of failing tests before implementation. | +| Performance and observability expectations | PASS | Endpoints are low-cardinality profile operations and fit existing telemetry/logging pipeline. | + +No constitutional violations identified. + +### Post-Design Gate Re-Check + +| Gate | Status | Notes | +|------|--------|-------| +| Architecture and modularity | PASS | Design isolates settings read/write behavior into dedicated API + frontend feature components. | +| Contract compatibility | PASS | Contract adds new `/api/users/me/settings` endpoints and does not break existing APIs. | +| Validation and safety | PASS | Numeric and coordinate validations are explicit; unauthenticated access remains blocked. | +| UX consistency and accessibility | PASS | Settings flow remains optional, non-blocking, and follows existing form-validation UX patterns. | +| Verification matrix coverage | PASS WITH ACTION | Quickstart includes backend, frontend, and e2e command set required by constitution. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/009-create-a-per-user/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── user-settings-api.yaml +└── tasks.md # generated by /speckit.tasks +``` + +### Source Code (repository root) +```text +src/ +├── BikeTracking.Api/ +│ ├── Endpoints/ +│ ├── Application/ +│ ├── Contracts/ +│ └── Infrastructure/ +├── BikeTracking.Api.Tests/ +│ ├── Endpoints/ +│ ├── Application/ +│ └── Infrastructure/ +└── BikeTracking.Frontend/ + ├── src/ + │ ├── pages/ + │ ├── components/ + │ └── services/ + └── tests/ +``` + +**Structure Decision**: Reuse the established web-app split. Implement settings read/upsert in API endpoints + application services, wire persistence through existing EF Core infrastructure, and add a typed frontend settings page with API service integration and user-scoped state. + +## Complexity Tracking + +No constitutional violations requiring justification. diff --git a/specs/009-create-a-per-user/quickstart.md b/specs/009-create-a-per-user/quickstart.md new file mode 100644 index 0000000..6bccdd0 --- /dev/null +++ b/specs/009-create-a-per-user/quickstart.md @@ -0,0 +1,116 @@ +# Quickstart: Per-User Settings Page + +**Feature**: Per-User Settings Page +**Branch**: `009-create-a-per-user` +**Date**: 2026-03-30 + +## Quick Reference + +- API contract: [user-settings-api.yaml](./contracts/user-settings-api.yaml) +- Existing dependent contracts: + - signup/identify baseline: `specs/002-user-signup-pin/contracts/signup-identify-api.yaml` + - login usage of identify endpoint: `specs/003-user-login/contracts/login-api.yaml` +- API playground requests: `src/BikeTracking.Api/BikeTracking.Api.http` (settings GET/PUT scenarios) +- Frontend route target: `/settings` (placeholder route wired in `src/BikeTracking.Frontend/src/App.tsx`) + +## Implementation Steps + +### 1. Add Backend Settings Read/Upsert Endpoints + +1. Map authenticated endpoint `GET /api/users/me/settings`. +2. Map authenticated endpoint `PUT /api/users/me/settings`. +3. Add application service logic to: +- resolve current rider ID from auth context +- load settings profile for rider +- create profile on first save +- update profile on later saves +- validate numeric and coordinate constraints before persistence +4. Return response DTO aligned with contract. + +### 2. Add Frontend Settings Page + +1. Add route/page for authenticated settings management. +2. Load profile values on page mount via `GET /api/users/me/settings`. +3. Render fields for average car mpg, yearly goal, oil change price, mileage rate (cents), and location picker. +4. Offer an optional browser-location action that fills latitude and longitude for riders who allow geolocation access. +5. On save, validate client-side and submit `PUT /api/users/me/settings`. +6. Render field-level validation feedback and save status messages. + +### 3. Handle Partial Profile and Update Flow + +1. Support empty/unset optional fields on first load. +2. Support single-field updates without requiring full re-entry. +3. Ensure a user can clear optional values intentionally (set back to unset). +4. Keep user isolation strict: no cross-user data visibility or updates. + +### 4. TDD Workflow (Mandatory) + +1. Write failing tests first for endpoint auth boundaries, validation, create/update semantics, and frontend field behavior. +2. Run tests and capture failing outputs. +3. Obtain user confirmation that failures are meaningful. +4. Implement minimum code to make approved tests pass. +5. Re-run tests after each meaningful change. + +## Suggested Test Coverage + +### Backend + +- unauthenticated requests to settings endpoints return unauthorized +- first-time save creates rider settings profile +- subsequent save updates only authenticated rider profile +- positive numeric validation enforced for provided fields +- coordinate range and coordinate-pair validation enforced + +### Frontend + +- existing settings load and populate fields +- first-time empty settings state is editable +- save sends expected payload including optional null/unset values +- field-level validation prevents invalid submit +- save success and error states are shown accessibly + +### E2E + +- authenticated rider can save settings and see values on reload +- rider can update one field without losing other settings +- one rider cannot see or mutate another rider's settings + +## Verification Commands (Mandatory) + +```bash +# backend checks +cd /workspaces/neCodeBikeTracking +dotnet test BikeTracking.slnx + +# frontend checks +cd /workspaces/neCodeBikeTracking/src/BikeTracking.Frontend +npm run lint +npm run build +npm run test:unit + +# cross-layer/authenticated journey checks +npm run test:e2e +``` + +## Manual Verification Flow + +1. Start stack: `dotnet run --project src/BikeTracking.AppHost` +2. Authenticate as rider A and open settings page. +3. Save values for all requested fields, including a valid location. +4. Use the browser-location action and confirm latitude/longitude are populated when permission is granted. +5. Refresh page and confirm values persist. +6. Update only one field and save; confirm untouched fields remain unchanged. +7. Clear one optional field (for example, average car mpg) and save; confirm it returns as unset on reload. +8. Authenticate as rider B and verify rider A settings are not visible. +9. Enter invalid numeric/coordinate values and confirm validation blocks save. + +## Acceptance Notes + +- Settings are strictly rider-scoped and require authentication. +- Numeric settings are positive when provided and may remain unset if unknown. +- Location persists as latitude/longitude (and optional label) from picker input. +- Riders can optionally use browser geolocation to prefill latitude and longitude before saving. +- Save is explicit; no background auto-save is required in this feature. +- First-time create and later update use the same endpoint contract. +- Settings updates are partial: only changed fields are sent from the UI, while omitted fields are preserved. +- Explicit null values clear optional settings fields when the rider intentionally removes a value. \ No newline at end of file diff --git a/specs/009-create-a-per-user/research.md b/specs/009-create-a-per-user/research.md new file mode 100644 index 0000000..9e408ec --- /dev/null +++ b/specs/009-create-a-per-user/research.md @@ -0,0 +1,110 @@ +# Research: Per-User Settings Page + +**Feature**: Per-User Settings Page (009) +**Branch**: `009-create-a-per-user` +**Date**: 2026-03-30 +**Phase**: Phase 0 - Research & Decisions + +## Research Objectives + +1. Define contract shape for loading and saving rider-specific settings. +2. Determine persistence strategy for first-time create and subsequent updates. +3. Determine validation boundaries for numeric and coordinate fields. +4. Define partial-save behavior for unknown user values. +5. Define location picker representation and transport format. + +## Key Findings + +### 1. Settings API Shape (DECIDED) + +**Decision**: Provide an additive rider-scoped settings contract with two endpoints: `GET /api/users/me/settings` and `PUT /api/users/me/settings`. + +**Rationale**: +- Keeps settings access explicit and contract-first. +- Supports idempotent full or partial field updates through a single write endpoint. +- Avoids coupling with signup/login contracts while preserving authenticated user context. + +**Alternatives considered**: +- Add fields to existing identify/login response: rejected because settings lifecycle is independent of login. +- Use multiple per-field endpoints: rejected due to higher API complexity and chatty client behavior. + +--- + +### 2. Persistence Model Strategy (DECIDED) + +**Decision**: Use one rider-owned settings profile record (create on first save, update on later saves), keyed by rider identity and stored in existing SQLite/EF Core infrastructure. + +**Rationale**: +- Matches per-user isolation requirements. +- Minimizes schema complexity while supporting atomic update of related settings. +- Fits current architecture and avoids adding a new storage engine. + +**Alternatives considered**: +- Separate table/entity per settings field: rejected as over-normalized for MVP scope. +- Store settings only in client session storage: rejected due to persistence and cross-device/browser inconsistency. + +--- + +### 3. Validation Rules (DECIDED) + +**Decision**: Enforce positive numeric validation for provided numeric fields and standard geographic bounds for coordinates (`latitude` in [-90, 90], `longitude` in [-180, 180]), with nullable values for optional/unset settings. + +**Rationale**: +- Aligns with specification requirements for positive numeric inputs. +- Prevents invalid geospatial values from entering the system. +- Supports progressive profile completion when riders do not know all values yet. + +**Alternatives considered**: +- Require all fields at first save: rejected because spec explicitly allows partial settings. +- Allow unconstrained coordinates and sanitize later: rejected because invalid values can propagate to downstream features. + +--- + +### 4. Partial Update Semantics (DECIDED) + +**Decision**: `PUT` treats omitted or null optional fields as intentional "unset"/"leave unset" values and persists only submitted valid state for the authenticated rider. + +**Rationale**: +- Supports first-time and incremental updates with one contract. +- Prevents accidental overwrites of unrelated users or profiles. +- Keeps frontend behavior straightforward for form save operations. + +**Alternatives considered**: +- Introduce separate PATCH semantics for partial updates: rejected for unnecessary contract branching at this stage. +- Force full payload on each save: rejected due to friction and mismatch with partial profile requirement. + +--- + +### 5. Location Picker Transport Format (DECIDED) + +**Decision**: Represent saved location as a structure containing optional display label plus numeric `latitude` and `longitude` values. + +**Rationale**: +- Preserves map/search UX context via label while keeping calculations based on coordinates. +- Avoids vendor lock-in to a specific map provider payload. +- Keeps backend contract focused on normalized values. + +**Alternatives considered**: +- Persist only label text: rejected because coordinates are required by the spec. +- Persist provider-specific place blob: rejected due to unnecessary coupling and migration risk. + +## Technical Decisions Summary + +| Decision Area | Chosen Approach | Why | +|---------------|-----------------|-----| +| API contract | `GET` + `PUT` on `/api/users/me/settings` | Simple, rider-scoped, additive | +| Persistence | One rider-owned settings profile record | Minimal schema with clear ownership | +| Validation | Positive numerics + bounded coordinates + nullable optional fields | Data integrity with partial completion | +| Update semantics | Idempotent save of submitted profile state | Supports first-save and edits | +| Location format | Label + latitude/longitude | UX context without provider lock-in | + +## Dependencies & Assumptions + +- Authenticated user identity is available in API request context. +- Existing EF Core migration workflow will add or evolve settings persistence schema. +- Frontend has existing authenticated route pattern where settings page can be integrated. +- No breaking change to prior feature contracts is required. + +## Open Questions + +None. All planning clarifications for this feature are resolved. \ No newline at end of file diff --git a/specs/009-create-a-per-user/spec.md b/specs/009-create-a-per-user/spec.md new file mode 100644 index 0000000..cfbef43 --- /dev/null +++ b/specs/009-create-a-per-user/spec.md @@ -0,0 +1,118 @@ +# Feature Specification: Per-User Settings Page + +**Feature Branch**: `009-create-a-per-user` +**Created**: 2026-03-30 +**Status**: Draft +**Input**: User description: "Create a per user settings page. Allow entry of average car mpg, a yearly goal, location picker for lat and long, oil change price, and mileage rate (in cents)." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Save Personal Ride and Cost Settings (Priority: P1) + +As an authenticated rider, I want a personal settings page where I can enter my average car mpg, yearly goal, oil change price, and mileage rate so the app can reflect my own commuting assumptions instead of generic defaults. + +**Why this priority**: This is the core value of the feature because it gives each rider a place to define the personal values that drive their own targets and cost comparisons. + +**Independent Test**: Can be fully tested by visiting the settings page as a signed-in rider, entering valid values for the numeric fields, saving, and confirming the same values appear when returning to the page. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider opens the settings page for the first time, **When** the page loads, **Then** the rider sees fields for average car mpg, yearly goal, oil change price, and mileage rate. +2. **Given** an authenticated rider enters valid values in the settings fields, **When** the rider saves the page, **Then** the system stores those values for that rider and confirms the save succeeded. +3. **Given** a rider has previously saved settings, **When** the rider returns to the settings page, **Then** the page shows the rider's currently saved values. + +--- + +### User Story 2 - Set a Personal Reference Location (Priority: P2) + +As an authenticated rider, I want to pick a location that stores latitude and longitude so the app can use a rider-specific reference point for location-based features and calculations. + +**Why this priority**: A saved location is part of the requested settings set, but it is secondary to the core need to store the rider's numeric personal settings. + +**Independent Test**: Can be fully tested by opening the settings page, selecting a location through the picker, saving, and verifying the saved location is shown again on a later visit. + +**Acceptance Scenarios**: + +1. **Given** an authenticated rider is on the settings page, **When** the rider selects a location from the location picker, **Then** the page captures the associated latitude and longitude values for that rider. +2. **Given** a rider selects a valid location and saves, **When** the rider revisits the settings page later, **Then** the previously selected location remains associated with the rider. + +--- + +### User Story 3 - Update Settings Safely Over Time (Priority: P3) + +As an authenticated rider, I want to update individual settings later without affecting other riders or losing unrelated saved values so my personal assumptions can change over time. + +**Why this priority**: Riders will revisit these values occasionally, but the feature still delivers clear value even before ongoing edits are polished beyond the initial save flow. + +**Independent Test**: Can be fully tested by saving an initial full settings profile, changing one field later, saving again, and verifying only that rider's intended value changed while the remaining saved values stay intact. + +**Acceptance Scenarios**: + +1. **Given** a rider already has saved settings, **When** the rider updates only one setting and saves, **Then** the updated value replaces the previous value and the rider's other saved settings remain unchanged. +2. **Given** two different riders have their own settings, **When** one rider changes and saves a value, **Then** the other rider's settings are unchanged. + +--- + +### Edge Cases + +- A rider has never saved settings before: the page loads with empty values or agreed defaults rather than another rider's data. +- A rider enters a non-numeric, zero, or negative value for a field that requires a positive amount: the system blocks saving that field value and shows a clear validation message. +- A rider provides a mileage rate with decimals in cents: the system preserves the entered precision supported by the product rather than silently rounding to a misleading value. +- A rider changes the selected location before saving: only the final confirmed location is stored. +- A rider leaves some settings blank because they do not know all values yet: the system saves only the completed valid settings and keeps incomplete optional fields unset. +- A rider opens the settings page without being authenticated: personal settings are not exposed. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a settings page for authenticated riders. +- **FR-002**: System MUST store settings separately for each rider so one rider can only view and change their own settings. +- **FR-003**: System MUST allow a rider to enter and save an average car mpg value. +- **FR-004**: System MUST allow a rider to enter and save a yearly goal value representing the rider's annual riding target. +- **FR-005**: System MUST allow a rider to enter and save an oil change price value. +- **FR-006**: System MUST allow a rider to enter and save a mileage rate value expressed in cents per mile. +- **FR-007**: System MUST allow a rider to choose a location and store the resulting latitude and longitude for that rider. +- **FR-008**: System MUST load a rider's existing saved settings when the rider opens the settings page. +- **FR-009**: System MUST require an explicit save action before changed settings are persisted. +- **FR-010**: System MUST validate each entered value before saving and prevent invalid values from being persisted. +- **FR-011**: System MUST show which field values failed validation in language a non-technical rider can understand. +- **FR-012**: System MUST preserve previously saved valid settings when a rider updates only a subset of fields. +- **FR-013**: System MUST support leaving individual settings unset when a rider has not provided a value for that field. +- **FR-014**: System MUST not expose personal settings to unauthenticated users. + +### Key Entities *(include if feature involves data)* + +- **User Settings Profile**: A rider-owned collection of personal settings including average car mpg, yearly goal, oil change price, mileage rate, and an optional saved location. +- **Location Preference**: A saved rider location represented by a selected place and its latitude and longitude coordinates. +- **Settings Field Value**: An individual user-provided value within the settings profile that can be saved, updated, validated, or left unset. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: At least 90% of authenticated riders can complete their first-time settings entry in under 2 minutes. +- **SC-002**: At least 95% of valid settings submissions succeed on the rider's first save attempt. +- **SC-003**: 100% of saved settings remain isolated to the rider who created or updated them. +- **SC-004**: At least 90% of riders who revisit the settings page can update a single field without re-entering unchanged values. + +## Assumptions + +- The settings page is available only after a rider has authenticated. +- The yearly goal is measured in the same ride-distance unit already used elsewhere in the product. +- Average car mpg, yearly goal, oil change price, and mileage rate are user-editable numeric values that must be positive when provided. +- A rider may save a partial settings profile if some requested values are still unknown. +- The saved location represents one rider-selected reference point at a time. diff --git a/specs/009-create-a-per-user/tasks.md b/specs/009-create-a-per-user/tasks.md new file mode 100644 index 0000000..9a355f5 --- /dev/null +++ b/specs/009-create-a-per-user/tasks.md @@ -0,0 +1,226 @@ +# Tasks: Per-User Settings Page + +**Input**: Design documents from `/specs/009-create-a-per-user/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/user-settings-api.yaml + +**Tests**: Tests are included because this feature plan requires a TDD-first workflow. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Align docs, API playground requests, and route scaffolding before implementation. + +- [X] T001 Add settings endpoint examples to src/BikeTracking.Api/BikeTracking.Api.http +- [X] T002 Sync settings implementation notes in specs/009-create-a-per-user/quickstart.md +- [X] T003 [P] Add settings page route placeholder in src/BikeTracking.Frontend/src/App.tsx + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add contracts, persistence shape, and service wiring required by all user stories. + +**⚠️ CRITICAL**: No user story work starts until this phase is complete. + +- [X] T004 Add user settings request/response contracts in src/BikeTracking.Api/Contracts/UsersContracts.cs +- [X] T005 Create settings application service scaffold in src/BikeTracking.Api/Application/Users/UserSettingsService.cs +- [X] T006 Add UserSettings DbSet and model configuration in src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +- [X] T007 Create settings persistence entity in src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs +- [X] T008 Add EF Core migration for user settings persistence in src/BikeTracking.Api/Infrastructure/Persistence/Migrations +- [X] T009 Wire UserSettingsService dependency registration in src/BikeTracking.Api/Program.cs +- [X] T010 Add GET and PUT settings endpoint mappings in src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +- [X] T011 Add frontend settings API DTOs and client methods in src/BikeTracking.Frontend/src/services/users-api.ts + +**Checkpoint**: Foundation complete; user-story work can proceed. + +--- + +## Phase 3: User Story 1 - Save Personal Ride and Cost Settings (Priority: P1) 🎯 MVP + +**Goal**: Allow authenticated riders to view and save average car mpg, yearly goal, oil change price, and mileage rate. + +**Independent Test**: Open settings page as an authenticated rider, save valid numeric values, refresh, and verify values persist. + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Add failing backend service tests for first-save and load behavior in src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +- [X] T013 [P] [US1] Add failing endpoint tests for GET/PUT settings success and 401 behavior in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +- [X] T014 [P] [US1] Add failing frontend API transport tests for get/save user settings in src/BikeTracking.Frontend/src/services/users-api.test.ts +- [X] T015 [P] [US1] Add failing settings page tests for numeric fields load/save flow in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +- [X] T016 [US1] Run failing US1 test set and capture red results for approval via src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs and src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx + +### Implementation for User Story 1 + +- [X] T017 [US1] Implement numeric settings load/save logic in src/BikeTracking.Api/Application/Users/UserSettingsService.cs +- [X] T018 [US1] Implement settings endpoint request handling and response mapping in src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +- [X] T019 [US1] Implement settings API client calls in src/BikeTracking.Frontend/src/services/users-api.ts +- [X] T020 [US1] Implement authenticated settings page numeric form in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +- [X] T021 [US1] Add settings page styling for numeric fields and validation states in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css +- [X] T022 [US1] Add settings navigation entry from the miles shell in src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx +- [X] T023 [US1] Run US1 backend and frontend tests to green in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs and src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx + +**Checkpoint**: User Story 1 is independently functional and demoable. + +--- + +## Phase 4: User Story 2 - Set a Personal Reference Location (Priority: P2) + +**Goal**: Allow riders to select and save a location with latitude and longitude on the settings page. + +**Independent Test**: Select a location, save settings, reload, and verify the same coordinates are returned and shown. + +### Tests for User Story 2 + +- [X] T024 [P] [US2] Add failing backend validation tests for latitude/longitude bounds and pair requirements in src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +- [X] T025 [P] [US2] Add failing endpoint tests for location save and retrieval payload shape in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +- [X] T026 [P] [US2] Add failing settings page tests for location picker selection and coordinate persistence in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +- [X] T027 [US2] Run failing US2 tests and capture red results for approval via src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs and src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx + +### Implementation for User Story 2 + +- [X] T028 [US2] Implement location coordinate validation and persistence in src/BikeTracking.Api/Application/Users/UserSettingsService.cs +- [X] T029 [US2] Extend settings contracts with location label and coordinates in src/BikeTracking.Api/Contracts/UsersContracts.cs +- [X] T030 [US2] Implement location picker state and payload mapping in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +- [X] T031 [US2] Implement location label/coordinate transport handling in src/BikeTracking.Frontend/src/services/users-api.ts +- [X] T032 [US2] Run US2 tests to green in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs and src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx + +**Checkpoint**: User Story 2 is independently functional and demoable. + +--- + +## Phase 5: User Story 3 - Update Settings Safely Over Time (Priority: P3) + +**Goal**: Ensure partial updates preserve other values and enforce rider isolation. + +**Independent Test**: Save full profile, update one field, verify unchanged fields persist and another rider cannot see changed data. + +### Tests for User Story 3 + +- [X] T033 [P] [US3] Add failing backend tests for partial update preservation and cross-user isolation in src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +- [X] T034 [P] [US3] Add failing endpoint tests for authenticated rider-scoped updates in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +- [X] T035 [P] [US3] Add failing frontend tests for single-field update without full re-entry in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +- [X] T036 [P] [US3] Add failing authenticated e2e settings isolation scenario in src/BikeTracking.Frontend/tests/e2e/settings.spec.ts +- [X] T037 [US3] Run failing US3 tests and capture red results for approval via src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs and src/BikeTracking.Frontend/tests/e2e/settings.spec.ts + +### Implementation for User Story 3 + +- [X] T038 [US3] Implement partial update merge semantics in src/BikeTracking.Api/Application/Users/UserSettingsService.cs +- [X] T039 [US3] Ensure rider identity scoping in settings endpoint handlers in src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +- [X] T040 [US3] Preserve unchanged form values and clear-field behavior in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +- [X] T041 [US3] Run US3 backend/frontend/e2e tests to green in src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs and src/BikeTracking.Frontend/tests/e2e/settings.spec.ts + +**Checkpoint**: User Story 3 is independently functional and demoable. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final consistency checks, docs, and full verification matrix. + +- [X] T042 [P] Update settings acceptance notes and manual verification flow in specs/009-create-a-per-user/quickstart.md +- [X] T043 [P] Add settings page route/accessibility assertions in src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx +- [X] T044 Run full verification matrix commands from specs/009-create-a-per-user/quickstart.md and record outcomes in specs/009-create-a-per-user/tasks.md +- [X] T045 Add optional browser-location fill action for latitude/longitude in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx +- [X] T046 Run targeted settings-page browser-location tests in src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx + +### Verification Results (2026-03-30) + +- `dotnet test BikeTracking.slnx` -> passed (total: 95, failed: 0, succeeded: 94, skipped: 1) +- `npm run lint` -> passed +- `npm run build` -> passed +- `npm run test:unit` -> passed (test files: 10 passed, tests: 81 passed) +- `npm run test:e2e` -> passed (20 passed) + +### Incremental Validation Results (2026-03-30 browser location enhancement) + +- `npx vitest run src/pages/settings/SettingsPage.test.tsx` -> passed (1 file, 6 tests) +- `npm run lint` -> passed + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup (Phase 1) has no dependencies. +- Foundational (Phase 2) depends on Setup and blocks all user stories. +- User Story phases (Phase 3-5) depend on Foundational completion. +- Polish (Phase 6) depends on completion of the selected user stories. + +### User Story Dependencies + +- **US1 (P1)** starts immediately after Phase 2 and delivers MVP. +- **US2 (P2)** depends on US1 settings page baseline and adds location behavior. +- **US3 (P3)** depends on US1/US2 persistence and endpoint behavior to validate safe updates and isolation. + +### Within Each User Story + +- Write tests first and run them red before implementation. +- Obtain user confirmation that red tests fail for intended behavioral reasons. +- Implement minimal code to make tests pass. +- Re-run story-specific tests to green before moving to next story. + +## Parallel Opportunities + +- Phase 1: T003 can run in parallel with T001-T002. +- Phase 2: T004, T005, T007 can run in parallel before wiring tasks T006/T009/T010. +- US1: T012-T015 are parallel test-authoring tasks. +- US2: T024-T026 are parallel test-authoring tasks. +- US3: T033-T036 are parallel test-authoring tasks. +- Phase 6: T042 and T043 can run in parallel. + +## Parallel Example: User Story 1 + +```bash +# Parallel test authoring tasks: +T012 src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +T013 src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +T014 src/BikeTracking.Frontend/src/services/users-api.test.ts +T015 src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +``` + +## Parallel Example: User Story 2 + +```bash +# Parallel test authoring tasks: +T024 src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +T025 src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +T026 src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +``` + +## Parallel Example: User Story 3 + +```bash +# Parallel test authoring tasks: +T033 src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs +T034 src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +T035 src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx +T036 src/BikeTracking.Frontend/tests/e2e/settings.spec.ts +``` + +## Implementation Strategy + +### MVP First (US1) + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 (numeric settings load/save). +3. Validate US1 independently before continuing. + +### Incremental Delivery + +1. Add US2 location selection and coordinate persistence. +2. Add US3 safe partial updates and cross-user isolation. +3. Finish with Phase 6 verification and documentation. + +### Parallel Team Strategy + +1. One developer owns backend contracts/service/endpoint tasks. +2. One developer owns frontend page/service/routing tasks. +3. One developer owns test authoring and e2e tasks. +4. Merge per-story after red-to-green checkpoint completion. + +## Notes + +- [P] tasks touch separate files with no unresolved dependencies. +- [US#] labels map tasks directly to user stories. +- Each user story remains independently testable and demoable. +- Preserve additive API contract behavior; do not break existing endpoints. diff --git a/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs new file mode 100644 index 0000000..94eb1c2 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/Users/UserSettingsServiceTests.cs @@ -0,0 +1,259 @@ +using BikeTracking.Api.Application.Users; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Tests.TestSupport; + +namespace BikeTracking.Api.Tests.Application.Users; + +public sealed class UserSettingsServiceTests +{ + [Fact] + public async Task SaveAsync_CreatesSettingsProfile_ForFirstSave() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User A"); + var service = new UserSettingsService(dbContext); + + var result = await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 31.5m, + YearlyGoalMiles: 1800m, + OilChangePrice: 89.99m, + MileageRateCents: 67.5m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Response); + Assert.True(result.Response.HasSettings); + + var loaded = await service.GetAsync(user.UserId, CancellationToken.None); + Assert.True(loaded.IsSuccess); + Assert.NotNull(loaded.Response); + Assert.True(loaded.Response.HasSettings); + Assert.Equal(31.5m, loaded.Response.Settings.AverageCarMpg); + Assert.Equal(1800m, loaded.Response.Settings.YearlyGoalMiles); + Assert.Equal(89.99m, loaded.Response.Settings.OilChangePrice); + Assert.Equal(67.5m, loaded.Response.Settings.MileageRateCents); + } + + [Fact] + public async Task SaveAsync_UpdatesExistingSettings_WithoutLosingUnchangedValues() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User B"); + var service = new UserSettingsService(dbContext); + + await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 30m, + YearlyGoalMiles: 1500m, + OilChangePrice: 80m, + MileageRateCents: 65m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None + ); + + await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 32m, + YearlyGoalMiles: null, + OilChangePrice: null, + MileageRateCents: null, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None, + new HashSet(StringComparer.OrdinalIgnoreCase) { "averageCarMpg" } + ); + + var loaded = await service.GetAsync(user.UserId, CancellationToken.None); + Assert.NotNull(loaded.Response); + Assert.Equal(32m, loaded.Response.Settings.AverageCarMpg); + Assert.Equal(1500m, loaded.Response.Settings.YearlyGoalMiles); + Assert.Equal(80m, loaded.Response.Settings.OilChangePrice); + Assert.Equal(65m, loaded.Response.Settings.MileageRateCents); + } + + [Fact] + public async Task SaveAsync_RejectsLatitudeOutsideValidRange() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User C"); + var service = new UserSettingsService(dbContext); + + var result = await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 31m, + YearlyGoalMiles: 1400m, + OilChangePrice: 70m, + MileageRateCents: 50m, + LocationLabel: "Office", + Latitude: 100m, + Longitude: -71m + ), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task SaveAsync_RejectsCoordinateWhenPairIsIncomplete() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User D"); + var service = new UserSettingsService(dbContext); + + var result = await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 31m, + YearlyGoalMiles: 1400m, + OilChangePrice: 70m, + MileageRateCents: 50m, + LocationLabel: "Office", + Latitude: 42.3601m, + Longitude: null + ), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task SaveAsync_ClearsField_WhenExplicitNullProvidedOnUpdate() + { + using var dbContext = TestFactories.CreateDbContext(); + var user = await SeedUserAsync(dbContext, "Settings User E"); + var service = new UserSettingsService(dbContext); + + await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 31m, + YearlyGoalMiles: 1400m, + OilChangePrice: 70m, + MileageRateCents: 50m, + LocationLabel: "Office", + Latitude: 42.3601m, + Longitude: -71.0589m + ), + CancellationToken.None + ); + + var updateResult = await service.SaveAsync( + user.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: null, + YearlyGoalMiles: 1600m, + OilChangePrice: 70m, + MileageRateCents: 50m, + LocationLabel: "Office", + Latitude: 42.3601m, + Longitude: -71.0589m + ), + CancellationToken.None + ); + + Assert.True(updateResult.IsSuccess); + + var loaded = await service.GetAsync(user.UserId, CancellationToken.None); + Assert.NotNull(loaded.Response); + Assert.Null(loaded.Response.Settings.AverageCarMpg); + Assert.Equal(1600m, loaded.Response.Settings.YearlyGoalMiles); + } + + [Fact] + public async Task SaveAsync_DoesNotAffectAnotherUsersSettings() + { + using var dbContext = TestFactories.CreateDbContext(); + var firstUser = await SeedUserAsync(dbContext, "Settings User F"); + var secondUser = await SeedUserAsync(dbContext, "Settings User G"); + var service = new UserSettingsService(dbContext); + + await service.SaveAsync( + firstUser.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 25m, + YearlyGoalMiles: 1000m, + OilChangePrice: 45m, + MileageRateCents: 50m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None + ); + + await service.SaveAsync( + secondUser.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 35m, + YearlyGoalMiles: 2200m, + OilChangePrice: 95m, + MileageRateCents: 70m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None + ); + + await service.SaveAsync( + firstUser.UserId, + new UserSettingsUpsertRequest( + AverageCarMpg: 30m, + YearlyGoalMiles: 1200m, + OilChangePrice: 55m, + MileageRateCents: 52m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + CancellationToken.None + ); + + var secondLoaded = await service.GetAsync(secondUser.UserId, CancellationToken.None); + + Assert.NotNull(secondLoaded.Response); + Assert.Equal(35m, secondLoaded.Response.Settings.AverageCarMpg); + Assert.Equal(2200m, secondLoaded.Response.Settings.YearlyGoalMiles); + Assert.Equal(95m, secondLoaded.Response.Settings.OilChangePrice); + Assert.Equal(70m, secondLoaded.Response.Settings.MileageRateCents); + } + + private static async Task SeedUserAsync( + BikeTrackingDbContext dbContext, + string name + ) + { + var user = new UserEntity + { + DisplayName = name, + NormalizedName = name.ToUpperInvariant(), + CreatedAtUtc = DateTime.UtcNow, + IsActive = true, + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + return user; + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs index 4a127b6..6f3c48f 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/UsersEndpointsTests.cs @@ -5,6 +5,7 @@ using BikeTracking.Api.Endpoints; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Security; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -95,6 +96,174 @@ await host.SeedUserAsync( Assert.Equal(payload.RetryAfterSeconds.ToString(), retryAfterHeader); } + [Fact] + public async Task GetUserSettings_Returns401_WithoutAuthentication() + { + await using var host = await IdentifyApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/users/me/settings"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PutThenGetUserSettings_ReturnsPersistedValues_ForAuthenticatedUser() + { + await using var host = await IdentifyApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Casey", "1234"); + + var putResponse = await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 31.5m, + YearlyGoalMiles: 1800m, + OilChangePrice: 89.99m, + MileageRateCents: 67.5m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + userId + ); + + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + + var getResponse = await host.Client.GetWithAuthAsync("/api/users/me/settings", userId); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var payload = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.True(payload.HasSettings); + Assert.Equal(31.5m, payload.Settings.AverageCarMpg); + Assert.Equal(1800m, payload.Settings.YearlyGoalMiles); + } + + [Fact] + public async Task PutThenGetUserSettings_PersistsLocationCoordinates_ForAuthenticatedUser() + { + await using var host = await IdentifyApiHost.StartAsync(); + var userId = await host.SeedUserAsync("LocationCase", "1234"); + + var putResponse = await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 31.5m, + YearlyGoalMiles: 1800m, + OilChangePrice: 89.99m, + MileageRateCents: 67.5m, + LocationLabel: "Downtown Office", + Latitude: 42.3601m, + Longitude: -71.0589m + ), + userId + ); + + Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode); + + var getResponse = await host.Client.GetWithAuthAsync("/api/users/me/settings", userId); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var payload = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal("Downtown Office", payload.Settings.LocationLabel); + Assert.Equal(42.3601m, payload.Settings.Latitude); + Assert.Equal(-71.0589m, payload.Settings.Longitude); + } + + [Fact] + public async Task PutUserSettings_ClearsExplicitlyNullField_ForAuthenticatedUser() + { + await using var host = await IdentifyApiHost.StartAsync(); + var userId = await host.SeedUserAsync("ClearFieldCase", "1234"); + + var firstPut = await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 29.5m, + YearlyGoalMiles: 1100m, + OilChangePrice: 75m, + MileageRateCents: 51m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + userId + ); + + Assert.Equal(HttpStatusCode.OK, firstPut.StatusCode); + + var secondPut = await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: null, + YearlyGoalMiles: 1100m, + OilChangePrice: 75m, + MileageRateCents: 51m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + userId + ); + + Assert.Equal(HttpStatusCode.OK, secondPut.StatusCode); + + var getResponse = await host.Client.GetWithAuthAsync("/api/users/me/settings", userId); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var payload = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Null(payload.Settings.AverageCarMpg); + Assert.Equal(1100m, payload.Settings.YearlyGoalMiles); + } + + [Fact] + public async Task GetUserSettings_ReturnsRiderScopedValues_ForEachAuthenticatedUser() + { + await using var host = await IdentifyApiHost.StartAsync(); + var firstUserId = await host.SeedUserAsync("ScopeUserOne", "1234"); + var secondUserId = await host.SeedUserAsync("ScopeUserTwo", "1234"); + + await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 20m, + YearlyGoalMiles: 900m, + OilChangePrice: 50m, + MileageRateCents: 40m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + firstUserId + ); + + await host.Client.PutWithAuthAsync( + "/api/users/me/settings", + new UserSettingsUpsertRequest( + AverageCarMpg: 33m, + YearlyGoalMiles: 2100m, + OilChangePrice: 92m, + MileageRateCents: 68m, + LocationLabel: null, + Latitude: null, + Longitude: null + ), + secondUserId + ); + + var firstGet = await host.Client.GetWithAuthAsync("/api/users/me/settings", firstUserId); + var secondGet = await host.Client.GetWithAuthAsync("/api/users/me/settings", secondUserId); + + var firstPayload = await firstGet.Content.ReadFromJsonAsync(); + var secondPayload = await secondGet.Content.ReadFromJsonAsync(); + + Assert.NotNull(firstPayload); + Assert.NotNull(secondPayload); + Assert.Equal(20m, firstPayload.Settings.AverageCarMpg); + Assert.Equal(33m, secondPayload.Settings.AverageCarMpg); + } + private sealed class IdentifyApiHost(WebApplication app) : IAsyncDisposable { public HttpClient Client { get; } = app.GetTestClient(); @@ -113,8 +282,18 @@ public static async Task StartAsync() builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder + .Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName) + .AddScheme< + UserIdHeaderAuthenticationSchemeOptions, + UserIdHeaderAuthenticationHandler + >(UserIdHeaderAuthenticationHandler.SchemeName, _ => { }); + builder.Services.AddAuthorization(); var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); app.MapUsersEndpoints(); await app.StartAsync(); diff --git a/src/BikeTracking.Api/Application/Users/UserSettingsService.cs b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs new file mode 100644 index 0000000..1d54050 --- /dev/null +++ b/src/BikeTracking.Api/Application/Users/UserSettingsService.cs @@ -0,0 +1,242 @@ +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Application.Users; + +public sealed class UserSettingsService(BikeTrackingDbContext dbContext) +{ + private static readonly string[] AllSettingsFields = + [ + "averagecarmpg", + "yearlygoalmiles", + "oilchangeprice", + "mileageratecents", + "locationlabel", + "latitude", + "longitude", + ]; + + private readonly BikeTrackingDbContext _dbContext = dbContext; + + public async Task GetAsync( + long riderId, + CancellationToken cancellationToken + ) + { + var existing = await _dbContext + .UserSettings.AsNoTracking() + .SingleOrDefaultAsync(x => x.UserId == riderId, cancellationToken); + + if (existing is null) + { + return UserSettingsResult.Success( + new UserSettingsResponse( + HasSettings: false, + Settings: new UserSettingsView( + AverageCarMpg: null, + YearlyGoalMiles: null, + OilChangePrice: null, + MileageRateCents: null, + LocationLabel: null, + Latitude: null, + Longitude: null, + UpdatedAtUtc: null + ) + ) + ); + } + + return UserSettingsResult.Success(ToResponse(existing)); + } + + public async Task SaveAsync( + long riderId, + UserSettingsUpsertRequest request, + CancellationToken cancellationToken, + ISet? providedFields = null + ) + { + var existing = await _dbContext.UserSettings.SingleOrDefaultAsync( + x => x.UserId == riderId, + cancellationToken + ); + + var normalizedFields = NormalizeFields(providedFields); + + var averageCarMpg = ResolveNullableDecimal( + existing?.AverageCarMpg, + request.AverageCarMpg, + normalizedFields.Contains("averagecarmpg") + ); + var yearlyGoalMiles = ResolveNullableDecimal( + existing?.YearlyGoalMiles, + request.YearlyGoalMiles, + normalizedFields.Contains("yearlygoalmiles") + ); + var oilChangePrice = ResolveNullableDecimal( + existing?.OilChangePrice, + request.OilChangePrice, + normalizedFields.Contains("oilchangeprice") + ); + var mileageRateCents = ResolveNullableDecimal( + existing?.MileageRateCents, + request.MileageRateCents, + normalizedFields.Contains("mileageratecents") + ); + var locationLabel = ResolveNullableString( + existing?.LocationLabel, + request.LocationLabel, + normalizedFields.Contains("locationlabel") + ); + var mergedLatitude = ResolveNullableDecimal( + existing?.Latitude, + request.Latitude, + normalizedFields.Contains("latitude") + ); + var mergedLongitude = ResolveNullableDecimal( + existing?.Longitude, + request.Longitude, + normalizedFields.Contains("longitude") + ); + + if (averageCarMpg is <= 0) + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Average car mpg must be greater than 0." + ); + if (yearlyGoalMiles is <= 0) + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Yearly goal must be greater than 0." + ); + if (oilChangePrice is <= 0) + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Oil change price must be greater than 0." + ); + if (mileageRateCents is <= 0) + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Mileage rate must be greater than 0." + ); + + if (mergedLatitude.HasValue != mergedLongitude.HasValue) + { + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Latitude and longitude must both be provided." + ); + } + + if (mergedLatitude is < -90m or > 90m) + { + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Latitude must be between -90 and 90." + ); + } + + if (mergedLongitude is < -180m or > 180m) + { + return UserSettingsResult.Failure( + UsersErrorCodes.ValidationFailed, + "Longitude must be between -180 and 180." + ); + } + + if (existing is null) + { + existing = new UserSettingsEntity + { + UserId = riderId, + AverageCarMpg = averageCarMpg, + YearlyGoalMiles = yearlyGoalMiles, + OilChangePrice = oilChangePrice, + MileageRateCents = mileageRateCents, + LocationLabel = locationLabel, + Latitude = mergedLatitude, + Longitude = mergedLongitude, + UpdatedAtUtc = DateTime.UtcNow, + }; + + _dbContext.UserSettings.Add(existing); + } + else + { + existing.AverageCarMpg = averageCarMpg; + existing.YearlyGoalMiles = yearlyGoalMiles; + existing.OilChangePrice = oilChangePrice; + existing.MileageRateCents = mileageRateCents; + existing.LocationLabel = locationLabel; + existing.Latitude = mergedLatitude; + existing.Longitude = mergedLongitude; + existing.UpdatedAtUtc = DateTime.UtcNow; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return UserSettingsResult.Success(ToResponse(existing)); + } + + private static UserSettingsResponse ToResponse(UserSettingsEntity entity) + { + return new UserSettingsResponse( + HasSettings: true, + Settings: new UserSettingsView( + AverageCarMpg: entity.AverageCarMpg, + YearlyGoalMiles: entity.YearlyGoalMiles, + OilChangePrice: entity.OilChangePrice, + MileageRateCents: entity.MileageRateCents, + LocationLabel: entity.LocationLabel, + Latitude: entity.Latitude, + Longitude: entity.Longitude, + UpdatedAtUtc: entity.UpdatedAtUtc + ) + ); + } + + private static HashSet NormalizeFields(ISet? providedFields) + { + if (providedFields is null) + { + return new HashSet(AllSettingsFields, StringComparer.OrdinalIgnoreCase); + } + + return providedFields + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static decimal? ResolveNullableDecimal( + decimal? existing, + decimal? requested, + bool isProvided + ) + { + return isProvided ? requested : existing; + } + + private static string? ResolveNullableString( + string? existing, + string? requested, + bool isProvided + ) + { + return isProvided ? requested : existing; + } +} + +public sealed record UserSettingsResult( + bool IsSuccess, + UserSettingsResponse? Response, + ErrorResponse? Error +) +{ + public static UserSettingsResult Success(UserSettingsResponse response) => + new(true, response, null); + + public static UserSettingsResult Failure(string code, string message) => + new(false, null, new ErrorResponse(code, message)); +} diff --git a/src/BikeTracking.Api/BikeTracking.Api.http b/src/BikeTracking.Api/BikeTracking.Api.http index b6192a6..cdb1004 100644 --- a/src/BikeTracking.Api/BikeTracking.Api.http +++ b/src/BikeTracking.Api/BikeTracking.Api.http @@ -46,6 +46,40 @@ X-User-Id: {{RiderId}} GET {{ApiService_HostAddress}}/api/rides/history?page=1&pageSize=25 Accept: application/json +### Get user settings (expects 200 for authenticated rider) +GET {{ApiService_HostAddress}}/api/users/me/settings +Accept: application/json +X-User-Id: {{RiderId}} + +### Save user settings (create or update) +PUT {{ApiService_HostAddress}}/api/users/me/settings +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "averageCarMpg": 31.5, + "yearlyGoalMiles": 1800, + "oilChangePrice": 89.99, + "mileageRateCents": 67.5, + "locationLabel": "Downtown Office", + "latitude": 42.3601, + "longitude": -71.0589 +} + +### Save user settings (partial fields) +PUT {{ApiService_HostAddress}}/api/users/me/settings +Content-Type: application/json +X-User-Id: {{RiderId}} + +{ + "averageCarMpg": 33.1, + "mileageRateCents": 70.0 +} + +### Edge case: unauthenticated settings request (expects 401) +GET {{ApiService_HostAddress}}/api/users/me/settings +Accept: application/json + ############################################################################### ### Edit Ride Scenarios (spec-006-edit-ride-history) ############################################################################### diff --git a/src/BikeTracking.Api/Contracts/UsersContracts.cs b/src/BikeTracking.Api/Contracts/UsersContracts.cs index 9c3910a..765b87d 100644 --- a/src/BikeTracking.Api/Contracts/UsersContracts.cs +++ b/src/BikeTracking.Api/Contracts/UsersContracts.cs @@ -13,6 +13,29 @@ public sealed record IdentifyRequest(string Name, string Pin); public sealed record IdentifySuccessResponse(long UserId, string UserName, bool Authorized); +public sealed record UserSettingsUpsertRequest( + decimal? AverageCarMpg, + decimal? YearlyGoalMiles, + decimal? OilChangePrice, + decimal? MileageRateCents, + string? LocationLabel, + decimal? Latitude, + decimal? Longitude +); + +public sealed record UserSettingsView( + decimal? AverageCarMpg, + decimal? YearlyGoalMiles, + decimal? OilChangePrice, + decimal? MileageRateCents, + string? LocationLabel, + decimal? Latitude, + decimal? Longitude, + DateTime? UpdatedAtUtc +); + +public sealed record UserSettingsResponse(bool HasSettings, UserSettingsView Settings); + public sealed record ErrorResponse( string Code, string Message, @@ -55,4 +78,5 @@ public static class UsersErrorCodes public const string NameAlreadyExists = "name_already_exists"; public const string InvalidCredentials = "invalid_credentials"; public const string Throttled = "throttled"; + public const string SettingsNotFound = "settings_not_found"; } diff --git a/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs b/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs index 7002d9f..c41a770 100644 --- a/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/UsersEndpoints.cs @@ -1,4 +1,5 @@ -using BikeTracking.Api.Application.Users; +using System.Text.Json; +using BikeTracking.Api.Application.Users; using BikeTracking.Api.Contracts; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,8 @@ namespace BikeTracking.Api.Endpoints; public static class UsersEndpoints { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + public static IEndpointRouteBuilder MapUsersEndpoints(this IEndpointRouteBuilder endpoints) { var usersGroup = endpoints.MapGroup("/api/users"); @@ -27,6 +30,23 @@ public static IEndpointRouteBuilder MapUsersEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status429TooManyRequests); + var meGroup = endpoints.MapGroup("/api/users/me").RequireAuthorization(); + + meGroup + .MapGet("/settings", GetUserSettings) + .WithName("GetUserSettings") + .WithSummary("Get per-user settings for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + + meGroup + .MapPut("/settings", PutUserSettings) + .WithName("PutUserSettings") + .WithSummary("Save per-user settings for the authenticated rider") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized); + return endpoints; } @@ -89,4 +109,70 @@ private static IResult ToThrottleResult(IdentifyResult result, HttpContext httpC return Results.Json(payload, statusCode: StatusCodes.Status429TooManyRequests); } + + private static async Task GetUserSettings( + HttpContext context, + UserSettingsService userSettingsService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + var result = await userSettingsService.GetAsync(riderId, cancellationToken); + if (result.IsSuccess && result.Response is not null) + return Results.Ok(result.Response); + + return Results.BadRequest( + result.Error + ?? new ErrorResponse(UsersErrorCodes.ValidationFailed, "Validation failed.") + ); + } + + private static async Task PutUserSettings( + HttpContext context, + [FromBody] JsonElement requestBody, + UserSettingsService userSettingsService, + CancellationToken cancellationToken + ) + { + var userIdString = context.User.FindFirst("sub")?.Value; + if (!long.TryParse(userIdString, out var riderId) || riderId <= 0) + return Results.Unauthorized(); + + if (requestBody.ValueKind is not JsonValueKind.Object) + { + return Results.BadRequest( + new ErrorResponse(UsersErrorCodes.ValidationFailed, "Validation failed.") + ); + } + + var request = requestBody.Deserialize(JsonOptions); + if (request is null) + { + return Results.BadRequest( + new ErrorResponse(UsersErrorCodes.ValidationFailed, "Validation failed.") + ); + } + + var providedFields = requestBody + .EnumerateObject() + .Select(x => x.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var result = await userSettingsService.SaveAsync( + riderId, + request, + cancellationToken, + providedFields + ); + if (result.IsSuccess && result.Response is not null) + return Results.Ok(result.Response); + + return Results.BadRequest( + result.Error + ?? new ErrorResponse(UsersErrorCodes.ValidationFailed, "Validation failed.") + ); + } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs index 214113b..9e6099a 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/BikeTrackingDbContext.cs @@ -11,6 +11,7 @@ public sealed class BikeTrackingDbContext(DbContextOptions AuthAttemptStates => Set(); public DbSet OutboxEvents => Set(); public DbSet Rides => Set(); + public DbSet UserSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -118,6 +119,56 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(static x => x.RiderId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(static entity => + { + entity.ToTable( + "UserSettings", + static tableBuilder => + { + tableBuilder.HasCheckConstraint( + "CK_UserSettings_AverageCarMpg_Positive", + "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_UserSettings_YearlyGoalMiles_Positive", + "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_UserSettings_OilChangePrice_Positive", + "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_UserSettings_MileageRateCents_Positive", + "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0" + ); + tableBuilder.HasCheckConstraint( + "CK_UserSettings_Latitude_Range", + "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)" + ); + tableBuilder.HasCheckConstraint( + "CK_UserSettings_Longitude_Range", + "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)" + ); + } + ); + + entity.HasKey(static x => x.UserId); + entity.Property(static x => x.AverageCarMpg); + entity.Property(static x => x.YearlyGoalMiles); + entity.Property(static x => x.OilChangePrice); + entity.Property(static x => x.MileageRateCents); + entity.Property(static x => x.LocationLabel).HasMaxLength(200); + entity.Property(static x => x.Latitude); + entity.Property(static x => x.Longitude); + entity.Property(static x => x.UpdatedAtUtc).IsRequired(); + + entity + .HasOne() + .WithMany() + .HasForeignKey(static x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs new file mode 100644 index 0000000..6fc469e --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Entities/UserSettingsEntity.cs @@ -0,0 +1,22 @@ +namespace BikeTracking.Api.Infrastructure.Persistence.Entities; + +public sealed class UserSettingsEntity +{ + public long UserId { get; set; } + + public decimal? AverageCarMpg { get; set; } + + public decimal? YearlyGoalMiles { get; set; } + + public decimal? OilChangePrice { get; set; } + + public decimal? MileageRateCents { get; set; } + + public string? LocationLabel { get; set; } + + public decimal? Latitude { get; set; } + + public decimal? Longitude { get; set; } + + public DateTime UpdatedAtUtc { get; set; } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs new file mode 100644 index 0000000..311a626 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.Designer.cs @@ -0,0 +1,309 @@ +// +using System; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(BikeTrackingDbContext))] + [Migration("20260330202303_AddUserSettingsTable")] + partial class AddUserSettingsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("ConsecutiveWrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DelayUntilUtc") + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulAuthUtc") + .HasColumnType("TEXT"); + + b.Property("LastWrongAttemptUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("AuthAttemptStates", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Miles") + .HasColumnType("TEXT"); + + b.Property("RideDateTimeLocal") + .HasColumnType("TEXT"); + + b.Property("RideMinutes") + .HasColumnType("INTEGER"); + + b.Property("RiderId") + .HasColumnType("INTEGER"); + + b.Property("Temperature") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RiderId", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_Rides_RiderId_CreatedAtUtc_Desc"); + + b.ToTable("Rides", null, t => + { + t.HasCheckConstraint("CK_Rides_Miles_GreaterThanZero", "CAST(\"Miles\" AS REAL) > 0 AND CAST(\"Miles\" AS REAL) <= 200"); + + t.HasCheckConstraint("CK_Rides_RideMinutes_GreaterThanZero", "\"RideMinutes\" IS NULL OR \"RideMinutes\" > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => + { + b.Property("OutboxEventId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateId") + .HasColumnType("INTEGER"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EventPayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("NextAttemptUtc") + .HasColumnType("TEXT"); + + b.Property("OccurredAtUtc") + .HasColumnType("TEXT"); + + b.Property("PublishedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("OutboxEventId"); + + b.HasIndex("AggregateType", "AggregateId"); + + b.HasIndex("PublishedAtUtc", "NextAttemptUtc"); + + b.ToTable("OutboxEvents", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.Property("UserCredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("HashAlgorithm") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IterationCount") + .HasColumnType("INTEGER"); + + b.Property("PinHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PinSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("UserCredentialId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCredentials", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("AuthAttemptState") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.AuthAttemptStateEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.RideEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("RiderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") + .WithOne("Credential") + .HasForeignKey("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserEntity", b => + { + b.Navigation("AuthAttemptState"); + + b.Navigation("Credential"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs new file mode 100644 index 0000000..31a52a4 --- /dev/null +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260330202303_AddUserSettingsTable.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BikeTracking.Api.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserSettingsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSettings", + columns: table => new + { + UserId = table.Column(type: "INTEGER", nullable: false), + AverageCarMpg = table.Column(type: "TEXT", nullable: true), + YearlyGoalMiles = table.Column(type: "TEXT", nullable: true), + OilChangePrice = table.Column(type: "TEXT", nullable: true), + MileageRateCents = table.Column(type: "TEXT", nullable: true), + LocationLabel = table.Column(type: "TEXT", maxLength: 200, nullable: true), + Latitude = table.Column(type: "TEXT", nullable: true), + Longitude = table.Column(type: "TEXT", nullable: true), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSettings", x => x.UserId); + table.CheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + table.CheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + table.CheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + table.CheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + table.CheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + table.CheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + table.ForeignKey( + name: "FK_UserSettings_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSettings"); + } + } +} diff --git a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs index 673aa55..d0f4fc0 100644 --- a/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs +++ b/src/BikeTracking.Api/Infrastructure/Persistence/Migrations/BikeTrackingDbContextModelSnapshot.cs @@ -85,6 +85,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("AverageCarMpg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("LocationLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("MileageRateCents") + .HasColumnType("TEXT"); + + b.Property("OilChangePrice") + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("YearlyGoalMiles") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings", null, t => + { + t.HasCheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"); + + t.HasCheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"); + + t.HasCheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"); + + t.HasCheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"); + }); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.OutboxEventEntity", b => { b.Property("OutboxEventId") @@ -226,6 +274,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.Entities.UserSettingsEntity", b => + { + b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BikeTracking.Api.Infrastructure.Persistence.UserCredentialEntity", b => { b.HasOne("BikeTracking.Api.Infrastructure.Persistence.UserEntity", "User") diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 2a27407..b41f1b2 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder .Services.AddAuthentication(UserIdHeaderAuthenticationHandler.SchemeName) diff --git a/src/BikeTracking.Frontend/src/App.tsx b/src/BikeTracking.Frontend/src/App.tsx index 4823dbe..b540121 100644 --- a/src/BikeTracking.Frontend/src/App.tsx +++ b/src/BikeTracking.Frontend/src/App.tsx @@ -6,6 +6,7 @@ import { SignupPage } from './pages/signup/signup-page' import { MilesShellPage } from './pages/miles/miles-shell-page' import { RecordRidePage } from './pages/RecordRidePage' import { HistoryPage } from './pages/HistoryPage' +import { SettingsPage } from './pages/settings/SettingsPage' function App() { return ( @@ -19,6 +20,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx index 8179909..73f46d1 100644 --- a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx +++ b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' import { render, screen, waitFor } from '@testing-library/react' import { MilesShellPage } from './miles-shell-page' import * as ridesService from '../../services/ridesService' @@ -32,7 +33,11 @@ describe('MilesShellPage', () => { totalRows: 0, }) - render() + render( + + + + ) await waitFor(() => { expect(screen.getByText(/this year/i)).toBeInTheDocument() @@ -56,10 +61,45 @@ describe('MilesShellPage', () => { totalRows: 0, }) - render() + render( + + + + ) await waitFor(() => { expect(mockGetRideHistory).toHaveBeenCalledWith({ page: 1, pageSize: 1 }) }) }) + + it('renders a Settings navigation link in the placeholder region', async () => { + mockGetRideHistory.mockResolvedValue({ + summaries: { + thisMonth: { miles: 0, rideCount: 0, period: 'thisMonth' }, + thisYear: { miles: 0, rideCount: 0, period: 'thisYear' }, + allTime: { miles: 0, rideCount: 0, period: 'allTime' }, + }, + filteredTotal: { miles: 0, rideCount: 0, period: 'filtered' }, + rides: [], + page: 1, + pageSize: 1, + totalRows: 0, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute( + 'href', + '/settings' + ) + expect( + screen.getByLabelText(/miles content placeholder/i) + ).toBeInTheDocument() + }) + }) }) diff --git a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx index 10ccc6d..8a40c4b 100644 --- a/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx +++ b/src/BikeTracking.Frontend/src/pages/miles/miles-shell-page.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { MileageSummaryCard } from '../../components/mileage-summary-card/mileage-summary-card' import { useAuth } from '../../context/auth-context' import type { RideHistoryResponse } from '../../services/ridesService' @@ -52,6 +53,9 @@ export function MilesShellPage() {

Ride history and trends can be explored from the History page.

+

+ Manage your profile assumptions from Settings. +

) diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css new file mode 100644 index 0000000..8ad7247 --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.css @@ -0,0 +1,104 @@ +.settings-page { + max-width: 56rem; + margin: 2rem auto; + padding: 0 1rem; +} + +.settings-card { + border: 1px solid #d4dbe5; + border-radius: 0.75rem; + background: #fff; + box-shadow: 0 1px 3px rgb(15 23 42 / 8%); + padding: 1.25rem; + display: grid; + gap: 1rem; +} + +.settings-card h1 { + margin: 0; + color: #152238; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.settings-field { + display: grid; + gap: 0.35rem; +} + +.settings-field label { + font-weight: 600; + color: #24324a; +} + +.settings-field input { + border: 1px solid #b9c5d6; + border-radius: 0.5rem; + padding: 0.5rem 0.625rem; +} + +.settings-field input:focus { + outline: 2px solid #a8c5f3; + outline-offset: 1px; +} + +.settings-inline-actions { + display: grid; + gap: 0.4rem; +} + +.settings-secondary-action { + justify-self: start; + border: 1px solid #9fb3cd; + border-radius: 0.5rem; + background: #f6f9fc; + color: #17324d; + padding: 0.5rem 0.75rem; + font-weight: 600; + cursor: pointer; +} + +.settings-secondary-action:disabled { + opacity: 0.7; + cursor: default; +} + +.settings-hint { + color: #4b5b73; + font-size: 0.9rem; +} + +.settings-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.settings-save { + border: none; + border-radius: 0.5rem; + background: #2f6fdd; + color: #fff; + padding: 0.6rem 1rem; + font-weight: 600; + cursor: pointer; +} + +.settings-save:disabled { + opacity: 0.7; + cursor: default; +} + +.settings-error { + color: #b42318; + margin: 0; +} + +.settings-success { + color: #0b6b2e; + margin: 0; +} diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx new file mode 100644 index 0000000..397ff4b --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.test.tsx @@ -0,0 +1,368 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { BrowserRouter } from 'react-router-dom' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { SettingsPage } from './SettingsPage' +import * as usersApi from '../../services/users-api' + +vi.mock('../../services/users-api', () => ({ + getUserSettings: vi.fn(), + saveUserSettings: vi.fn(), +})) + +const mockGetUserSettings = vi.mocked(usersApi.getUserSettings) +const mockSaveUserSettings = vi.mocked(usersApi.saveUserSettings) + +function installGeolocationMock( + implementation: Geolocation['getCurrentPosition'] +): void { + Object.defineProperty(window.navigator, 'geolocation', { + configurable: true, + value: { + getCurrentPosition: implementation, + } satisfies Pick, + }) +} + +describe('SettingsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + installGeolocationMock((success) => { + success({ + coords: { + latitude: 41.881832, + longitude: -87.623177, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + toJSON: () => ({}), + }, + timestamp: Date.now(), + toJSON: () => ({}), + }) + }) + }) + + it('loads and displays saved numeric settings values', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: '2026-03-30T10:00:00Z', + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/average car mpg/i)).toHaveValue(31.5) + expect(screen.getByLabelText(/yearly goal/i)).toHaveValue(1800) + expect(screen.getByLabelText(/oil change price/i)).toHaveValue(89.99) + expect(screen.getByLabelText(/mileage rate/i)).toHaveValue(67.5) + }) + }) + + it('submits edited numeric settings via save action', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: null, + }, + }, + }) + + mockSaveUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: '2026-03-30T10:00:00Z', + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save settings/i })).toBeInTheDocument() + }) + + fireEvent.change(screen.getByLabelText(/average car mpg/i), { + target: { value: '31.5' }, + }) + fireEvent.change(screen.getByLabelText(/yearly goal/i), { + target: { value: '1800' }, + }) + fireEvent.change(screen.getByLabelText(/oil change price/i), { + target: { value: '89.99' }, + }) + fireEvent.change(screen.getByLabelText(/mileage rate/i), { + target: { value: '67.5' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /save settings/i })) + + await waitFor(() => { + expect(mockSaveUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + }) + ) + }) + }) + + it('loads and saves location picker values with coordinates', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: 'Downtown Office', + latitude: 42.3601, + longitude: -71.0589, + updatedAtUtc: '2026-03-30T10:00:00Z', + }, + }, + }) + + mockSaveUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: 'HQ Campus', + latitude: 41.9, + longitude: -87.6, + updatedAtUtc: '2026-03-30T10:10:00Z', + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/location/i)).toHaveValue('Downtown Office') + expect(screen.getByLabelText(/latitude/i)).toHaveValue(42.3601) + expect(screen.getByLabelText(/longitude/i)).toHaveValue(-71.0589) + }) + + fireEvent.change(screen.getByLabelText(/location/i), { + target: { value: 'HQ Campus' }, + }) + fireEvent.change(screen.getByLabelText(/latitude/i), { + target: { value: '41.9' }, + }) + fireEvent.change(screen.getByLabelText(/longitude/i), { + target: { value: '-87.6' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /save settings/i })) + + await waitFor(() => { + expect(mockSaveUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ + locationLabel: 'HQ Campus', + latitude: 41.9, + longitude: -87.6, + }) + ) + }) + }) + + it('submits only changed field values when updating a single setting', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 28, + yearlyGoalMiles: 1000, + oilChangePrice: 60, + mileageRateCents: 55, + locationLabel: 'Home', + latitude: 41.881, + longitude: -87.623, + updatedAtUtc: '2026-03-30T10:00:00Z', + }, + }, + }) + + mockSaveUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: true, + settings: { + averageCarMpg: 30, + yearlyGoalMiles: 1000, + oilChangePrice: 60, + mileageRateCents: 55, + locationLabel: 'Home', + latitude: 41.881, + longitude: -87.623, + updatedAtUtc: '2026-03-30T10:05:00Z', + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByLabelText(/average car mpg/i)).toHaveValue(28) + }) + + fireEvent.change(screen.getByLabelText(/average car mpg/i), { + target: { value: '30' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /save settings/i })) + + await waitFor(() => { + expect(mockSaveUserSettings).toHaveBeenCalledWith({ + averageCarMpg: 30, + }) + }) + }) + + it('fills latitude and longitude from the browser location action', async () => { + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /use browser location/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /use browser location/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/latitude/i)).toHaveValue(41.881832) + expect(screen.getByLabelText(/longitude/i)).toHaveValue(-87.623177) + }) + }) + + it('shows an error when browser location lookup fails', async () => { + installGeolocationMock((_success, error) => { + error?.({ + code: 1, + message: 'Permission denied', + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + }) + }) + + mockGetUserSettings.mockResolvedValue({ + ok: true, + status: 200, + data: { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: null, + }, + }, + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /use browser location/i })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /use browser location/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/unable to read browser location/i) + }) + }) +}) diff --git a/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..98600d0 --- /dev/null +++ b/src/BikeTracking.Frontend/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from 'react' +import { + getUserSettings, + saveUserSettings, + type UserSettingsResponse, + type UserSettingsUpsertRequest, +} from '../../services/users-api' +import './SettingsPage.css' + +interface SettingsFormSnapshot { + averageCarMpg: number | null + yearlyGoalMiles: number | null + oilChangePrice: number | null + mileageRateCents: number | null + locationLabel: string | null + latitude: number | null + longitude: number | null +} + +function toSnapshot(response: UserSettingsResponse): SettingsFormSnapshot { + return { + averageCarMpg: response.settings.averageCarMpg, + yearlyGoalMiles: response.settings.yearlyGoalMiles, + oilChangePrice: response.settings.oilChangePrice, + mileageRateCents: response.settings.mileageRateCents, + locationLabel: response.settings.locationLabel, + latitude: response.settings.latitude, + longitude: response.settings.longitude, + } +} + +function normalizeLocationLabel(value: string): string | null { + const normalized = value.trim() + return normalized === '' ? null : normalized +} + +export function SettingsPage() { + const [averageCarMpg, setAverageCarMpg] = useState('') + const [yearlyGoalMiles, setYearlyGoalMiles] = useState('') + const [oilChangePrice, setOilChangePrice] = useState('') + const [mileageRateCents, setMileageRateCents] = useState('') + const [locationLabel, setLocationLabel] = useState('') + const [latitude, setLatitude] = useState('') + const [longitude, setLongitude] = useState('') + const [locating, setLocating] = useState(false) + const [initialSnapshot, setInitialSnapshot] = useState({ + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + }) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + useEffect(() => { + let isMounted = true + + async function load(): Promise { + setError('') + try { + const response = await getUserSettings() + if (!isMounted) return + + if (response.ok && response.data) { + const settings = response.data.settings + setAverageCarMpg(settings.averageCarMpg ?? '') + setYearlyGoalMiles(settings.yearlyGoalMiles ?? '') + setOilChangePrice(settings.oilChangePrice ?? '') + setMileageRateCents(settings.mileageRateCents ?? '') + setLocationLabel(settings.locationLabel ?? '') + setLatitude(settings.latitude ?? '') + setLongitude(settings.longitude ?? '') + setInitialSnapshot(toSnapshot(response.data)) + } else { + setError(response.error?.message ?? 'Failed to load settings') + } + } catch { + if (isMounted) { + setError('Failed to load settings') + } + } finally { + if (isMounted) { + setLoading(false) + } + } + } + + void load() + + return () => { + isMounted = false + } + }, []) + + function onUseBrowserLocation(): void { + setError('') + setSuccess('') + + if (!window.navigator.geolocation) { + setError('Unable to read browser location on this device.') + return + } + + setLocating(true) + window.navigator.geolocation.getCurrentPosition( + (position) => { + setLatitude(Number(position.coords.latitude.toFixed(6))) + setLongitude(Number(position.coords.longitude.toFixed(6))) + setLocating(false) + setSuccess('Browser location loaded. Save settings to keep it.') + }, + () => { + setLocating(false) + setError('Unable to read browser location. Check browser permissions and try again.') + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + } + ) + } + + async function onSave(event: React.FormEvent): Promise { + event.preventDefault() + setError('') + setSuccess('') + setSaving(true) + + const currentSnapshot: SettingsFormSnapshot = { + averageCarMpg: averageCarMpg === '' ? null : averageCarMpg, + yearlyGoalMiles: yearlyGoalMiles === '' ? null : yearlyGoalMiles, + oilChangePrice: oilChangePrice === '' ? null : oilChangePrice, + mileageRateCents: mileageRateCents === '' ? null : mileageRateCents, + locationLabel: normalizeLocationLabel(locationLabel), + latitude: latitude === '' ? null : latitude, + longitude: longitude === '' ? null : longitude, + } + + const payload: UserSettingsUpsertRequest = {} + if (currentSnapshot.averageCarMpg !== initialSnapshot.averageCarMpg) + payload.averageCarMpg = currentSnapshot.averageCarMpg + if (currentSnapshot.yearlyGoalMiles !== initialSnapshot.yearlyGoalMiles) + payload.yearlyGoalMiles = currentSnapshot.yearlyGoalMiles + if (currentSnapshot.oilChangePrice !== initialSnapshot.oilChangePrice) + payload.oilChangePrice = currentSnapshot.oilChangePrice + if (currentSnapshot.mileageRateCents !== initialSnapshot.mileageRateCents) + payload.mileageRateCents = currentSnapshot.mileageRateCents + if (currentSnapshot.locationLabel !== initialSnapshot.locationLabel) + payload.locationLabel = currentSnapshot.locationLabel + if (currentSnapshot.latitude !== initialSnapshot.latitude) + payload.latitude = currentSnapshot.latitude + if (currentSnapshot.longitude !== initialSnapshot.longitude) + payload.longitude = currentSnapshot.longitude + + if (Object.keys(payload).length === 0) { + setSaving(false) + setSuccess('No changes to save.') + return + } + + try { + const response = await saveUserSettings(payload) + if (response.ok && response.data) { + const settings = response.data.settings + setAverageCarMpg(settings.averageCarMpg ?? '') + setYearlyGoalMiles(settings.yearlyGoalMiles ?? '') + setOilChangePrice(settings.oilChangePrice ?? '') + setMileageRateCents(settings.mileageRateCents ?? '') + setLocationLabel(settings.locationLabel ?? '') + setLatitude(settings.latitude ?? '') + setLongitude(settings.longitude ?? '') + setInitialSnapshot(toSnapshot(response.data)) + setSuccess('Settings saved successfully.') + } else { + setError(response.error?.message ?? 'Failed to save settings') + } + } catch { + setError('Failed to save settings') + } finally { + setSaving(false) + } + } + + if (loading) { + return
Loading settings...
+ } + + return ( +
+
+

Settings

+ + {error ? ( +

+ {error} +

+ ) : null} + {success ?

{success}

: null} + +
+
+
+ + + setAverageCarMpg(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ +
+ + + setYearlyGoalMiles(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ +
+ + + setOilChangePrice(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ +
+ + + setMileageRateCents(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ +
+ + setLocationLabel(e.target.value)} + /> +
+ + + Optionally fill latitude and longitude from this browser. + +
+
+ +
+ + + setLatitude(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ +
+ + + setLongitude(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/src/BikeTracking.Frontend/src/services/users-api.test.ts b/src/BikeTracking.Frontend/src/services/users-api.test.ts index 5c3437c..39a2f9c 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.test.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.test.ts @@ -1,131 +1,284 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { identifyUser, loginUser, signupUser } from './users-api' +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getUserSettings, + identifyUser, + loginUser, + saveUserSettings, + signupUser, +} from "./users-api"; -const fetchMock = vi.fn() -const url = 'http://localhost:5436/api'; +const fetchMock = vi.fn(); +const url = "http://localhost:5436/api"; -function jsonResponse(body: unknown, status: number, headers?: Record): Response { +function jsonResponse( + body: unknown, + status: number, + headers?: Record, +): Response { return new Response(JSON.stringify(body), { status, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...headers, }, - }) + }); } -describe('users-api transport', () => { +describe("users-api transport", () => { beforeEach(() => { - fetchMock.mockReset() - vi.stubGlobal('fetch', fetchMock) - }) + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + sessionStorage.clear(); + }); afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); - it('loginUser posts to identify endpoint with JSON body', async () => { + it("loginUser posts to identify endpoint with JSON body", async () => { fetchMock.mockResolvedValueOnce( - jsonResponse({ userId: 1, userName: 'Alice', authorized: true }, 200) - ) + jsonResponse({ userId: 1, userName: "Alice", authorized: true }, 200), + ); - const payload = { name: 'Alice', pin: '1234' } - const result = await loginUser(payload) + const payload = { name: "Alice", pin: "1234" }; + const result = await loginUser(payload); - expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( `${url}/users/identify`, expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), - }) - ) - expect(result.ok).toBe(true) - expect(result.status).toBe(200) - expect(result.data).toEqual({ userId: 1, userName: 'Alice', authorized: true }) - }) - - it('identifyUser and loginUser both target identify endpoint', async () => { + }), + ); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.data).toEqual({ + userId: 1, + userName: "Alice", + authorized: true, + }); + }); + + it("identifyUser and loginUser both target identify endpoint", async () => { fetchMock - .mockResolvedValueOnce(jsonResponse({ userId: 2, userName: 'Bob', authorized: true }, 200)) - .mockResolvedValueOnce(jsonResponse({ userId: 2, userName: 'Bob', authorized: true }, 200)) + .mockResolvedValueOnce( + jsonResponse({ userId: 2, userName: "Bob", authorized: true }, 200), + ) + .mockResolvedValueOnce( + jsonResponse({ userId: 2, userName: "Bob", authorized: true }, 200), + ); - await identifyUser({ name: 'Bob', pin: '5678' }) - await loginUser({ name: 'Bob', pin: '5678' }) + await identifyUser({ name: "Bob", pin: "5678" }); + await loginUser({ name: "Bob", pin: "5678" }); - expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[0][0]).toBe(`${url}/users/identify`) - expect(fetchMock.mock.calls[1][0]).toBe(`${url}/users/identify`) - }) + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe(`${url}/users/identify`); + expect(fetchMock.mock.calls[1][0]).toBe(`${url}/users/identify`); + }); - it('returns parsed error payload on non-success response', async () => { + it("returns parsed error payload on non-success response", async () => { fetchMock.mockResolvedValueOnce( jsonResponse( { - code: 'validation_failed', - message: 'Validation failed.', - details: ['Name is required.'], + code: "validation_failed", + message: "Validation failed.", + details: ["Name is required."], }, - 400 - ) - ) + 400, + ), + ); - const result = await loginUser({ name: '', pin: '1234' }) + const result = await loginUser({ name: "", pin: "1234" }); - expect(result.ok).toBe(false) - expect(result.status).toBe(400) + expect(result.ok).toBe(false); + expect(result.status).toBe(400); expect(result.error).toEqual({ - code: 'validation_failed', - message: 'Validation failed.', - details: ['Name is required.'], - }) - }) + code: "validation_failed", + message: "Validation failed.", + details: ["Name is required."], + }); + }); - it('returns retry-after header and throttle payload', async () => { + it("returns retry-after header and throttle payload", async () => { fetchMock.mockResolvedValueOnce( jsonResponse( { - code: 'throttled', - message: 'Too many attempts. Try again later.', + code: "throttled", + message: "Too many attempts. Try again later.", retryAfterSeconds: 5, }, 429, - { 'Retry-After': '5' } - ) - ) + { "Retry-After": "5" }, + ), + ); - const result = await loginUser({ name: 'Alice', pin: '0000' }) + const result = await loginUser({ name: "Alice", pin: "0000" }); - expect(result.ok).toBe(false) - expect(result.status).toBe(429) - expect(result.retryAfterSeconds).toBe(5) + expect(result.ok).toBe(false); + expect(result.status).toBe(429); + expect(result.retryAfterSeconds).toBe(5); expect(result.error).toEqual({ - code: 'throttled', - message: 'Too many attempts. Try again later.', + code: "throttled", + message: "Too many attempts. Try again later.", retryAfterSeconds: 5, - }) - }) + }); + }); - it('returns undefined error when response body is not JSON', async () => { + it("returns undefined error when response body is not JSON", async () => { fetchMock.mockResolvedValueOnce( - new Response('Service unavailable', { + new Response("Service unavailable", { status: 503, - headers: { 'Content-Type': 'text/plain' }, - }) - ) + headers: { "Content-Type": "text/plain" }, + }), + ); + + const result = await signupUser({ name: "Alice", pin: "1234" }); + + expect(result.ok).toBe(false); + expect(result.status).toBe(503); + expect(result.error).toBeUndefined(); + }); + + it("propagates fetch errors for caller-level handling", async () => { + fetchMock.mockRejectedValueOnce(new Error("network down")); + + await expect(loginUser({ name: "Alice", pin: "1234" })).rejects.toThrow( + "network down", + ); + }); + + it("getUserSettings sends GET request to the settings endpoint", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: "2026-03-30T10:00:00Z", + }, + }, + 200, + ), + ); + + const result = await getUserSettings(); + + expect(fetchMock).toHaveBeenCalledWith( + `${url}/users/me/settings`, + expect.objectContaining({ headers: {} }), + ); + expect(result.ok).toBe(true); + expect(result.data?.hasSettings).toBe(true); + }); - const result = await signupUser({ name: 'Alice', pin: '1234' }) + it("saveUserSettings sends PUT request with JSON body", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + hasSettings: true, + settings: { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: "2026-03-30T10:00:00Z", + }, + }, + 200, + ), + ); + + const payload = { + averageCarMpg: 31.5, + yearlyGoalMiles: 1800, + oilChangePrice: 89.99, + mileageRateCents: 67.5, + }; + + const result = await saveUserSettings(payload); + + expect(fetchMock).toHaveBeenCalledWith( + `${url}/users/me/settings`, + expect.objectContaining({ + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + expect(result.ok).toBe(true); + }); - expect(result.ok).toBe(false) - expect(result.status).toBe(503) - expect(result.error).toBeUndefined() - }) + it("settings requests include auth header when session user exists", async () => { + sessionStorage.setItem( + "bike_tracking_auth_session", + JSON.stringify({ userId: 42, userName: "Alice" }), + ); + + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + hasSettings: false, + settings: { + averageCarMpg: null, + yearlyGoalMiles: null, + oilChangePrice: null, + mileageRateCents: null, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: null, + }, + }, + 200, + ), + ) + .mockResolvedValueOnce( + jsonResponse( + { + hasSettings: true, + settings: { + averageCarMpg: 30, + yearlyGoalMiles: 1000, + oilChangePrice: 50, + mileageRateCents: 40, + locationLabel: null, + latitude: null, + longitude: null, + updatedAtUtc: "2026-03-30T10:00:00Z", + }, + }, + 200, + ), + ); - it('propagates fetch errors for caller-level handling', async () => { - fetchMock.mockRejectedValueOnce(new Error('network down')) + await getUserSettings(); + await saveUserSettings({ averageCarMpg: 30 }); - await expect(loginUser({ name: 'Alice', pin: '1234' })).rejects.toThrow('network down') - }) -}) + expect(fetchMock.mock.calls[0][1]).toEqual( + expect.objectContaining({ + headers: { "X-User-Id": "42" }, + }), + ); + expect(fetchMock.mock.calls[1][1]).toEqual( + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + "X-User-Id": "42", + }, + }), + ); + }); +}); diff --git a/src/BikeTracking.Frontend/src/services/users-api.ts b/src/BikeTracking.Frontend/src/services/users-api.ts index a85a9a0..ef9eca1 100644 --- a/src/BikeTracking.Frontend/src/services/users-api.ts +++ b/src/BikeTracking.Frontend/src/services/users-api.ts @@ -1,4 +1,9 @@ -const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, '') ?? 'http://localhost:5436'; +const API_BASE_URL = + (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( + /\/$/, + "", + ) ?? "http://localhost:5436"; +const SESSION_KEY = "bike_tracking_auth_session"; export interface SignupRequest { name: string; @@ -9,7 +14,7 @@ export interface SignupSuccessResponse { userId: number; userName: string; createdAtUtc: string; - eventStatus: 'queued' | 'published'; + eventStatus: "queued" | "published"; } export interface IdentifyRequest { @@ -23,6 +28,32 @@ export interface IdentifySuccessResponse { authorized: true; } +export interface UserSettingsUpsertRequest { + averageCarMpg?: number | null; + yearlyGoalMiles?: number | null; + oilChangePrice?: number | null; + mileageRateCents?: number | null; + locationLabel?: string | null; + latitude?: number | null; + longitude?: number | null; +} + +export interface UserSettingsView { + averageCarMpg: number | null; + yearlyGoalMiles: number | null; + oilChangePrice: number | null; + mileageRateCents: number | null; + locationLabel: string | null; + latitude: number | null; + longitude: number | null; + updatedAtUtc: string | null; +} + +export interface UserSettingsResponse { + hasSettings: boolean; + settings: UserSettingsView; +} + export interface ErrorResponse { code: string; message: string; @@ -30,7 +61,7 @@ export interface ErrorResponse { } export interface ThrottleResponse { - code: 'throttled'; + code: "throttled"; message: string; retryAfterSeconds: number; } @@ -43,17 +74,45 @@ export interface ApiResult { retryAfterSeconds?: number; } -async function postJson(path: string, payload: unknown): Promise> { +function getAuthHeaders(contentTypeJson: boolean): Record { + const headers: Record = {}; + if (contentTypeJson) { + headers["Content-Type"] = "application/json"; + } + + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) { + return headers; + } + + const parsed = JSON.parse(raw) as { userId?: number }; + if (typeof parsed.userId === "number" && parsed.userId > 0) { + headers["X-User-Id"] = parsed.userId.toString(); + } + } catch { + // Ignore malformed session payloads and continue unauthenticated. + } + + return headers; +} + +async function postJson( + path: string, + payload: unknown, +): Promise> { const response = await fetch(`${API_BASE_URL}${path}`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(payload), }); - const retryAfterHeader = response.headers.get('Retry-After'); - const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined; + const retryAfterHeader = response.headers.get("Retry-After"); + const retryAfterSeconds = retryAfterHeader + ? Number.parseInt(retryAfterHeader, 10) + : undefined; if (response.ok) { const data = (await response.json()) as TSuccess; @@ -80,14 +139,103 @@ async function postJson(path: string, payload: }; } -export function signupUser(payload: SignupRequest): Promise> { - return postJson('/api/users/signup', payload); +async function getJson( + path: string, +): Promise> { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: getAuthHeaders(false), + }); + + if (response.ok) { + const data = (await response.json()) as TSuccess; + return { + ok: true, + status: response.status, + data, + }; + } + + let parsedError: TError | undefined; + try { + parsedError = (await response.json()) as TError; + } catch { + parsedError = undefined; + } + + return { + ok: false, + status: response.status, + error: parsedError, + }; +} + +async function putJson( + path: string, + payload: unknown, +): Promise> { + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "PUT", + headers: getAuthHeaders(true), + body: JSON.stringify(payload), + }); + + if (response.ok) { + const data = (await response.json()) as TSuccess; + return { + ok: true, + status: response.status, + data, + }; + } + + let parsedError: TError | undefined; + try { + parsedError = (await response.json()) as TError; + } catch { + parsedError = undefined; + } + + return { + ok: false, + status: response.status, + error: parsedError, + }; +} + +export function signupUser( + payload: SignupRequest, +): Promise> { + return postJson("/api/users/signup", payload); +} + +export function identifyUser( + payload: IdentifyRequest, +): Promise< + ApiResult +> { + return postJson( + "/api/users/identify", + payload, + ); +} + +export function loginUser( + payload: IdentifyRequest, +): Promise< + ApiResult +> { + return postJson( + "/api/users/identify", + payload, + ); } -export function identifyUser(payload: IdentifyRequest): Promise> { - return postJson('/api/users/identify', payload); +export function getUserSettings(): Promise> { + return getJson("/api/users/me/settings"); } -export function loginUser(payload: IdentifyRequest): Promise> { - return postJson('/api/users/identify', payload); +export function saveUserSettings( + payload: UserSettingsUpsertRequest, +): Promise> { + return putJson("/api/users/me/settings", payload); } diff --git a/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts new file mode 100644 index 0000000..fa507df --- /dev/null +++ b/src/BikeTracking.Frontend/tests/e2e/settings.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { createAndLoginUser, uniqueUser } from "./support/auth-helpers"; + +const TEST_PIN = "87654321"; + +test.describe("009-settings e2e", () => { + test("keeps settings isolated between authenticated riders", async ({ + page, + }) => { + const riderOne = uniqueUser("e2e-settings-rider-1"); + const riderTwo = uniqueUser("e2e-settings-rider-2"); + + await createAndLoginUser(page, riderOne, TEST_PIN); + + await page.goto("/settings"); + await page.locator("#averageCarMpg").fill("29.5"); + await page.locator("#yearlyGoalMiles").fill("1234"); + await page.getByRole("button", { name: "Save Settings" }).click(); + await expect(page.getByText(/settings saved successfully/i)).toBeVisible(); + + await page.getByRole("button", { name: "Log out" }).click(); + await expect(page).toHaveURL("/login"); + + await createAndLoginUser(page, riderTwo, TEST_PIN); + + await page.goto("/settings"); + await expect(page.locator("#averageCarMpg")).toHaveValue(""); + await expect(page.locator("#yearlyGoalMiles")).toHaveValue(""); + }); +});