◀ Back to API Contract README | Next: 04.2 Data Models ▶


Table of Contents

  1. API Overview
  2. Authentication & Authorization
  3. Authentication APIs
  4. User APIs
  5. Training/Course APIs
  6. Session APIs
  7. Meta APIs ⭐ NEW
  8. Common Error Responses
  9. Implementation Notes

API Overview

Base URL

Production:  https://api.solidfocus.com/v1
Staging:     https://api-staging.solidfocus.com/v1
Development: http://localhost:8000/v1

Note: Base URL is assumed - not provided by client. Confirm with SolidFocus RD team.

Architecture (v3.0 - From Client Spec)

Offline-First Design: - APP is the source of truth for workout data - Server acts as archive and sync coordinator ("數據歸檔中心") - APP calculates all metrics locally (calories, distance, XP) - Batch upload strategy for session synchronization - Store-and-Forward Model: APP writes to local SQLite first, syncs when online

Key Principles (from client spec):

  • 離線優先 (Offline First): APP is first data storage point (Source of Truth)
  • 異質數據標準化: Use PostgreSQL JSONB for heterogeneous device data (bike, rower, treadmill)
  • 儲存後轉發 (Store-and-Forward): APP always writes to local DB first, batch syncs later

Assumptions Made by Fugu (Not in Client Spec)

⚠️ The following are NOT defined by client - need discussion:

  • Content-Type format (assume JSON)
  • Date/Time format (assume ISO 8601 UTC)
  • Request/Response body structures
  • Error response formats
  • HTTP status codes
  • Authentication header format

Status: These should be discussed and agreed upon with SolidFocus RD team.


Authentication & Authorization

⚠️ Note: Client spec does not define authentication details. The following are assumptions made by Fugu:

Assumed Authentication Method

  • JWT (JSON Web Token) based authentication
  • Bearer token scheme (assumed)
  • Access token + Refresh token pattern (assumed)

Status: Need to confirm with SolidFocus RD team:

  • Token format and claims
  • Token expiration times
  • Authorization header format
  • Token invalidation mechanism

Authentication APIs

From Client Spec (Section 4.1):

The client defined the following authentication endpoints with descriptions only. No request/response formats were provided.

Method Endpoint Client Description
POST /auth/register 帳號註冊 (僅限 Email/Password)
POST /auth/login 帳號登入 (回傳 JWT)
POST /auth/password/request-reset 忘記密碼 (寄送驗證碼/信件)
POST /auth/password/reset 重設密碼 (帶入驗證碼)
POST /auth/sso/{provider} SSO 登入 (provider = apple/google/facebook)
POST /auth/logout 登出 (撤銷 Refresh Token)
POST /auth/refresh 刷新 Access Token

⚠️ IMPORTANT: Client spec only provides endpoint paths and descriptions. All request/response formats, status codes, and error responses below are Fugu's design proposals and need to be confirmed with SolidFocus RD team.


