Add Wahoo activity sync with provider-agnostic framework

Provider-agnostic sync framework + Wahoo as first implementation:

Framework (apps/journal/app/lib/sync/):
- SyncProvider interface for OAuth2, webhooks, import, conversion
- Provider registry for settings UI iteration
- Generic sync_connections + sync_imports tables
- Token storage with auto-refresh

Wahoo provider:
- OAuth2 with workouts_read, user_read, offline_data scopes
- Webhook-based auto-import (workout_summary events)
- Manual import page with pagination
- FIT→GPX conversion via fit-file-parser
- Webhook token verification

Routes:
- /api/sync/connect/:provider — OAuth redirect
- /api/sync/callback/:provider — OAuth callback
- /api/sync/disconnect/:provider — remove connection
- /api/sync/webhook/:provider — webhook receiver
- /sync/import/:provider — manual import page

Settings: Connected Services section with per-provider connect/disconnect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-04 11:19:15 +01:00
parent dd6543e313
commit b6711d23d6
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
25 changed files with 1124 additions and 5 deletions

28
pnpm-lock.yaml generated
View file

@ -112,6 +112,9 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
fit-file-parser:
specifier: ^2.3.3
version: 2.3.3
i18next:
specifier: ^26.0.1
version: 26.0.1(typescript@5.9.3)
@ -2158,6 +2161,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.12:
resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==}
engines: {node: '>=6.0.0'}
@ -2195,6 +2201,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -2644,6 +2653,9 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fit-file-parser@2.3.3:
resolution: {integrity: sha512-TZPFfjkEev5TTd9RnZ4xn4k5ZSx2VZiKNjoZsHIkmQDK0S0XA7ebfdMLj76BK7kStsHh5WbK8Fmn/w85jgd0dA==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@ -2756,6 +2768,9 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -5577,6 +5592,8 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.12: {}
basic-auth@2.0.1:
@ -5624,6 +5641,11 @@ snapshots:
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
cac@6.7.14: {}
@ -6062,6 +6084,10 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fit-file-parser@2.3.3:
dependencies:
buffer: 6.0.3
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
@ -6179,6 +6205,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}