# MEP App-Readiness Sprint Guidelines (Sprint 17+)

## Single Source of Truth for Native App API Development

Last updated: 24 February 2026

---

## TABLE OF CONTENTS

1. [Purpose](#1-purpose)
2. [Contract Rules (All Sprints)](#2-contract-rules-all-sprints)
3. [Gap Analysis Summary](#3-gap-analysis-summary)
4. [Sprint Status Tracker](#4-sprint-status-tracker)
5. [Sprint 17 — Challenge Detail, Content, Destination, Utility APIs](#5-sprint-17)
6. [Sprint 18 — Challenge Join API (Free + Paid + Acceptance)](#6-sprint-18)
7. [Sprint 19 — Account and Profile APIs](#7-sprint-19)
8. [Sprint 20 — Store Product APIs](#8-sprint-20)
9. [Sprint 21 — Cart API (New DB Table)](#9-sprint-21)
10. [Sprint 22 — Order APIs (Checkout + History)](#10-sprint-22)
11. [Sprint 23 — Web Cart Migration + Ride Detail Enrichment](#11-sprint-23)
12. [Sprint 24 — Service Consolidation (Deduplication)](#12-sprint-24)
13. [Full API Contract Reference](#13-full-api-contract-reference)
14. [Native App Dependency Notes](#14-native-app-dependency-notes)
15. [Definition of Done (App Readiness)](#15-definition-of-done)

---

## 1. PURPOSE

This document governs all sprints from Sprint 17 onward. It defines the exact scope, API contracts, file requirements, acceptance criteria, and implementation order for making the MEP platform fully accessible to native mobile apps.

All sprints are additive. The existing web application must continue to function identically after each sprint. No web regression is acceptable.

This document overrides verbal discussion. If there is a conflict between this document and a conversation, this document wins.

---

## 2. CONTRACT RULES (ALL SPRINTS)

These rules apply to every sprint from 17 onward without exception.

### 2.1 API Contract Stability

- Existing API endpoints under /api/v1 must not change response structure or field names.
- New endpoints must define stable JSON contracts as specified in Section 13.
- No silent field removals or renames.

### 2.2 Auth Consistency

- Public endpoints (no JWT): challenge detail, content, destination, guide, countries, certificate verification.
- JWT required: all user-specific, payment, cart, order, ride, and account endpoints.

### 2.3 Idempotency Mandatory

- Duplicate join calls must never create duplicate rides. Return existing ride.
- Duplicate capture calls must never double-charge. Return existing payment.
- Duplicate order creation must not create multiple pending orders.

### 2.4 Absolute URL Requirement

All file paths in API responses must be absolute URLs using Config::baseUrl(). This applies to:

- challenge.logo_url (from logo_path)
- product.image_url (from image_path)
- variant.image_url (from image_path)
- option_value.image_url (from image_path)
- user.avatar_url (from avatar_path)
- media[].url (from file_path)
- certificate.download_url (from file_path)
- video.stream_url (from file_path)

Pattern: Services store relative paths. Controllers expand them to absolute URLs.

### 2.5 No Business Logic in Controllers

Controllers orchestrate and format responses only. All business rules remain in Services.

### 2.6 Additive First

Changes must be additive wherever possible. Modification of existing Services requires explicit file upload and approval per Controlled Edit Protocol.

### 2.7 Timestamp Format

All API JSON responses return timestamps in UTC. The ride timezone field is provided separately for display conversion. Native apps handle UTC-to-local conversion.

---

## 3. GAP ANALYSIS SUMMARY

### 3.1 Already Covered (34 Existing Endpoints)

| Feature | Endpoints | Status |
|---------|-----------|--------|
| Auth (register, login, refresh, logout, me) | 5 | COMPLETE |
| Motorcycles CRUD | 5 | COMPLETE |
| Challenge list (basic) | 1 | PARTIAL |
| Ride lifecycle (create, list, show, start, pause, resume, finish, cancel) | 9 | COMPLETE |
| GPS trackpoint batch upload with SHA-256 | 1 | COMPLETE |
| Media token request + upload | 2 | COMPLETE |
| Validation trigger + show | 2 | COMPLETE |
| Payment create + callback + status | 3 | COMPLETE |
| Certificate get | 1 | COMPLETE |
| Video upload + status + stream + data bundle | 4 | COMPLETE |
| Audio track list | 1 | COMPLETE |

### 3.2 Gaps to Fill (23 Must-Have, 3 Deferred)

| # | Feature | Sprint | Status |
|---|---------|--------|--------|
| G1 | Challenge detail (full) | 17 | DONE |
| G2 | Challenge content (overview, rules, guide) | 17 | DONE |
| G3 | Challenge destination data + GeoJSON | 17 | DONE |
| G4 | Challenge guide (rendered) | 17 | DONE |
| G5 | VAT estimate for challenge entry | 17 | DONE |
| G6 | Country list | 17 | DONE |
| G7 | Challenge join (free + paid + acceptance) | 18 | PENDING |
| G8 | User achievements (completed challenges) | 17 | DONE |
| G9 | Account profile view | 19 | PENDING |
| G10 | Account profile update | 19 | PENDING |
| G11 | Account password change | 19 | PENDING |
| G12 | Account avatar upload | 19 | PENDING |
| G13 | Store product list (with eligibility) | 20 | PENDING |
| G14 | Store product detail + variants | 20 | PENDING |
| G15 | Cart (add, remove, update, view) | 21 | PENDING |
| G16 | Cart shipping/VAT estimate | 21 | PENDING |
| G17 | Store checkout (create order + PayPal) | 22 | PENDING |
| G18 | Store order capture (PayPal return) | 22 | PENDING |
| G19 | Order history | 22 | PENDING |
| G20 | Order detail | 22 | PENDING |
| G21 | Certificate public verification | 17 | DONE |
| G22 | Ride detail enrichment | 23 | PENDING |
| G23 | Ride trackpoints for map | 23 | PENDING |
| G24 | Logo serving | DEFERRED | Existing web URLs work |
| G25 | Product image serving | DEFERRED | Existing web URLs work |
| G26 | Video public serving | DEFERRED | Existing web URLs work |

---

## 4. SPRINT STATUS TRACKER

| Sprint | Name | Endpoints | DB Changes | Status |
|--------|------|-----------|------------|--------|
| 17 | Challenge Detail + Utility APIs | 8 new | None | DONE |
| 18 | Challenge Join API | 2 new | None | PENDING |
| 19 | Account and Profile APIs | 5 new | None | PENDING |
| 20 | Store Product APIs | 2 new | None | PENDING |
| 21 | Cart API | 6 new | 1 new table (cart_items) | PENDING |
| 22 | Order APIs | 4 new | None | PENDING |
| 23 | Web Cart Migration + Ride Enrichment | 2 new + 1 enriched | None | PENDING |
| 24 | Service Consolidation | 0 new | None | PENDING |

Total new endpoints at completion: 30 (plus 1 enriched existing endpoint)
Total API endpoints at completion: 64+ (34 existing + 30 new)

---

## 5. SPRINT 17

### Status: DONE

### Objective

Add 8 API endpoints giving native apps access to challenge details, content, destinations, guides, VAT estimation, country lists, user achievements, and certificate verification.

### Endpoints Added

| Method | Route | Auth | Purpose |
|--------|-------|------|---------|
| GET | /api/v1/challenges/{id} | None | Full challenge detail with logo URL |
| GET | /api/v1/challenges/{id}/content | None | Overview, rules, guide, rules_version |
| GET | /api/v1/challenges/{id}/destination | None | Destination type, data, GeoJSON |
| GET | /api/v1/challenges/{id}/guide | None | Rendered guide (placeholders replaced) |
| POST | /api/v1/challenges/{id}/estimate-vat | JWT | VAT calculation for country_code |
| GET | /api/v1/countries | None | Full country list |
| GET | /api/v1/challenges/completed | JWT | User completed challenges with ride data |
| GET | /api/v1/certificates/{number}/verify | None | Public certificate verification (JSON) |

### Services Impacted (Read-Only)

ChallengeService, DestinationService, VatService, CountryService, CertificateService

### Database Changes

None.

### Files Modified

- src/Controllers/ChallengeController.php (full replacement, 7 new methods)
- src/Controllers/CertificateController.php (1 new method: verifyApi)
- src/Services/ChallengeService.php (added rs.finished_at to getUserCompletedChallenges SELECT)
- src/Routes/api.php (8 new route lines)

### Web Regression Risk

Zero. All additive.

### Route Ordering Note

/api/v1/challenges/completed MUST be registered BEFORE /api/v1/challenges/{id} to prevent "completed" being captured as a route parameter {id}.

---

## 6. SPRINT 18

### Status: PENDING

### Objective

Enable native apps to join challenges (free and paid) with rules acceptance, and complete paid joins via native PayPal SDK.

### Sprint Prompt

```
Implement Sprint 18: Challenge Join API (Free + Paid + Acceptance).

2 new API endpoints:

1. POST /api/v1/challenges/{id}/join (JWT)
   - Request: { motorcycle_id, country_code, accept_rules, accept_dispute }
   - Free challenge: validate acceptance if rules exist, update user country,
     record acceptance, create ride with status 'joined', send joined email.
     Return { payment_required: false, ride: {id, status, challenge_id, motorcycle_id} }
   - Paid challenge: validate acceptance if rules exist, calculate VAT,
     create PayPal order, store pending payment.
     Return { payment_required: true, paypal_order_id, approval_url }
   - Idempotency: if user already has a 'joined' ride for this challenge,
     return that existing ride. Never create duplicates.
   - Errors: 400 motorcycle not found/not owned. 400 invalid country_code.
     422 accept_rules/accept_dispute missing when rules exist. 404 challenge not found/inactive.

2. POST /api/v1/challenges/{id}/join/capture (JWT)
   - Request: { paypal_order_id }
   - Captures PayPal payment, creates ride with status 'joined',
     sends payment confirmation + joined emails.
     Return { ride: {id, status, challenge_id}, payment: {status, amount_eur, vat_eur} }
   - Idempotency: if already captured, return existing ride and payment. Never capture twice.
   - Errors: 400 PayPal order not found/already captured. 400 order mismatch.

Modification required (1):
PaymentService::createRideJoinPayment() must also return paypal_order_id in its
return array. Additive change — add one key to the existing return array. Web flow
ignores the extra field.

Database changes: None.
Web regression risk: Minimal. Only modification adds a field to an existing return array.
```

### Files Required

1. src/Services/PaymentService.php
2. src/Services/RideService.php
3. src/Services/AuthService.php
4. src/Controllers/WebChallengeController.php (reference for existing join flow)
5. src/Routes/api.php (latest with Sprint 17 applied)

### Acceptance Criteria

- POST /api/v1/challenges/{id}/join with valid motorcycle_id and country_code returns ride for free challenges.
- POST /api/v1/challenges/{id}/join returns paypal_order_id + approval_url for paid challenges.
- POST /api/v1/challenges/{id}/join returns 422 if acceptance missing when rules exist.
- POST /api/v1/challenges/{id}/join returns 400 for invalid motorcycle or country.
- POST /api/v1/challenges/{id}/join/capture captures payment, creates ride, returns both.
- POST /api/v1/challenges/{id}/join/capture returns 400 if already captured (idempotent).
- Duplicate join calls for same user+challenge return existing ride, never create duplicates.
- Existing web join flow works identically.
- Challenge acceptance recorded in challenge_acceptances table.
- User country_code updated on join.

---

## 7. SPRINT 19

### Status: PENDING

### Objective

Give native apps full profile management: view profile, update name/country, change password, upload/delete avatar.

### Sprint Prompt

```
Implement Sprint 19: Account and Profile APIs.

5 new API endpoints:

1. GET /api/v1/account (JWT)
   Return: { user: { id, name, email, country_code, avatar_url, created_at } }
   avatar_url is null if no avatar. Must be absolute URL if set.

2. PUT /api/v1/account/profile (JWT)
   Request: { name, country_code }
   Return: Same as GET /api/v1/account (updated profile).
   Errors: 400 if name empty or country_code invalid.

3. PUT /api/v1/account/password (JWT)
   Request: { current_password, new_password }
   Return: { success: true }
   Errors: 400 if current_password wrong. 400 if new_password too short.

4. POST /api/v1/account/avatar (JWT)
   Request: multipart/form-data with "avatar" file field.
   Return: { avatar_url: "https://..." }
   Errors: 400 if file too large or invalid type.

5. DELETE /api/v1/account/avatar (JWT)
   Return: { success: true }

Services impacted (read-only calls to existing methods):
AuthService (getUserById, updateUserCountry, changePassword, updateAvatar)

Modification required (1):
AuthService.php may need a new updateProfile() method that sets both name and
country_code in a single call. Verify if this exists. If not, add one new public
static method. This is additive.

Database changes: None.
Web regression risk: Zero. All new routes calling existing service methods.
```

### Files Required

1. src/Services/AuthService.php
2. src/Routes/api.php (latest)

### Acceptance Criteria

- GET /api/v1/account returns full profile with absolute avatar_url.
- PUT /api/v1/account/profile updates name and country_code.
- PUT /api/v1/account/password validates current password, sets new.
- POST /api/v1/account/avatar uploads and returns absolute URL.
- DELETE /api/v1/account/avatar removes avatar.
- All 5 endpoints require JWT.
- Existing web account page works identically.

---

## 8. SPRINT 20

### Status: PENDING

### Objective

Expose store products to native apps with per-user eligibility gating based on approved ride validations.

### Sprint Prompt

```
Implement Sprint 20: Store Product APIs.

2 new API endpoints:

1. GET /api/v1/store/products (JWT)
   Return: { products: [ { id, name, price_eur, weight_kg, image_url, status,
   challenge_id, eligible, has_variants } ] }
   eligible is false if user has no approved validation for the linked challenge.
   image_url must be absolute URL.

2. GET /api/v1/store/products/{id} (JWT)
   Return: { product: { id, name, description, price_eur, weight_kg, image_url,
   eligible, option_groups: [ { id, name, sort_order, values: [ { id, value,
   image_url, sort_order } ] } ], variants: [ { id, sku, price_eur, weight_kg,
   stock_qty, status, image_url, option_value_ids: [...] } ] } }
   Errors: 404 if not found.

Services impacted:
StoreService (listActiveProducts, getProduct, getProductOptionGroups,
getProductVariants), ValidationService (eligibility check)

Database changes: None.
Web regression risk: Zero. Additive routes only.
```

### Files Required

1. src/Services/StoreService.php
2. src/Services/ValidationService.php
3. src/Routes/api.php (latest)

### Acceptance Criteria

- GET /api/v1/store/products returns products with eligible boolean per product.
- GET /api/v1/store/products/{id} returns full product with option_groups and variants.
- All image URLs are absolute.
- Eligibility correctly reflects user's approved validations.
- Both endpoints require JWT.

---

## 9. SPRINT 21

### Status: PENDING

### Objective

Create server-side persistent cart for API clients. Web continues using session cart unchanged.

### Sprint Prompt

```
Implement Sprint 21: Cart API.

Database change:
New migration 048_create_cart_items.sql:
CREATE TABLE cart_items (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id INT UNSIGNED NOT NULL,
  product_id INT UNSIGNED NOT NULL,
  variant_id INT UNSIGNED DEFAULT NULL,
  qty INT UNSIGNED NOT NULL DEFAULT 1,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_cart_user_product_variant (user_id, product_id, variant_id),
  CONSTRAINT fk_cart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  CONSTRAINT fk_cart_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

New file: src/Services/CartService.php
Methods: getCart, addItem, updateItem, removeItem, clearCart, getCartTotals

6 new API endpoints:

1. GET /api/v1/cart (JWT)
   Return: { items: [...], item_count, subtotal_eur }

2. POST /api/v1/cart/items (JWT)
   Request: { product_id, variant_id, qty }
   Return: Same as GET /api/v1/cart.
   Validates stock and eligibility. Adding same product+variant increments qty.
   Errors: 400 product not found/ineligible. 400 variant not found/inactive. 400 insufficient stock.

3. PUT /api/v1/cart/items/{id} (JWT)
   Request: { qty }
   Return: Same as GET /api/v1/cart. qty=0 removes item.
   Errors: 404 cart item not found. 400 insufficient stock.

4. DELETE /api/v1/cart/items/{id} (JWT)
   Return: Same as GET /api/v1/cart.

5. DELETE /api/v1/cart (JWT)
   Return: { success: true, items: [], item_count: 0, subtotal_eur: 0 }

6. POST /api/v1/cart/estimate (JWT)
   Request: { country_code }
   Return: { subtotal_eur, shipping_eur, vat_rate, vat_eur, total_eur }
   Errors: 400 if cart empty. 400 if country_code invalid.

Services impacted:
StoreService (read-only: product/variant lookups), ShippingService (estimate),
VatService (calculate), ValidationService (eligibility check on add)

Web regression risk: Zero. New table and new service. Web continues using session cart.
```

### Files Required

1. src/Services/StoreService.php
2. src/Services/ShippingService.php
3. src/Services/VatService.php
4. src/Services/ValidationService.php
5. src/Routes/api.php (latest)

### Acceptance Criteria

- cart_items table created with unique constraint on (user_id, product_id, variant_id).
- getCart() returns items with product name, price, variant label, weight, stock.
- addItem() validates stock and eligibility, rejects ineligible.
- updateItem() with qty=0 removes item.
- removeItem() deletes row.
- clearCart() deletes all user rows.
- Adding same product+variant twice increments qty.
- Stock validation prevents over-ordering.
- Eligibility check prevents ineligible adds.
- Web store cart (session-based) completely unaffected.

---

## 10. SPRINT 22

### Status: PENDING

### Objective

Enable native apps to create store orders from DB cart, process PayPal payments, and view order history.

### Sprint Prompt

```
Implement Sprint 22: Order APIs (Checkout + History).

4 new API endpoints:

1. POST /api/v1/orders (JWT)
   Request: { country_code, billing_address: { name, street, city, postal_code,
   country_code, phone }, shipping_address: { ... } }
   Return: { order: { id, status, total_eur }, paypal_order_id, approval_url }
   Creates order from DB cart, initiates PayPal.
   Errors: 400 if cart empty. 400 if addresses invalid. 400 if items out of stock/ineligible.
   Idempotency: if pending order exists with same uncaptured PayPal order, return it.

2. POST /api/v1/orders/{id}/capture (JWT)
   Request: { paypal_order_id }
   Return: { order: { id, status, total_eur, subtotal_eur, shipping_eur, vat_eur, paid_at } }
   Captures payment, clears cart.
   Idempotency: if already paid, return existing order. Never double-charge.
   Errors: 400 if already captured. 400 if PayPal capture fails.

3. GET /api/v1/orders (JWT)
   Return: { orders: [ { id, status, total_eur, item_count, created_at, paid_at } ] }

4. GET /api/v1/orders/{id} (JWT)
   Return: { order: { id, status, subtotal_eur, shipping_eur, vat_rate, vat_eur,
   total_eur, created_at, paid_at, items: [...], billing_address: {...},
   shipping_address: {...} } }
   Errors: 404 if not found or not owned by user.

New method in StoreService: createOrderFromCart() that accepts cart data array
instead of reading from session.

Services impacted:
CartService, StoreService, ShippingService, VatService, PaymentService

Database changes: None (orders, order_items, order_addresses tables already exist).
Web regression risk: Zero. New API controller calls new service method. Existing web checkout unchanged.
```

### Files Required

1. src/Services/StoreService.php
2. src/Services/CartService.php (created in Sprint 21)
3. src/Services/PaymentService.php
4. src/Controllers/WebOrderController.php (reference)
5. src/Routes/api.php (latest)

### Acceptance Criteria

- POST /api/v1/orders creates order from DB cart, returns paypal_order_id.
- POST /api/v1/orders/{id}/capture captures payment, clears cart, returns order.
- GET /api/v1/orders returns user orders with items.
- GET /api/v1/orders/{id} returns order detail with items and addresses.
- Duplicate capture calls return success without double-charging.
- Web store checkout flow completely unaffected.

---

## 11. SPRINT 23

### Status: PENDING

### Objective

Part A: Unify web and API cart by migrating web store from session-based cart to CartService.
Part B: Enrich ride detail API and add trackpoints endpoint.

### Sprint Prompt

```
Implement Sprint 23: Web Cart Migration + Ride Detail Enrichment.

PART A: Web Cart Migration

Modify WebStoreController.php: addToCart(), viewCart(), updateCart(), removeFromCart()
to call CartService instead of manipulating $_SESSION['cart'].

Modify WebOrderController.php: create() method reads cart from CartService instead
of $_SESSION.

Reason: unified cart state between web and API.

PART B: Ride Detail Enrichment

Enrich existing GET /api/v1/rides/{id} to accept ?include=media,validation,certificate,video,extremes
query parameter. Returns nested objects when requested. Without include parameter,
response is identical to current (backward compatible).

New endpoint:
GET /api/v1/rides/{id}/trackpoints (JWT)
Return: { trackpoints: [ { latitude, longitude, altitude, speed, heading, accuracy,
recorded_at } ], count: N }

Modification required:
RideController.php show() method. Check for include parameter, conditionally load
and attach data. Additive logic only.

Database changes: None.
Web regression risk: Low for Part B. Medium for Part A — requires thorough testing
of web store add/remove/checkout flow after migration.
```

### Files Required

1. src/Controllers/WebStoreController.php
2. src/Controllers/WebOrderController.php
3. src/Controllers/RideController.php
4. src/Services/RideService.php
5. src/Services/MediaService.php
6. src/Services/ValidationService.php
7. src/Services/CertificateService.php
8. src/Services/VideoService.php
9. src/Services/TrackpointService.php
10. src/Services/CartService.php (created in Sprint 21)
11. src/Routes/api.php (latest)

### Acceptance Criteria

Part A:
- Web store add to cart, view cart, update qty, remove item, checkout all work identically.
- Cart persists across browser sessions for logged-in users.
- Session-based cart code removed from WebStoreController and WebOrderController.

Part B:
- ?include=media returns media array with absolute URLs.
- ?include=validation returns validation object.
- ?include=certificate returns cert number and download URL.
- ?include=video returns video status and stream URL.
- ?include=extremes returns max/min altitude and max speed with coordinates.
- Without include parameter, response is identical to current.
- GET /api/v1/rides/{id}/trackpoints returns full trackpoints array.

---

## 12. SPRINT 24

### Status: PENDING

### Objective

Reduce duplicated destination/content loading across controllers and views. Not blocking for app launch.

### Sprint Prompt

```
Implement Sprint 24: Service Consolidation (Deduplication).

New methods in ChallengeService (additive):
- getDestinationBundle(int $challengeId): returns destination_type, destination_data,
  geojson in one call. Returns null for standard challenges.
- getContentForEmail(int $challengeId): returns content + guideUrl + renderGuide
  in one call for email trigger locations.

New file: assets/destination-map.js
Shared module replacing inline destination map JavaScript in 5 view files:
guide page, join page, ride detail, recording map, admin challenge preview.

Modifications required (up to 8 files):
WebChallengeController, WebRideController, WebAppController (replace duplicated
destination loading), RideService, PaymentService (replace duplicated email content
loading), and 5 view files (replace inline JS with module calls).

Database changes: None.
Web regression risk: Medium. Multiple view files touched. Each must be tested.
```

### Files Required

To be determined at sprint start. Likely 8-12 files.

### Acceptance Criteria

- getDestinationBundle() returns complete destination data in one call.
- getContentForEmail() returns complete email content in one call.
- All 3 email locations use getContentForEmail().
- destination-map.js module draws destination zone on any Leaflet map.
- Guide, join, ride detail, recording, and admin preview all use the module.
- All destination maps render identically to before. No visual regression.

---

## 13. FULL API CONTRACT REFERENCE

### 13.1 Challenge APIs (Sprint 17) — DONE

**GET /api/v1/challenges/{id}**
Auth: None

Response:
```json
{
  "challenge": {
    "id": 1,
    "title": "Iron Butt 1600",
    "slug": "iron-butt-1600",
    "description": "Ride 1600km in 24 hours",
    "distance_km": 1600.00,
    "time_limit_hours": 24.00,
    "price_eur": 25.00,
    "status": "active",
    "challenge_type": "standard",
    "destination_type": null,
    "destination_name": null,
    "logo_url": "https://freebikers.dk/mep/logos/challenge_1_abc.png"
  }
}
```
Errors: 404 if not found or inactive.

---

**GET /api/v1/challenges/{id}/content**
Auth: None

Response:
```json
{
  "content": {
    "overview_text": "This challenge tests...",
    "rules_text": "1. You must ride...",
    "guide_text": "To complete the {challenge_name}...",
    "rules_version": "1.0"
  }
}
```
content is null if no content exists.
Errors: 404 if challenge not found.

---

**GET /api/v1/challenges/{id}/destination**
Auth: None

Response:
```json
{
  "destination_type": "country",
  "destination_name": "France",
  "destination_data": {"country_code": "FR"},
  "geojson": {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [...]}}
}
```
geojson is null for city and rectangle types. Entire response fields are null for standard challenges.
Errors: 404 if challenge not found.

---

**GET /api/v1/challenges/{id}/guide**
Auth: None

Response:
```json
{
  "challenge_title": "Iron Butt 1600",
  "rendered_guide": "To complete the Iron Butt 1600, you must ride 1,600 km within 24.0 hours...",
  "overview_text": "This challenge tests...",
  "rules_text": "1. You must ride...",
  "rules_version": "1.0"
}
```
rendered_guide has all placeholders replaced. null if no guide text.
Errors: 404 if challenge not found.

---

**POST /api/v1/challenges/{id}/estimate-vat**
Auth: JWT

Request:
```json
{"country_code": "DK"}
```

Response:
```json
{
  "entry_fee": 25.00,
  "vat_rate": 0.25,
  "vat_eur": 6.25,
  "total_eur": 31.25
}
```
Errors: 404 if challenge not found. 400 if country_code missing/invalid.

---

**GET /api/v1/countries**
Auth: None

Response:
```json
{"countries": {"AF": "Afghanistan", "AL": "Albania", "DK": "Denmark"}}
```

---

**GET /api/v1/challenges/completed**
Auth: JWT

Response:
```json
{
  "completed": [
    {
      "challenge_id": 1,
      "challenge_title": "Iron Butt 1600",
      "ride_session_id": 42,
      "distance_km": 1623.45,
      "elapsed_seconds": 67320,
      "started_at": "2026-02-15T06:00:00Z",
      "finished_at": "2026-02-15T14:42:00Z",
      "completed_at": "2026-02-16T10:00:00Z",
      "timezone": "Europe/Copenhagen",
      "certificate_number": "MEP-1600K-2026-000042",
      "logo_url": "https://freebikers.dk/mep/logos/challenge_1_abc.png"
    }
  ]
}
```

---

### 13.2 Certificate Verification (Sprint 17) — DONE

**GET /api/v1/certificates/{number}/verify**
Auth: None

Response (valid):
```json
{
  "valid": true,
  "certificate_number": "MEP-1600K-2026-000123",
  "rider_name": "Rajinder",
  "challenge_title": "Iron Butt 1600",
  "distance_km": 1623.45,
  "duration_display": "18 hours 42 minutes",
  "completed_at": "2026-02-15T14:42:00Z",
  "timezone": "Europe/Copenhagen"
}
```

Response (invalid):
```json
{"valid": false}
```

---

### 13.3 Challenge Join (Sprint 18)

**POST /api/v1/challenges/{id}/join**
Auth: JWT

Request:
```json
{
  "motorcycle_id": 5,
  "country_code": "DK",
  "accept_rules": true,
  "accept_dispute": true
}
```

Response (free):
```json
{
  "payment_required": false,
  "ride": {"id": 42, "status": "joined", "challenge_id": 1, "motorcycle_id": 5}
}
```

Response (paid):
```json
{
  "payment_required": true,
  "paypal_order_id": "8AB123456XY",
  "approval_url": "https://www.sandbox.paypal.com/checkoutnow?token=8AB123456XY"
}
```

Errors: 400, 404, 422 as described in Sprint 18 prompt.
Idempotency: existing 'joined' ride returned, no duplicates.

---

**POST /api/v1/challenges/{id}/join/capture**
Auth: JWT

Request:
```json
{"paypal_order_id": "8AB123456XY"}
```

Response:
```json
{
  "ride": {"id": 42, "status": "joined", "challenge_id": 1},
  "payment": {"status": "paid", "amount_eur": 31.25, "vat_eur": 6.25}
}
```

Idempotency: already captured returns existing data.

---

### 13.4 Account APIs (Sprint 19)

**GET /api/v1/account**
Auth: JWT

Response:
```json
{
  "user": {
    "id": 7,
    "name": "Rajinder",
    "email": "rajinder@example.com",
    "country_code": "DK",
    "avatar_url": "https://freebikers.dk/mep/api/v1/account/avatar/user_7_1708123456.jpg",
    "created_at": "2026-01-15T10:00:00Z"
  }
}
```

---

**PUT /api/v1/account/profile**
Auth: JWT

Request:
```json
{"name": "Rajinder Singh", "country_code": "DK"}
```

Response: Same as GET /api/v1/account.

---

**PUT /api/v1/account/password**
Auth: JWT

Request:
```json
{"current_password": "oldpass", "new_password": "newpass123"}
```

Response:
```json
{"success": true}
```

---

**POST /api/v1/account/avatar**
Auth: JWT
Request: multipart/form-data with "avatar" file field

Response:
```json
{"avatar_url": "https://freebikers.dk/mep/api/v1/account/avatar/user_7_1708123456.jpg"}
```

---

**DELETE /api/v1/account/avatar**
Auth: JWT

Response:
```json
{"success": true}
```

---

### 13.5 Store APIs (Sprint 20)

**GET /api/v1/store/products**
Auth: JWT

Response:
```json
{
  "products": [
    {
      "id": 3,
      "name": "1600K Challenge Badge",
      "price_eur": 12.50,
      "weight_kg": 0.05,
      "image_url": "https://freebikers.dk/mep/storage/products/badge_1600k.jpg",
      "status": "active",
      "challenge_id": 1,
      "eligible": true,
      "has_variants": true
    }
  ]
}
```

---

**GET /api/v1/store/products/{id}**
Auth: JWT

Response:
```json
{
  "product": {
    "id": 3,
    "name": "1600K Challenge Badge",
    "description": "Official completion badge...",
    "price_eur": 12.50,
    "weight_kg": 0.05,
    "image_url": "https://freebikers.dk/mep/storage/products/badge_1600k.jpg",
    "eligible": true,
    "option_groups": [
      {
        "id": 1,
        "name": "Color",
        "sort_order": 0,
        "values": [
          {"id": 1, "value": "Gold", "image_url": null, "sort_order": 0},
          {"id": 2, "value": "Silver", "image_url": null, "sort_order": 1}
        ]
      }
    ],
    "variants": [
      {
        "id": 10,
        "sku": "BADGE-GOLD",
        "price_eur": null,
        "weight_kg": null,
        "stock_qty": 50,
        "status": "active",
        "image_url": null,
        "option_value_ids": [1]
      }
    ]
  }
}
```

---

### 13.6 Cart APIs (Sprint 21)

**GET /api/v1/cart**
Auth: JWT

Response:
```json
{
  "items": [
    {
      "id": 1,
      "product_id": 3,
      "product_name": "1600K Challenge Badge",
      "variant_id": 10,
      "variant_label": "Gold",
      "price_eur": 12.50,
      "weight_kg": 0.05,
      "qty": 2,
      "stock_qty": 50,
      "image_url": "https://freebikers.dk/mep/storage/products/badge_1600k.jpg"
    }
  ],
  "item_count": 2,
  "subtotal_eur": 25.00
}
```

---

**POST /api/v1/cart/items**
Auth: JWT

Request:
```json
{"product_id": 3, "variant_id": 10, "qty": 1}
```

Response: Same as GET /api/v1/cart.

---

**PUT /api/v1/cart/items/{id}**
Auth: JWT

Request:
```json
{"qty": 3}
```

Response: Same as GET /api/v1/cart. qty=0 removes item.

---

**DELETE /api/v1/cart/items/{id}**
Auth: JWT

Response: Same as GET /api/v1/cart.

---

**DELETE /api/v1/cart**
Auth: JWT

Response:
```json
{"success": true, "items": [], "item_count": 0, "subtotal_eur": 0}
```

---

**POST /api/v1/cart/estimate**
Auth: JWT

Request:
```json
{"country_code": "DK"}
```

Response:
```json
{
  "subtotal_eur": 25.00,
  "shipping_eur": 5.50,
  "vat_rate": 0.25,
  "vat_eur": 7.63,
  "total_eur": 38.13
}
```

---

### 13.7 Order APIs (Sprint 22)

**POST /api/v1/orders**
Auth: JWT

Request:
```json
{
  "country_code": "DK",
  "billing_address": {"name": "Rajinder", "street": "Vestergade 1", "city": "Roskilde", "postal_code": "4000", "country_code": "DK", "phone": "+4512345678"},
  "shipping_address": {"name": "Rajinder", "street": "Vestergade 1", "city": "Roskilde", "postal_code": "4000", "country_code": "DK", "phone": "+4512345678"}
}
```

Response:
```json
{
  "order": {"id": 15, "status": "pending", "total_eur": 38.13},
  "paypal_order_id": "9CD456789AB",
  "approval_url": "https://www.sandbox.paypal.com/checkoutnow?token=9CD456789AB"
}
```

---

**POST /api/v1/orders/{id}/capture**
Auth: JWT

Request:
```json
{"paypal_order_id": "9CD456789AB"}
```

Response:
```json
{
  "order": {
    "id": 15,
    "status": "paid",
    "total_eur": 38.13,
    "subtotal_eur": 25.00,
    "shipping_eur": 5.50,
    "vat_eur": 7.63,
    "paid_at": "2026-02-20T14:30:00Z"
  }
}
```

---

**GET /api/v1/orders**
Auth: JWT

Response:
```json
{
  "orders": [
    {"id": 15, "status": "paid", "total_eur": 38.13, "item_count": 2, "created_at": "2026-02-20T14:25:00Z", "paid_at": "2026-02-20T14:30:00Z"}
  ]
}
```

---

**GET /api/v1/orders/{id}**
Auth: JWT

Response:
```json
{
  "order": {
    "id": 15,
    "status": "paid",
    "subtotal_eur": 25.00,
    "shipping_eur": 5.50,
    "vat_rate": 0.25,
    "vat_eur": 7.63,
    "total_eur": 38.13,
    "created_at": "2026-02-20T14:25:00Z",
    "paid_at": "2026-02-20T14:30:00Z",
    "items": [
      {"product_name": "1600K Challenge Badge", "variant_label": "Gold", "price_eur": 12.50, "qty": 2, "weight_kg": 0.05}
    ],
    "billing_address": {"name": "Rajinder", "street": "Vestergade 1", "city": "Roskilde", "postal_code": "4000", "country_code": "DK"},
    "shipping_address": {"name": "Rajinder", "street": "Vestergade 1", "city": "Roskilde", "postal_code": "4000", "country_code": "DK"}
  }
}
```

---

### 13.8 Ride Detail Enrichment (Sprint 23)

**GET /api/v1/rides/{id}?include=media,validation,certificate,video,extremes**
Auth: JWT

Additional response fields when include parameters present:
```json
{
  "ride": {"...existing fields..."},
  "media": [
    {"id": 1, "media_type": "start_photo", "url": "https://...", "captured_at": "2026-02-15T06:01:00Z"}
  ],
  "validation": {"status": "approved", "trust_score": 95, "flags_json": "[]"},
  "certificate": {"certificate_number": "MEP-1600K-2026-000123", "download_url": "https://..."},
  "video": {"status": "ready", "stream_url": "https://...", "duration_seconds": 60.5},
  "extremes": {
    "max_altitude": {"m": 1234.5, "lat": 47.1234, "lng": 10.5678},
    "min_altitude": {"m": 12.3, "lat": 55.6789, "lng": 12.3456},
    "max_speed": {"kmh": 156.78, "lat": 52.1234, "lng": 11.5678}
  }
}
```

---

**GET /api/v1/rides/{id}/trackpoints**
Auth: JWT

Response:
```json
{
  "trackpoints": [
    {"latitude": 55.6789, "longitude": 12.3456, "altitude": 45.2, "speed": 85.3, "heading": 180.5, "accuracy": 8.2, "recorded_at": "2026-02-15T06:05:00Z"}
  ],
  "count": 4500
}
```

---

## 14. NATIVE APP DEPENDENCY NOTES

### 14.1 Trackpoint Hashing Compatibility

The SHA-256 hash verification is critical for anti-cheat. The native app must produce the identical hash the server expects.

Algorithm:
1. Take array of point objects.
2. Each point has keys in order: latitude, longitude, altitude, speed, heading, accuracy, recorded_at.
3. JSON.stringify the array.
4. SHA-256 hash the resulting string.

Compatibility concerns:
- Number precision: JavaScript and native languages may serialize differently.
- Null handling: JavaScript null vs native null serialization.
- Key ordering: JSON.stringify preserves insertion order.

Recommendation: Create test vector endpoint GET /api/v1/trackpoints/test-vectors (no auth, Sprint 18+) with known inputs and expected hashes computed by server-side code.

### 14.2 PayPal SDK Client ID

Native apps need the PayPal Client ID. Expose via:

GET /api/v1/config/payment (no auth)
```json
{
  "paypal_client_id": "AaBbCcDd...",
  "paypal_environment": "sandbox",
  "currency": "EUR"
}
```

This can be added alongside Sprint 18 (join/capture endpoints).

### 14.3 Media Upload Content-Type

POST /api/v1/media/upload accepts multipart/form-data with fields: token, photo (file). Native cameras produce JPEG. Already compatible.

### 14.4 JWT Token Storage

- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Keystore

Refresh token rotation: POST /api/v1/auth/refresh with refresh_token in body.

### 14.5 Timezone Handling

All API responses return UTC timestamps. The ride timezone field is included in ride data. Native apps convert UTC to local display using this timezone.

---

## 15. DEFINITION OF DONE (APP READINESS)

All of the following must be true after Sprint 23:

- [ ] 30 new API endpoints deployed and returning correct JSON
- [ ] All 34 existing API endpoints still pass (regression test)
- [ ] cart_items table created and populated correctly
- [ ] Web store cart migrated to CartService with no behavior change
- [ ] Free challenge join via API creates ride and returns it
- [ ] Paid challenge join via API returns paypal_order_id
- [ ] Paid challenge capture via API creates ride after payment
- [ ] Rules acceptance recorded in challenge_acceptances via API join
- [ ] Store product eligibility correctly gated per user validation status
- [ ] Cart add/update/remove/clear all work via API
- [ ] Store order creation via API produces PayPal order
- [ ] Store order capture via API completes order and clears cart
- [ ] Order history and detail return complete data via API
- [ ] Certificate verification returns JSON with valid/invalid status
- [ ] Ride detail with include parameter returns all nested data
- [ ] Trackpoints endpoint returns full route data
- [ ] All media URLs in API responses are absolute URLs (not relative paths)
- [ ] All logo URLs in API responses are absolute URLs
- [ ] All timestamp fields in API responses are UTC ISO 8601 format
- [ ] PayPal idempotency: duplicate capture calls return success without double-charging
- [ ] Join idempotency: duplicate join calls for same user+challenge return existing ride

---

## END OF DOCUMENT