Proposed API Details (Fugu's Design - Needs Client Approval)

Notes:

  • The detailed request/response formats below are NOT provided by client
  • These are Fugu's proposed designs based on common REST API practices
  • Must be reviewed and approved by SolidFocus RD team before implementation
  • Client may have different preferences for data structures, error formats, etc.

Discussion Needed:

  1. Request/response body structures
  2. Error response format
  3. HTTP status codes to use
  4. Field naming conventions
  5. Token expiration times
  6. Password requirements

For detailed proposals, see internal design document: docs/api-proposals/auth-endpoints.md (to be created after client discussion)


User APIs

From Client Spec (Section 4.2):

The client defined the following user endpoints with descriptions only. No request/response formats were provided.

Method Endpoint Client Description
GET /users/me 取得個人資料 (含 UserStat)
PATCH /users/me 更新身高、體重、暱稱、性別
PATCH /users/me/stats 同步累計數據 (強制以 APP 端的累計里程/XP/等級覆蓋伺服器)
POST /users/me/avatar 上傳大頭貼

⚠️ Note: Request/response details need to be designed and agreed upon with SolidFocus RD team.


Training/Course APIs

From Client Spec (Section 4.3):

The client defined the following training endpoints:

Method Endpoint Client Description
GET /trainings 取得課程列表 (支援設備篩選、分頁)
GET /trainings/{id} 取得單一課程詳細設定

Known Details from Client Spec:

  • Supports filtering by device_type parameter
  • Returns course metadata including video URLs
  • Uses JSONB for flexible settings and detail fields (see database schema)

Session APIs

From Client Spec (Section 4.4):

The client defined the following session endpoints:

Method Endpoint Client Description
POST /sessions/batch_upload 批次同步 離線生成的運動紀錄
POST /sessions 單筆運動紀錄上傳
GET /sessions 取得歷史運動列表
GET /sessions/{id} 取得運動詳情 (含曲線圖數據)
DELETE /sessions/{id} 刪除運動紀錄

Known Architecture from Client Spec:

  1. Idempotency Design:

  2. APP generates client_session_uuid (UUIDv4)

  3. Server checks if client_session_uuid exists before creating
  4. Duplicate uploads return success (idempotent behavior)

  5. Batch Upload Flow (from client sequence diagram):

    APP → POST /sessions/batch_upload
         Header: X-Idempotency-Key (mentioned in spec)
    
    Server checks client_session_uuid
    ├─ Already exists → 200 OK (ignore write)
    └─ New record → 201 Created → Update UserStats

  6. Offline-First Pattern:

  7. APP stores sessions locally with Status: PENDING

  8. Batch uploads when network available
  9. Marks as Status: SYNCED after successful upload

Database Schema (from client WorkoutSession model):

  • Uses PostgreSQL JSONB for device-agnostic metrics storage
  • Fields: client_session_id, device_type, source_type, metrics_summary, time_series_data
  • See 04.2 Data Models for complete schema

Meta APIs

From Client Spec (Section 4.5):

The client defined the following meta endpoints:

Method Endpoint Client Description
GET /meta/schemas 取得各器材 (Rower/Bike) 的數據欄位定義 (供 VUE 後台動態渲染)
GET /meta/app_version 檢查 APP 是否需要強制更新

Purpose (from client spec):

  • /meta/schemas: Provides device-specific field definitions for admin panel dynamic rendering
  • /meta/app_version: Version checking for forced app updates

Summary of What Client Provided

Client Defined:

  1. Endpoint paths and HTTP methods
  2. High-level descriptions (Chinese)
  3. Database schema (SQLModel classes with field types)
  4. Architecture concepts (Offline-First, Store-and-Forward, JSONB usage)
  5. Sequence diagram showing sync flow

Client Did NOT Define:

  1. Request body structures
  2. Response body structures
  3. HTTP status codes (200, 201, 400, 403, etc.)
  4. Error response formats
  5. Error codes (e.g., validation_error, duplicate_session)
  6. Header requirements (except mention of X-Idempotency-Key)
  7. Pagination details
  8. Query parameter formats

Next Steps

Before Implementation:

  1. Schedule API contract discussion with SolidFocus RD team
  2. Present Fugu's proposed request/response formats
  3. Agree on error response structure
  4. Agree on HTTP status code conventions
  5. Document final agreed-upon API contract

During Discussion:

  • Show client the database schema they provided
  • Propose request/response formats matching their schema
  • Discuss error handling preferences
  • Agree on field naming conventions
  • Confirm authentication mechanism details

OLD CONTENT BELOW (Fugu's Design Proposals) - TO BE DISCUSSED WITH CLIENT


Click to expand: Fugu's Detailed API Proposals (NOT approved by client) ### 1. Get Current User Profile (Fugu's Proposal) **Endpoint**: `GET /users/me` **Description**: Get current authenticated user's profile and statistics. **Request Headers**:
Authorization: Bearer <access_token>
**Success Response** (200 OK):
{
  "user": {
    "id": 12345,
    "user_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "email": "user@example.com",
    "display_name": "John Doe",
    "avatar_url": "https://cdn.solidfocus.com/avatars/user-12345.jpg",
    "gender": "m"
  },
  "stats": {
    "workout_streak_days": 7,
    "total_duration_seconds": 14400,
    "total_calories": 2500.5,
    "total_distance_km": 85.3,
    "total_xp": 5000,
    "current_level": 5,
    "last_workout_date": "2025-12-23T10:30:00Z"
  }
}
**Response Fields**: | Field | Type | Description | |-------|------|-------------| | user.id | integer | User database ID | | user.user_uuid | string | User UUID (globally unique) | | user.email | string | User email | | user.display_name | string | Public display name | | user.avatar_url | string | Avatar image URL (null if not set) | | user.gender | string | "m", "f", "o", or null | | stats.workout_streak_days | integer | Current workout streak (days) | | stats.total_duration_seconds | integer | Total workout duration (seconds) | | stats.total_calories | float | Total energy burned (kCal) | | stats.total_distance_km | float | Total distance (kilometers) | | stats.total_xp | integer | Total experience points | | stats.current_level | integer | User current level | | stats.last_workout_date | string | Last workout date (ISO 8601, null if never) | **Error Responses**: 401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
--- ### 2. Update User Profile ✅ **Status**: Confirmed **Endpoint**: `PATCH /users/me` **Description**: Update current user's profile information. **Request Headers**:
Authorization: Bearer <access_token>
Content-Type: application/json
**Request Body** (all fields optional):
{
  "display_name": "Johnny Doe",
  "gender": "m"
}
**Success Response** (200 OK):
{
  "user": {
    "id": 12345,
    "user_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "email": "user@example.com",
    "display_name": "Johnny Doe",
    "avatar_url": "https://cdn.solidfocus.com/avatars/user-12345.jpg",
    "gender": "m"
  },
  "message": "Profile updated successfully"
}
**Error Responses**: 400 Bad Request:
{
  "error": "validation_error",
  "message": "Display name cannot be empty",
  "details": {
    "field": "display_name"
  }
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Only non-null fields in request body will be updated - Email cannot be changed via this endpoint (security) - Display name must be 1-50 characters --- ### 3. Update User Stats (Force Sync) ✅ **Status**: Confirmed - APP wins in conflict resolution **Endpoint**: `PATCH /users/me/stats` **Description**: Force update user statistics from APP local database. APP is source of truth in v3.0. **Request Headers**:
Authorization: Bearer <access_token>
Content-Type: application/json
**Request Body** (all fields optional):
{
  "workout_streak_days": 10,
  "total_duration_seconds": 18000,
  "total_calories": 3200.5,
  "total_distance_km": 120.5,
  "total_xp": 8000,
  "current_level": 6,
  "last_workout_date": "2025-12-23T10:30:00Z"
}
**Success Response** (200 OK):
{
  "stats": {
    "workout_streak_days": 10,
    "total_duration_seconds": 18000,
    "total_calories": 3200.5,
    "total_distance_km": 120.5,
    "total_xp": 8000,
    "current_level": 6,
    "last_workout_date": "2025-12-23T10:30:00Z"
  },
  "message": "Stats force-synced successfully"
}
**Error Responses**: 400 Bad Request:
{
  "error": "validation_error",
  "message": "Invalid stat values",
  "details": {
    "field": "total_duration_seconds",
    "message": "Must be non-negative"
  }
}
**Implementation Notes**: - **APP wins** - Server always accepts APP values without question - Used for resolving sync conflicts after offline sessions - Server overwrites its stats with APP-provided values - Only provided fields are updated (partial update supported) - Validation only checks data types and non-negative values - All overrides are logged for debugging and analytics --- ### 4. Upload Avatar ✅ **Status**: Confirmed - NEW in v3.0 **Endpoint**: `POST /users/me/avatar` **Description**: Upload user avatar image. **Request Headers**:
Authorization: Bearer <access_token>
Content-Type: multipart/form-data
**Request Body** (multipart/form-data):
avatar: <image file>
**Form Fields**: | Field | Type | Required | Description | |-------|------|----------|-------------| | avatar | file | Yes | Image file (JPEG, PNG, WebP) | **File Requirements**: - Max file size: 5 MB - Supported formats: JPEG, PNG, WebP - Recommended dimensions: 512x512 or larger (square) - Server will auto-resize and optimize **Success Response** (200 OK):
{
  "avatar_url": "https://cdn.solidfocus.com/avatars/user-12345.jpg",
  "message": "Avatar uploaded successfully"
}
**Error Responses**: 400 Bad Request (Invalid file):
{
  "error": "validation_error",
  "message": "Invalid image file",
  "details": {
    "field": "avatar",
    "message": "File must be JPEG, PNG, or WebP format"
  }
}
400 Bad Request (File too large):
{
  "error": "validation_error",
  "message": "File size exceeds limit",
  "details": {
    "field": "avatar",
    "max_size": "5MB",
    "uploaded_size": "6.2MB"
  }
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Uploaded images are stored in object storage (S3, GCS, etc.) - Served via CDN for low latency - Old avatar is deleted when new one is uploaded - `avatar_url` is updated in User model - Consider generating thumbnails for different sizes --- ## Training/Course APIs ### 1. List Training Courses ✅ **Status**: Confirmed **Endpoint**: `GET /trainings` **Description**: Get list of available training courses/programs. **Request Headers**:
Authorization: Bearer <access_token>
**Query Parameters**: | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | type | string | No | all | Filter by type: "course", "game", "free", or "all" | | device_type | string | No | all | Filter by device: "bike", "rower", "treadmill", or "all" | | enabled | boolean | No | true | Filter by enabled status | | limit | integer | No | 20 | Results per page (max 100) | | offset | integer | No | 0 | Pagination offset | **Example Request**:
GET /trainings?type=course&device_type=bike&limit=10&offset=0
**Success Response** (200 OK):
{
  "total": 25,
  "limit": 10,
  "offset": 0,
  "trainings": [
    {
      "id": 101,
      "type": "course",
      "name": "初階有氧訓練",
      "cover": "https://cdn.solidfocus.com/covers/course-101.jpg",
      "video_url": null,
      "supported_devices": ["bike", "rower"],
      "enabled": true,
      "settings": {
        "duration_minutes": 30,
        "difficulty": "easy",
        "target_heart_rate_zone": "zone2"
      },
      "detail": {
        "description": "適合初學者的30分鐘有氧訓練",
        "stages": [
          {
            "duration_seconds": 300,
            "resistance_level": 3,
            "description": "暖身階段"
          },
          {
            "duration_seconds": 1200,
            "resistance_level": 5,
            "description": "有氧訓練"
          },
          {
            "duration_seconds": 300,
            "resistance_level": 2,
            "description": "緩和階段"
          }
        ]
      }
    },
    {
      "id": 102,
      "type": "course",
      "name": "台灣環島體感",
      "cover": "https://cdn.solidfocus.com/covers/course-102.jpg",
      "video_url": "https://cdn.solidfocus.com/videos/taiwan-tour.m3u8",
      "supported_devices": ["bike"],
      "enabled": true,
      "settings": {
        "duration_minutes": 45,
        "difficulty": "medium",
        "has_video": true,
        "game_enabled": true
      },
      "detail": {
        "description": "跟著影片體驗台灣美景",
        "stages": [
          {
            "duration_seconds": 300,
            "resistance_level": 3,
            "target_speed_kmh": 20,
            "description": "北海岸出發"
          },
          {
            "duration_seconds": 1800,
            "resistance_level": 7,
            "target_speed_kmh": 25,
            "description": "宜蘭山區爬坡"
          },
          {
            "duration_seconds": 600,
            "resistance_level": 4,
            "target_speed_kmh": 22,
            "description": "花蓮海岸線"
          }
        ]
      }
    }
  ]
}
**Response Fields**: | Field | Type | Description | |-------|------|-------------| | total | integer | Total number of trainings matching filter | | limit | integer | Results per page | | offset | integer | Current offset | | trainings | array | Array of training objects | | trainings[].id | integer | Training ID | | trainings[].type | string | "course", "game", or "free" | | trainings[].name | string | Training name | | trainings[].cover | string | Cover image URL | | trainings[].video_url | string | Video URL (HLS/m3u8), null if no video | | trainings[].supported_devices | array | Supported device types: ["bike", "rower", "treadmill"] | | trainings[].enabled | boolean | Whether training is available | | trainings[].settings | object | Flexible JSON settings (duration, difficulty, etc.) | | trainings[].detail | object | Flexible JSON details (stages, description, etc.) | **Error Responses**: 401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
--- ### 2. Get Training Course Detail ✅ **Status**: Confirmed - Include video URL **Endpoint**: `GET /trainings/{id}` **Description**: Get detailed information about a specific training course. **Request Headers**:
Authorization: Bearer <access_token>
**Path Parameters**: | Parameter | Type | Description | |-----------|------|-------------| | id | integer | Training ID | **Example Request**:
GET /trainings/102
**Success Response** (200 OK):
{
  "id": 102,
  "type": "course",
  "name": "台灣環島體感",
  "cover": "https://cdn.solidfocus.com/covers/course-102.jpg",
  "video_url": "https://cdn.solidfocus.com/videos/taiwan-tour.m3u8",
  "views": 3421,
  "enabled": true,
  "settings": {
    "duration_minutes": 45,
    "difficulty": "medium",
    "has_video": true,
    "game_enabled": true,
    "tags": ["scenic", "taiwan", "medium"]
  },
  "detail": {
    "description": "跟著影片體驗台灣美景,搭配智能阻力調整",
    "long_description": "這是一段45分鐘的虛擬環島旅程...",
    "instructor": "教練名稱",
    "music": "流行音樂",
    "stages": [
      {
        "duration_seconds": 300,
        "resistance_level": 3,
        "target_speed_kmh": 20,
        "target_cadence_rpm": 70,
        "description": "北海岸出發"
      },
      {
        "duration_seconds": 1800,
        "resistance_level": 7,
        "target_speed_kmh": 25,
        "target_cadence_rpm": 80,
        "description": "宜蘭山區爬坡"
      },
      {
        "duration_seconds": 600,
        "resistance_level": 4,
        "target_speed_kmh": 22,
        "target_cadence_rpm": 75,
        "description": "花蓮海岸線"
      }
    ]
  }
}
**Error Responses**: 404 Not Found:
{
  "error": "not_found",
  "message": "Training course not found"
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Increment `views` counter each time this endpoint is called - `video_url` should be HLS streaming URL (.m3u8) for iOS/Android compatibility - `settings` and `detail` are flexible JSON structures - can evolve without schema changes --- ## Session APIs ### 1. Create Workout Session ✅ **Status**: Confirmed - Auto-updates UserStats **Endpoint**: `POST /sessions` **Description**: Create a new workout session record. This will automatically update user statistics. **Request Headers**:
Authorization: Bearer <access_token>
Content-Type: application/json
**Request Body**:
{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "started_at": "2025-12-17T10:00:00Z",
  "ended_at": "2025-12-17T10:30:00Z",
  "duration": 1800,
  "machine_name": "SolidFocus Bike Pro",
  "machine_model": "SF-BP-2024",
  "workout_type": "bike",
  "training_id": 102,
  "training_settings": {
    "difficulty": "medium",
    "game_enabled": true,
    "video_played": true
  },
  "total_energy": 250.5,
  "total_distance": 12.5,
  "score": 8750.0,
  "game_enabled": true,
  "detail": {
    "avg_heart_rate": 145,
    "max_heart_rate": 170,
    "avg_power": 180,
    "max_power": 250,
    "avg_cadence": 80,
    "avg_speed": 25.5,
    "max_speed": 32.0,
    "avg_resistance": 5,
    "calories_breakdown": {
      "active": 230.5,
      "basal": 20.0
    }
  },
  "raw": "<base64_encoded_binary_data>"
}
**Request Body Fields**: | Field | Type | Required | Description | |-------|------|----------|-------------| | session_id | string | Yes | Client-generated UUID (globally unique) | | started_at | string | Yes | ISO 8601 timestamp with timezone | | ended_at | string | Yes | ISO 8601 timestamp with timezone | | duration | integer | Yes | Workout duration in seconds (excluding pauses) | | machine_name | string | Yes | Complete Local Name from BLE | | machine_model | string | Yes | Machine model identifier | | workout_type | string | Yes | "bike" or "rower" | | training_id | integer | No | Training course ID (null for free ride) | | training_settings | object | No | Flexible JSON settings | | total_energy | float | Yes | Total energy burned (kCal) | | total_distance | float | Yes | Total distance (KM) | | score | float | No | Game score (if game_enabled) | | game_enabled | boolean | Yes | Whether game mode was used | | detail | object | No | Flexible JSON with detailed metrics | | raw | string | No | Base64-encoded binary BLE raw data | **Success Response** (201 Created):
{
  "session": {
    "id": 9876,
    "session_id": "550e8400-e29b-41d4-a716-446655440000",
    "user_id": 12345,
    "started_at": "2025-12-17T10:00:00Z",
    "ended_at": "2025-12-17T10:30:00Z",
    "duration": 1800,
    "machine_name": "SolidFocus Bike Pro",
    "machine_model": "SF-BP-2024",
    "workout_type": "bike",
    "training_id": 102,
    "total_energy": 250.5,
    "total_distance": 12.5,
    "score": 8750.0,
    "game_enabled": true,
    "created_at": "2025-12-17T10:30:15Z"
  },
  "updated_stats": {
    "workout_streak": 8,
    "workout_total_duration": 16200,
    "workout_total_energy": 2751.0,
    "workout_total_distance": 97.8,
    "level": 5
  },
  "message": "Session created successfully"
}
**Error Responses**: 400 Bad Request (Duplicate session_id):
{
  "error": "duplicate_session",
  "message": "Session with this session_id already exists",
  "details": {
    "session_id": "550e8400-e29b-41d4-a716-446655440000",
    "existing_session_created_at": "2025-12-17T10:30:15Z"
  }
}
400 Bad Request (Invalid data):
{
  "error": "validation_error",
  "message": "Invalid session data",
  "details": {
    "field": "duration",
    "message": "Duration must be positive"
  }
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - `session_id` must be globally unique (suggest: device_mac_prefix + uuid) - Server automatically calculates and updates `UserStats` after session creation: - `workout_total_duration` += duration - `workout_total_energy` += total_energy - `workout_total_distance` += total_distance - `workout_streak` recalculated based on consecutive workout days - `level` updated based on total stats (XP formula TBD) - `elapsed_time` can be derived from `ended_at - started_at` (not stored separately) - `raw` binary data is optional but recommended for debugging device issues --- ### 2. Batch Upload Workout Sessions ⭐ NEW ✅ **Status**: Confirmed - CRITICAL for v3.0 Offline-First architecture **Endpoint**: `POST /sessions/batch_upload` **Description**: Upload multiple workout sessions in a single request. Supports idempotent operation for offline sync. **Request Headers**:
Authorization: Bearer <access_token>
Content-Type: application/json
X-Idempotency-Key: <unique_batch_id>
**Headers**: | Header | Type | Required | Description | |--------|------|----------|-------------| | X-Idempotency-Key | string | Yes | Unique batch identifier (UUID) for idempotency | **Request Body**:
{
  "sessions": [
    {
      "client_session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "start_time": "2025-12-17T10:00:00Z",
      "end_time": "2025-12-17T10:30:00Z",
      "duration": 1800,
      "device_type": "bike",
      "source_type": "local",
      "internal_course_name": null,
      "training_id": 102,
      "total_energy": 250.5,
      "total_distance": 12.5,
      "xp_earned": 500,
      "metrics_summary": {
        "avg_heart_rate": 145,
        "max_heart_rate": 170,
        "avg_power": 180,
        "max_power": 250,
        "avg_cadence": 80
      },
      "time_series_data": [
        {
          "timestamp": 0,
          "heart_rate": 120,
          "power": 150,
          "cadence": 70
        },
        {
          "timestamp": 60,
          "heart_rate": 145,
          "power": 180,
          "cadence": 80
        }
      ]
    },
    {
      "client_session_id": "b2c3d4e5-f6g7-8901-bcde-fg2345678901",
      "start_time": "2025-12-18T09:00:00Z",
      "end_time": "2025-12-18T09:20:00Z",
      "duration": 1200,
      "device_type": "bike",
      "source_type": "free",
      "internal_course_name": "Quick Warmup",
      "training_id": null,
      "total_energy": 150.0,
      "total_distance": 6.0,
      "xp_earned": 300,
      "metrics_summary": {
        "avg_heart_rate": 130,
        "avg_power": 140,
        "avg_cadence": 75
      },
      "time_series_data": []
    }
  ]
}
**Request Body Fields**: | Field | Type | Required | Description | |-------|------|----------|-------------| | sessions | array | Yes | Array of session objects | | sessions[].client_session_id | string | Yes | APP-generated UUIDv4 (idempotency key) | | sessions[].start_time | string | Yes | ISO 8601 timestamp (UTC) | | sessions[].end_time | string | Yes | ISO 8601 timestamp (UTC) | | sessions[].duration | integer | Yes | Workout duration in seconds | | sessions[].device_type | string | Yes | "bike", "rower", or "treadmill" | | sessions[].source_type | string | Yes | "backend", "local", or "free" | | sessions[].internal_course_name | string | No | APP built-in course name (for free rides) | | sessions[].training_id | integer | No | Server training course ID (null for free rides) | | sessions[].total_energy | float | Yes | Total energy burned (kCal) | | sessions[].total_distance | float | Yes | Total distance (kilometers) | | sessions[].xp_earned | integer | Yes | Experience points earned (calculated by APP) | | sessions[].metrics_summary | object | Yes | JSONB with summary metrics | | sessions[].time_series_data | array | No | JSONB array with time-series data for charts | **Success Response** (200 OK):
{
  "success_count": 2,
  "duplicate_count": 0,
  "error_count": 0,
  "results": [
    {
      "client_session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "status": "created",
      "server_id": 9876
    },
    {
      "client_session_id": "b2c3d4e5-f6g7-8901-bcde-fg2345678901",
      "status": "created",
      "server_id": 9877
    }
  ],
  "updated_stats": {
    "workout_streak_days": 8,
    "total_duration_seconds": 18000,
    "total_calories": 3200.5,
    "total_distance_km": 125.5,
    "total_xp": 8800,
    "current_level": 6
  },
  "message": "Batch upload completed successfully"
}
**Success Response (With Duplicates)** (200 OK):
{
  "success_count": 1,
  "duplicate_count": 1,
  "error_count": 0,
  "results": [
    {
      "client_session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "status": "duplicate",
      "server_id": 9876,
      "message": "Session already exists"
    },
    {
      "client_session_id": "b2c3d4e5-f6g7-8901-bcde-fg2345678901",
      "status": "created",
      "server_id": 9877
    }
  ],
  "updated_stats": {
    "workout_streak_days": 8,
    "total_duration_seconds": 18000,
    "total_calories": 3200.5,
    "total_distance_km": 125.5,
    "total_xp": 8800,
    "current_level": 6
  },
  "message": "Batch upload completed with 1 duplicate"
}
**Error Responses**: 400 Bad Request (Invalid data):
{
  "error": "validation_error",
  "message": "Invalid session data in batch",
  "details": {
    "session_index": 0,
    "field": "duration",
    "message": "Duration must be positive"
  }
}
400 Bad Request (Missing idempotency key):
{
  "error": "validation_error",
  "message": "X-Idempotency-Key header is required"
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - **Idempotency**: Use `X-Idempotency-Key` header + `client_session_id` for duplicate detection - **APP wins**: All metrics calculated by APP, server only validates and stores - **Partial success**: Server processes all sessions, returns individual status for each - **Duplicate handling**: Returns 200 OK with `duplicate` status, does not update stats - **Stats update**: Only successful (non-duplicate) sessions update user stats - **Transaction**: All sessions in one batch are processed in a single database transaction - **Performance**: Batch size recommended: 1-50 sessions per request - **Retry**: Same idempotency key can be retried safely (returns same result) --- ### 3. List Workout Sessions ✅ **Status**: Confirmed - Support date range filtering **Endpoint**: `GET /sessions` **Description**: Get list of user's workout sessions with optional filtering. **Request Headers**:
Authorization: Bearer <access_token>
**Query Parameters**: | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | start_date | string | No | - | Filter by started_at >= (ISO 8601) | | end_date | string | No | - | Filter by started_at <= (ISO 8601) | | workout_type | string | No | - | Filter by type: "bike" or "rower" | | training_id | integer | No | - | Filter by specific training course | | limit | integer | No | 20 | Results per page (max 100) | | offset | integer | No | 0 | Pagination offset | | sort | string | No | -started_at | Sort field: "started_at", "-started_at" | **Example Request**:
GET /sessions?start_date=2025-12-01T00:00:00Z&end_date=2025-12-31T23:59:59Z&limit=10
**Success Response** (200 OK):
{
  "total": 45,
  "limit": 10,
  "offset": 0,
  "sessions": [
    {
      "id": 9876,
      "session_id": "550e8400-e29b-41d4-a716-446655440000",
      "started_at": "2025-12-17T10:00:00Z",
      "ended_at": "2025-12-17T10:30:00Z",
      "duration": 1800,
      "machine_name": "SolidFocus Bike Pro",
      "machine_model": "SF-BP-2024",
      "workout_type": "bike",
      "training": {
        "id": 102,
        "name": "台灣環島體感",
        "cover": "https://cdn.solidfocus.com/covers/course-102.jpg"
      },
      "total_energy": 250.5,
      "total_distance": 12.5,
      "score": 8750.0,
      "game_enabled": true,
      "created_at": "2025-12-17T10:30:15Z",
      "summary": {
        "avg_heart_rate": 145,
        "avg_power": 180,
        "avg_speed": 25.5
      }
    },
    {
      "id": 9875,
      "session_id": "550e8400-e29b-41d4-a716-446655440001",
      "started_at": "2025-12-16T09:00:00Z",
      "ended_at": "2025-12-16T09:20:00Z",
      "duration": 1200,
      "machine_name": "SolidFocus Bike Pro",
      "machine_model": "SF-BP-2024",
      "workout_type": "bike",
      "training": null,
      "total_energy": 180.0,
      "total_distance": 8.0,
      "score": null,
      "game_enabled": false,
      "created_at": "2025-12-16T09:20:10Z",
      "summary": {
        "avg_heart_rate": 138,
        "avg_power": 165,
        "avg_speed": 24.0
      }
    }
  ]
}
**Error Responses**: 400 Bad Request (Invalid date format):
{
  "error": "validation_error",
  "message": "Invalid date format",
  "details": {
    "field": "start_date",
    "expected_format": "ISO 8601 (e.g., 2025-12-17T10:00:00Z)"
  }
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Sessions are sorted by `started_at` descending by default (most recent first) - `training` field is populated with basic info if `training_id` exists - `summary` field contains key metrics from `detail` JSON for quick display --- ### 3. Get Session Detail ✅ **Status**: Confirmed **Endpoint**: `GET /sessions/{session_id}` **Description**: Get detailed information about a specific workout session. **Request Headers**:
Authorization: Bearer <access_token>
**Path Parameters**: | Parameter | Type | Description | |-----------|------|-------------| | session_id | string | Client-generated session UUID | **Example Request**:
GET /sessions/550e8400-e29b-41d4-a716-446655440000
**Success Response** (200 OK):
{
  "id": 9876,
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_id": 12345,
  "started_at": "2025-12-17T10:00:00Z",
  "ended_at": "2025-12-17T10:30:00Z",
  "duration": 1800,
  "machine_name": "SolidFocus Bike Pro",
  "machine_model": "SF-BP-2024",
  "workout_type": "bike",
  "training": {
    "id": 102,
    "name": "台灣環島體感",
    "type": "course",
    "cover": "https://cdn.solidfocus.com/covers/course-102.jpg"
  },
  "training_settings": {
    "difficulty": "medium",
    "game_enabled": true,
    "video_played": true
  },
  "total_energy": 250.5,
  "total_distance": 12.5,
  "score": 8750.0,
  "game_enabled": true,
  "detail": {
    "avg_heart_rate": 145,
    "max_heart_rate": 170,
    "avg_power": 180,
    "max_power": 250,
    "avg_cadence": 80,
    "max_cadence": 95,
    "avg_speed": 25.5,
    "max_speed": 32.0,
    "avg_resistance": 5,
    "max_resistance": 8,
    "calories_breakdown": {
      "active": 230.5,
      "basal": 20.0
    },
    "heart_rate_zones": {
      "zone1": 120,
      "zone2": 480,
      "zone3": 900,
      "zone4": 300,
      "zone5": 0
    }
  },
  "created_at": "2025-12-17T10:30:15Z"
}
**Error Responses**: 404 Not Found:
{
  "error": "not_found",
  "message": "Session not found"
}
403 Forbidden (Session belongs to another user):
{
  "error": "forbidden",
  "message": "You don't have permission to access this session"
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Returns full session details including complete `detail` JSON - `training` object is null if session was free ride (no course selected) - Raw binary data is not returned in this endpoint (too large) --- ### 4. Delete Workout Session ✅ **Status**: Confirmed **Endpoint**: `DELETE /sessions/{session_id}` **Description**: Delete a workout session and recalculate user statistics. **Request Headers**:
Authorization: Bearer <access_token>
**Path Parameters**: | Parameter | Type | Description | |-----------|------|-------------| | session_id | string | Client-generated session UUID | **Example Request**:
DELETE /sessions/550e8400-e29b-41d4-a716-446655440000
**Success Response** (200 OK):
{
  "message": "Session deleted successfully",
  "deleted_session_id": "550e8400-e29b-41d4-a716-446655440000",
  "updated_stats": {
    "workout_streak": 7,
    "workout_total_duration": 14400,
    "workout_total_energy": 2500.5,
    "workout_total_distance": 85.3,
    "level": 5
  }
}
**Error Responses**: 404 Not Found:
{
  "error": "not_found",
  "message": "Session not found"
}
403 Forbidden:
{
  "error": "forbidden",
  "message": "You don't have permission to delete this session"
}
401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Server recalculates `UserStats` after deletion: - Subtract session's duration, energy, distance from totals - Recalculate `workout_streak` based on remaining sessions - Recalculate `level` based on updated stats - Associated metrics and raw data are cascade deleted - Consider soft delete (mark as deleted) for data recovery --- ## Meta APIs ### 1. Get Device Schemas ✅ **Status**: Confirmed - NEW in v3.0 **Endpoint**: `GET /meta/schemas` **Description**: Get device-specific field definitions for dynamic form rendering in Vue admin panel. **Request Headers**:
Authorization: Bearer <access_token>
**Query Parameters**: | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | device_type | string | No | all | Filter by device: "bike", "rower", "treadmill", or "all" | **Example Request**:
GET /meta/schemas?device_type=bike
**Success Response** (200 OK):
{
  "bike": {
    "metrics_summary_fields": [
      {
        "key": "avg_heart_rate",
        "label": "Average Heart Rate",
        "type": "integer",
        "unit": "bpm",
        "required": false
      },
      {
        "key": "avg_power",
        "label": "Average Power",
        "type": "integer",
        "unit": "watts",
        "required": false
      },
      {
        "key": "avg_cadence",
        "label": "Average Cadence",
        "type": "integer",
        "unit": "rpm",
        "required": false
      },
      {
        "key": "avg_speed",
        "label": "Average Speed",
        "type": "float",
        "unit": "km/h",
        "required": false
      }
    ],
    "time_series_fields": [
      {
        "key": "timestamp",
        "label": "Timestamp",
        "type": "integer",
        "unit": "seconds",
        "required": true
      },
      {
        "key": "heart_rate",
        "label": "Heart Rate",
        "type": "integer",
        "unit": "bpm",
        "required": false
      },
      {
        "key": "power",
        "label": "Power",
        "type": "integer",
        "unit": "watts",
        "required": false
      },
      {
        "key": "cadence",
        "label": "Cadence",
        "type": "integer",
        "unit": "rpm",
        "required": false
      },
      {
        "key": "speed",
        "label": "Speed",
        "type": "float",
        "unit": "km/h",
        "required": false
      }
    ]
  },
  "rower": {
    "metrics_summary_fields": [
      {
        "key": "avg_stroke_rate",
        "label": "Average Stroke Rate",
        "type": "integer",
        "unit": "spm",
        "required": false
      },
      {
        "key": "avg_power",
        "label": "Average Power",
        "type": "integer",
        "unit": "watts",
        "required": false
      }
    ],
    "time_series_fields": [
      {
        "key": "timestamp",
        "label": "Timestamp",
        "type": "integer",
        "unit": "seconds",
        "required": true
      },
      {
        "key": "stroke_rate",
        "label": "Stroke Rate",
        "type": "integer",
        "unit": "spm",
        "required": false
      },
      {
        "key": "power",
        "label": "Power",
        "type": "integer",
        "unit": "watts",
        "required": false
      }
    ]
  }
}
**Response Fields**: | Field | Type | Description | |-------|------|-------------| | {device_type} | object | Device-specific schema definition | | {device_type}.metrics_summary_fields | array | Field definitions for metrics_summary JSONB | | {device_type}.time_series_fields | array | Field definitions for time_series_data JSONB array | | field.key | string | JSON key name | | field.label | string | Display label for UI | | field.type | string | Data type: "integer", "float", "string", "boolean" | | field.unit | string | Unit of measurement (optional) | | field.required | boolean | Whether field is required | **Error Responses**: 401 Unauthorized:
{
  "error": "unauthorized",
  "message": "Invalid or expired token"
}
**Implementation Notes**: - Used by Vue admin panel for dynamic form rendering - Schema can be updated without changing admin panel code - Supports heterogeneous device data in same database table - Consider caching response (changes infrequently) --- ### 2. Check App Version ✅ **Status**: Confirmed - NEW in v3.0 **Endpoint**: `GET /meta/app_version` **Description**: Check for forced app updates and version compatibility. **Request Headers**:
Content-Type: application/json
**Query Parameters**: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | platform | string | Yes | "ios" or "android" | | version | string | Yes | Current app version (semver: "1.2.3") | **Example Request**:
GET /meta/app_version?platform=ios&version=1.2.0
**Success Response (Update Not Required)** (200 OK):
{
  "update_required": false,
  "latest_version": "1.2.5",
  "update_url": "https://apps.apple.com/app/solidfocus/id123456789",
  "message": "You are using an older version, but update is optional"
}
**Success Response (Forced Update Required)** (200 OK):
{
  "update_required": true,
  "latest_version": "2.0.0",
  "minimum_version": "1.5.0",
  "update_url": "https://apps.apple.com/app/solidfocus/id123456789",
  "message": "This version is no longer supported. Please update to continue.",
  "features": [
    "New workout modes",
    "Bug fixes and performance improvements"
  ]
}
**Response Fields**: | Field | Type | Description | |-------|------|-------------| | update_required | boolean | Whether user must update to continue using app | | latest_version | string | Latest available app version | | minimum_version | string | Minimum supported version (if update_required) | | update_url | string | App Store / Play Store URL | | message | string | User-facing message about the update | | features | array | Optional list of new features | **Error Responses**: 400 Bad Request:
{
  "error": "validation_error",
  "message": "Invalid platform or version format",
  "details": {
    "field": "version",
    "message": "Must be semantic version format (e.g., 1.2.3)"
  }
}
**Implementation Notes**: - No authentication required (public endpoint) - Use semantic versioning comparison (semver) - `update_required` = true when version < minimum_version - Consider A/B testing for gradual rollouts - Cache response for performance (check on app launch) --- ## Common Error Responses All error responses follow this standard format:
{
  "error": "error_code",
  "message": "Human-readable error message",
  "details": {
    "field": "field_name",
    "additional_info": "..."
  }
}
### HTTP Status Codes | Status Code | Meaning | Usage | |-------------|---------|-------| | 200 OK | Success | Successful GET, PATCH, DELETE | | 201 Created | Resource created | Successful POST | | 400 Bad Request | Invalid input | Validation errors, malformed JSON | | 401 Unauthorized | Authentication failed | Missing/invalid/expired token | | 403 Forbidden | Permission denied | Valid auth but insufficient permissions | | 404 Not Found | Resource not found | Invalid ID, deleted resource | | 409 Conflict | Resource conflict | Duplicate session_id, device binding conflict | | 500 Internal Server Error | Server error | Unexpected server errors | ### Common Error Codes | Error Code | Description | |------------|-------------| | `validation_error` | Request validation failed | | `unauthorized` | Authentication required or failed | | `forbidden` | Access denied | | `not_found` | Resource not found | | `duplicate_session` | Session ID already exists | | `device_bound` | Account already bound to another device | | `invalid_token` | Token is invalid or expired | | `authentication_failed` | Login credentials incorrect | ### Error Response Examples **Validation Error (400)**:
{
  "error": "validation_error",
  "message": "Invalid input data",
  "details": {
    "field": "email",
    "message": "Email format is invalid",
    "value": "not-an-email"
  }
}
**Authentication Error (401)**:
{
  "error": "unauthorized",
  "message": "Authentication token is missing or invalid",
  "details": {
    "hint": "Include valid token in Authorization header"
  }
}
**Permission Error (403)**:
{
  "error": "forbidden",
  "message": "You don't have permission to access this resource",
  "details": {
    "required_permission": "admin"
  }
}
**Server Error (500)**:
{
  "error": "internal_server_error",
  "message": "An unexpected error occurred",
  "details": {
    "request_id": "req_1234567890",
    "hint": "Please contact support if this persists"
  }
}
--- ## Implementation Notes ### 1. Offline-First Architecture (v3.0) **Design Philosophy**: - APP is the source of truth for workout data - Server is archive and sync coordinator - All workout metrics calculated by APP locally - Server validates, stores, and serves data back to clients **Benefits**: - Enables true offline workout support - Reduces network dependency during workouts - Simplifies server-side logic (no complex calculations) - Prevents data loss during network outages **Trade-offs**: - Server cannot recalculate stats if APP logic changes - Requires careful APP-side validation before upload - Stats may drift if APP has bugs (use force-sync endpoint) ### 2. Workout Calculation & Sync **Client-side Calculation**: - Workout Engine on app calculates all metrics - Session summary created on app when workout ends - App uploads completed sessions to server when online - Enables offline workout support **Server-side Responsibilities**: - Store session data - Validate session data integrity - Update UserStats based on received sessions - Handle duplicate session prevention (via session_id) **Sync Strategy (v3.0)**: - App maintains local session queue with sync status - Use batch upload endpoint (`POST /sessions/batch_upload`) for efficiency - Include `X-Idempotency-Key` header for safe retries - Server returns individual status for each session (created/duplicate/error) - Only non-duplicate sessions update user stats - Mark sessions as synced after successful server response ### 3. Session ID Generation (v3.0) **Format**:
UUIDv4 (client-generated)
Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
**Field Name**: `client_session_id` (renamed from `session_id` in v0.11) **Benefits**: - Globally unique across all users and devices - Standard UUID format (widely supported) - Prevents collisions even if multiple apps generate IDs offline - Acts as idempotency key for duplicate detection **Implementation**: - APP generates UUIDv4 when workout starts - Stored in local database with session data - Sent to server in `client_session_id` field - Server checks for duplicates before creating new record ### 4. Training Settings & Detail JSON **Flexibility**: - `settings` and `detail` are flexible JSON fields - Allows schema evolution without database migration - Different workout types can have different structures **Validation**: - Server should validate required fields but allow extra fields - Document expected schemas in API spec - Consider JSON Schema validation for type safety ### 5. Video Delivery **Video Format**: - Use HLS (HTTP Live Streaming) for iOS/Android compatibility - Format: `.m3u8` playlist with `.ts` segments - Adaptive bitrate for different network conditions **CDN Configuration**: - Store video files in Object Storage (S3, GCS, etc.) - Serve via CDN for low latency - Consider signed URLs for access control ### 6. UserStats Calculation (v3.0) **Automatic Updates** (when sessions uploaded): - `workout_streak_days`: Count consecutive days with at least one workout - Breaks if user misses a day (no workout in 24-hour period) - Timezone consideration: Use user's local timezone - Updated when new session is uploaded - `total_duration_seconds`: Sum of all session durations - `total_calories`: Sum of all session total_energy values - `total_distance_km`: Sum of all session total_distance values - `total_xp`: Sum of all session xp_earned values - `current_level`: Calculate based on total XP - Level formula: TBD (e.g., level = floor(sqrt(total_xp / 100))) - Or use fixed thresholds: [0, 100, 300, 600, 1000, 1500, ...] - `last_workout_date`: Updated to most recent session end_time **Force-Sync Override** (v3.0 NEW): - `PATCH /users/me/stats` allows APP to force-sync when out of sync - **APP wins** - Server accepts APP values without question - Used after offline sessions or when detecting drift - All overrides are logged for debugging and analytics - No audit approval needed (APP is source of truth) ### 7. Authentication Implementation Details (v3.0) **JWT Token Structure**:
{
  "sub": "12345",
  "user_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "email": "user@example.com",
  "iat": 1702825805,
  "exp": 1702912205
}
**Token Security**: - Use HS256 or RS256 algorithm - Store secret key securely (environment variable) - ⚠️ **Device binding removed in v3.0** - No device_id in token - Invalidate tokens on logout (maintain blocklist/revocation list) - Consider token rotation for refresh tokens in future versions **SSO Implementation**: - Verify ID token with provider's public keys/API endpoints - Extract user info (sub, email, name) from verified token - Create or update SocialAccount record - Store `provider` ("apple", "google", "facebook") - Store `provider_uid` (provider's user ID) - Store extra data in JSONB `extra_data` field - Link to existing User if email matches, or create new User - Apple may hide email - handle gracefully (use provider_uid only) ### 8. Error Handling Best Practices **Consistency**: - Always return JSON error responses (even for 500 errors) - Include `error` code for programmatic handling - Include `message` for human-readable description - Include `details` for additional context **Client-side Handling**: - Check HTTP status code first - Parse `error` code for specific error handling - Display `message` to user when appropriate - Log `details` for debugging **Security Considerations**: - Don't expose sensitive info in error messages - Use generic messages for auth failures (prevent user enumeration) - Log detailed errors server-side for debugging - Include request_id for support investigations ---

◀ Back to API Contract README | Next: 04.2 Data Models ▶