◀ Back to API Contract README | Next: 04.2 Data Models ▶
Table of Contents¶
- API Overview
- Authentication & Authorization
- Authentication APIs
- User APIs
- Training/Course APIs
- Session APIs
- Meta APIs ⭐ NEW
- Common Error Responses
- 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:
- Request/response body structures
- Error response format
- HTTP status codes to use
- Field naming conventions
- Token expiration times
- 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_typeparameter - Returns course metadata including video URLs
- Uses JSONB for flexible
settingsanddetailfields (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:
-
Idempotency Design:
-
APP generates
client_session_uuid(UUIDv4) - Server checks if
client_session_uuidexists before creating -
Duplicate uploads return success (idempotent behavior)
-
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 -
Offline-First Pattern:
-
APP stores sessions locally with
Status: PENDING - Batch uploads when network available
- Marks as
Status: SYNCEDafter 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:
- Endpoint paths and HTTP methods
- High-level descriptions (Chinese)
- Database schema (SQLModel classes with field types)
- Architecture concepts (Offline-First, Store-and-Forward, JSONB usage)
- Sequence diagram showing sync flow
❌ Client Did NOT Define:
- Request body structures
- Response body structures
- HTTP status codes (200, 201, 400, 403, etc.)
- Error response formats
- Error codes (e.g.,
validation_error,duplicate_session) - Header requirements (except mention of
X-Idempotency-Key) - Pagination details
- Query parameter formats
Next Steps¶
Before Implementation:
- Schedule API contract discussion with SolidFocus RD team
- Present Fugu's proposed request/response formats
- Agree on error response structure
- Agree on HTTP status code conventions
- 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
---