From 8e5b6d6fe940a0ac241bb1029202cd0d24d6ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 8 May 2026 01:25:33 +0200 Subject: [PATCH] Wahoo capability adapters + caller migration (groups 3-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of deepen-connected-services. Built TDD: contract test red, adapter green for each capability seam. Capability adapters (providers/wahoo/): - importer.ts: Importer seam — listImportable + importOne against Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't share third-party data). 2 contract tests green. - pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId, version}. FIT-Course conversion, route: external_id, PUT-vs-POST decision, PUT->POST-on-404 fallback all internal to the adapter (per ADR-0003). Idempotency via sync_pushes preserved. 4 contract tests green. - webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes events to local users via provider_user_id; unknown user returns silently. 6 contract tests green. - manifest.ts: declares credential_kind=oauth, OAuth config, scopes, buildAuthUrl, exchangeCode, and references each capability adapter. Module shape: - connected-services/index.ts: side-effect imports providers/index.ts to register manifests, then re-exports manager + registry + types. - connected-services/providers/index.ts: barrel that calls registerManifest(wahooManifest). - connected-services/oauth-state.server.ts: OAuth state encode/decode (extracted from legacy pushes.server.ts). - connected-services/push-action.server.ts: orchestration above the RoutePusher seam — load route, ownership check, scope check, build RoutePushInput, invoke pusher, return PushOutcome union. Replaces the legacy pushRouteToProvider in lib/sync/pushes.server.ts. Caller migration (group 5): - api.sync.connect.$provider.ts -> manifest.buildAuthUrl - api.sync.callback.$provider.ts -> manifest.exchangeCode + link() - api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls best-effort revoke via the credential adapter, then deletes locally) - api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch - api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider - sync.import.$provider.tsx -> manifest.importer.listImportable + inline FIT->GPX in the action (form-supplied metadata bypasses the Importer seam which is reserved for automatic / webhook imports) - routes.$id.tsx -> getService instead of getConnection - settings.connections.tsx -> getAllManifests instead of legacy registry Legacy lib/sync/ deleted except imports.server.ts (which manages the sync_imports table, untouched by this change). DB migration verified locally (task 1.3): pnpm db:migrate-data renamed the table and backfilled credentials JSONB; pnpm db:push then dropped the legacy access_token/refresh_token/expires_at columns. Final shape matches the schema; check constraints + unique index in place. Test status (task 6.1): - pnpm typecheck: green across all 15 workspaces - pnpm lint: green - @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than before because pushes.server.test.ts and the legacy wahoo.test.ts were deleted. Their coverage is replaced by the new contract tests (importer/pusher/webhook) plus manager.test.ts + oauth.test.ts. Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test), 7.1-7.3 (followups + spec deltas at archive). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/connected-services/index.ts | 9 + .../connected-services/oauth-state.server.ts | 23 ++ .../lib/connected-services/providers/index.ts | 11 + .../providers/wahoo/importer.test.ts | 102 ++++++ .../providers/wahoo/importer.ts | 174 ++++++++++ .../providers/wahoo/manifest.ts | 130 ++++++++ .../providers/wahoo/pusher.test.ts | 208 ++++++++++++ .../providers/wahoo/pusher.ts | 217 ++++++++++++ .../providers/wahoo/webhook.test.ts | 133 ++++++++ .../providers/wahoo/webhook.ts | 100 ++++++ .../connected-services/push-action.server.ts | 115 +++++++ .../app/lib/sync/connections.server.ts | 127 ------- .../providers/__fixtures__/wahoo-ride.fit | Bin 34734 -> 0 bytes .../app/lib/sync/providers/wahoo.test.ts | 285 ---------------- apps/journal/app/lib/sync/providers/wahoo.ts | 291 ----------------- .../app/lib/sync/pushes.server.test.ts | 309 ------------------ apps/journal/app/lib/sync/pushes.server.ts | 222 ------------- apps/journal/app/lib/sync/registry.ts | 12 - apps/journal/app/lib/sync/types.ts | 124 ------- .../app/routes/api.sync.callback.$provider.ts | 38 ++- .../app/routes/api.sync.connect.$provider.ts | 12 +- .../routes/api.sync.disconnect.$provider.ts | 28 +- .../api.sync.push.$provider.$routeId.ts | 20 +- .../app/routes/api.sync.webhook.$provider.ts | 63 +--- apps/journal/app/routes/routes.$id.tsx | 4 +- .../app/routes/settings.connections.tsx | 13 +- .../app/routes/sync.import.$provider.tsx | 99 +++--- .../deepen-connected-services/tasks.md | 28 +- 28 files changed, 1375 insertions(+), 1522 deletions(-) create mode 100644 apps/journal/app/lib/connected-services/index.ts create mode 100644 apps/journal/app/lib/connected-services/oauth-state.server.ts create mode 100644 apps/journal/app/lib/connected-services/providers/index.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/importer.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts create mode 100644 apps/journal/app/lib/connected-services/push-action.server.ts delete mode 100644 apps/journal/app/lib/sync/connections.server.ts delete mode 100644 apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit delete mode 100644 apps/journal/app/lib/sync/providers/wahoo.test.ts delete mode 100644 apps/journal/app/lib/sync/providers/wahoo.ts delete mode 100644 apps/journal/app/lib/sync/pushes.server.test.ts delete mode 100644 apps/journal/app/lib/sync/pushes.server.ts delete mode 100644 apps/journal/app/lib/sync/registry.ts delete mode 100644 apps/journal/app/lib/sync/types.ts diff --git a/apps/journal/app/lib/connected-services/index.ts b/apps/journal/app/lib/connected-services/index.ts new file mode 100644 index 0000000..a3bb78c --- /dev/null +++ b/apps/journal/app/lib/connected-services/index.ts @@ -0,0 +1,9 @@ +// Public entry point for the connected-services module. Importing from +// here guarantees provider manifests are registered before any caller +// looks them up. + +import "./providers/index.ts"; + +export * from "./manager.ts"; +export * from "./registry.ts"; +export * from "./types.ts"; diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts new file mode 100644 index 0000000..fe886af --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -0,0 +1,23 @@ +// OAuth state encoding for the connect/callback flow. The state param is +// reflected back to the callback unchanged, so we use it to carry +// post-callback intent (where to return to, whether a push should resume). + +export interface PushOAuthState { + pushAfter?: { routeId: string }; + returnTo?: string; +} + +export function encodeOAuthState(state: PushOAuthState): string { + return Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); +} + +export function decodeOAuthState(raw: string | null | undefined): PushOAuthState { + if (!raw) return {}; + try { + const json = Buffer.from(raw, "base64url").toString("utf8"); + const parsed = JSON.parse(json) as PushOAuthState; + return typeof parsed === "object" && parsed != null ? parsed : {}; + } catch { + return {}; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts new file mode 100644 index 0000000..d97b66e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -0,0 +1,11 @@ +// Provider barrel. Imports every provider manifest and registers it with +// the registry. Adding a provider: import its manifest here and add the +// `registerManifest(...)` call. + +import { registerManifest } from "../registry.ts"; +import { wahooManifest } from "./wahoo/manifest.ts"; + +registerManifest(wahooManifest); + +// Re-export so callers (mostly tests) can grab a manifest directly. +export { wahooManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts new file mode 100644 index 0000000..0292bf9 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts @@ -0,0 +1,102 @@ +// Contract tests for the Wahoo Importer capability adapter. +// +// The seam under test is `Importer` from registry.ts: +// listImportable(ctx, page) -> ImportableList +// importOne(ctx, workoutId) -> ImportResult +// +// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy +// wahoo.test.ts and follow the code as it's reorganized. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(creds), + }; +} + +// Importer is loaded after the implementation file exists. Using a dynamic +// import isolates the test from module load order during initial red. +const { wahooImporter } = await import("./importer.ts"); + +describe("wahooImporter.listImportable", () => { + it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { + id: 42, + name: "Morning ride", + workout_type: "biking", + starts: "2026-05-01T07:00:00Z", + workout_summary: { + duration_active_accum: 3600, + distance_accum: 25000, + file: { url: "https://cdn.example/42.fit" }, + }, + }, + ], + total: 1, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toContain("/v1/workouts"); + expect(((init as RequestInit).headers as Record).Authorization).toBe( + "Bearer fake-token", + ); + expect(result.workouts).toHaveLength(1); + expect(result.workouts[0]!.id).toBe("42"); + expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit"); + expect(result.total).toBe(1); + }); + + it("filters out third-party workouts (fitness_app_id >= 1000)", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + { + id: 2, + name: "Third-party", + workout_type: "biking", + starts: "2026-05-01T08:00:00Z", + fitness_app_id: 1234, + }, + ], + total: 2, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + expect(result.workouts.map((w) => w.id)).toEqual(["1"]); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts new file mode 100644 index 0000000..8cbdcf1 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts @@ -0,0 +1,174 @@ +// Wahoo Importer capability adapter. Implements the Importer seam against +// Wahoo's /v1/workouts API. +// +// Credentials always flow through ctx.withFreshCredentials — this module +// never reads the connected_services credentials JSONB directly. + +import FitParser from "fit-file-parser"; +import { generateGpx } from "@trails-cool/gpx"; +import { createActivity } from "../../../activities.server.ts"; +import { recordImport, isAlreadyImported } from "../../../sync/imports.server.ts"; +import type { + CapabilityContext, + ImportableList, + ImportResult, + Importer, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooWorkout { + id: number; + name: string; + workout_type: string; + starts: string; + fitness_app_id?: number; + workout_summary?: { + duration_active_accum?: number; + distance_accum?: number; + file?: { url?: string }; + }; +} + +async function fetchWahooWorkoutPage( + creds: OAuthCredentials, + page: number, +): Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; +}> { + const params = new URLSearchParams({ page: String(page), per_page: "30" }); + const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`); + return resp.json() as Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; + }>; +} + +function toImportable(w: WahooWorkout) { + return { + id: String(w.id), + name: w.name || `Workout ${w.id}`, + type: w.workout_type ?? "unknown", + startedAt: w.starts, + duration: w.workout_summary?.duration_active_accum + ? Math.round(w.workout_summary.duration_active_accum) + : null, + distance: w.workout_summary?.distance_accum + ? Math.round(w.workout_summary.distance_accum) + : null, + fileUrl: w.workout_summary?.file?.url, + }; +} + +async function fitToGpx(buffer: Buffer, name: string): Promise { + const parsed = await new Promise>((resolve, reject) => { + const parser = new FitParser({ force: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parser.parse(buffer as any, (error: unknown, data: any) => { + if (error) reject(error); + else resolve(data ?? {}); + }); + }); + + const records = (parsed.records ?? []) as Array<{ + position_lat?: number; + position_long?: number; + altitude?: number; + timestamp?: string | Date; + }>; + + const trackPoints = records + .filter((r) => r.position_lat != null && r.position_long != null) + .map((r) => ({ + lat: r.position_lat!, + lon: r.position_long!, + ele: r.altitude, + time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, + })); + + if (trackPoints.length < 2) return null; + + return generateGpx({ name, tracks: [trackPoints] }); +} + +async function downloadFit(fileUrl: string): Promise { + // Wahoo CDN URLs are pre-signed; no auth header needed (and adding one + // breaks them). + const resp = await fetch(fileUrl); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); +} + +export const wahooImporter: Importer = { + async listImportable( + ctx: CapabilityContext, + page: number, + ): Promise { + const data = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, page), + ); + + // Wahoo does not share workout data from third-party apps + // (fitness_app_id >= 1000). + const wahooOnly = data.workouts.filter( + (w) => !w.fitness_app_id || w.fitness_app_id < 1000, + ); + + return { + workouts: wahooOnly.map(toImportable), + total: data.total, + page: data.page, + perPage: data.per_page, + }; + }, + + async importOne( + ctx: CapabilityContext, + workoutId: string, + ): Promise { + // Look up the workout to get the file URL (Wahoo doesn't expose a + // direct /v1/workouts/ with file; we re-fetch the page). + // For simplicity we ask Wahoo for the workout directly; if that fails + // we fall back to scanning page 1. + const list = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, 1), + ); + const workout = list.workouts.find((w) => String(w.id) === workoutId); + if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`); + + // Resolve the connected service's user id via the capability context. + // The caller (route handler) supplies userId out-of-band — for now the + // route handler bridges the gap. We use the manager's getServiceById. + const { getServiceById } = await import("../../manager.ts"); + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const userId = service.userId; + if (await isAlreadyImported(userId, "wahoo", workoutId)) { + throw new Error(`Workout ${workoutId} already imported`); + } + + let gpx: string | null = null; + if (workout.workout_summary?.file?.url) { + const buffer = await downloadFit(workout.workout_summary.file.url); + gpx = await fitToGpx(buffer, workout.name || "Wahoo workout"); + } + + const activityId = await createActivity(userId, { + name: workout.name || `Wahoo workout ${workoutId}`, + gpx: gpx ?? undefined, + }); + await recordImport(userId, "wahoo", workoutId, activityId); + + return { activityId, hadGeometry: gpx !== null }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts new file mode 100644 index 0000000..8f5eaf2 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts @@ -0,0 +1,130 @@ +// Wahoo provider manifest. Declares credential kind, OAuth config, +// authorization/exchange flows, and capability adapters. +// +// Adding to the registry happens via providers/index.ts which imports each +// provider's barrel. Don't `import`-cycle this file from registry.ts. + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { + ProviderManifest, + CapabilityContext, +} from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { wahooImporter } from "./importer.ts"; +import { wahooPusher } from "./pusher.ts"; +import { wahooWebhook } from "./webhook.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; +const WAHOO_AUTH = "https://api.wahooligan.com/oauth"; + +const SCOPES = [ + "workouts_read", + "user_read", + "offline_data", + "routes_read", + "routes_write", +]; + +function clientId(): string { + return process.env.WAHOO_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.WAHOO_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return `${WAHOO_AUTH}/token`; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, + get revokeUrl() { + return `${WAHOO_API}/v1/permissions`; + }, +}; + +export const wahooManifest: ProviderManifest = { + id: "wahoo", + displayName: "Wahoo", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + scopes: SCOPES, + + buildAuthUrl(redirectUri: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId(), + redirect_uri: redirectUri, + response_type: "code", + scope: SCOPES.join(" "), + state, + }); + return `${WAHOO_AUTH}/authorize?${params}`; + }, + + async exchangeCode( + code: string, + redirectUri: string, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(`${WAHOO_AUTH}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId(), + client_secret: clientSecret(), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + const err = new Error(`Wahoo token exchange failed: ${resp.status} ${text}`); + // Attach a code so callers can distinguish the "too many tokens" sandbox case. + (err as Error & { code?: string }).code = text.includes("Too many unrevoked access tokens") + ? "too_many_tokens" + : "generic"; + throw err; + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + // Pull provider user id so webhook routing works. + const userResp = await fetch(`${WAHOO_API}/v1/user`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { id: number }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.id != null ? String(user.id) : null, + // Wahoo does not return a `scope` field and grants scopes + // all-or-nothing, so the requested set is the granted set. + grantedScopes: SCOPES, + }; + }, + + importer: wahooImporter, + routePusher: wahooPusher, + webhookReceiver: wahooWebhook, +}; + +// Re-export the capability adapters for direct testing access if needed. +export type { CapabilityContext }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts new file mode 100644 index 0000000..4bb2a30 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts @@ -0,0 +1,208 @@ +// Contract tests for the Wahoo RoutePusher capability adapter. +// +// Seam: pushRoute(ctx, input) -> {remoteId, version} +// +// Wahoo workarounds (FIT conversion, route: external_id, PUT-vs-POST, +// PUT->POST-on-404 fallback) are tested here as adapter-internal — they +// must not surface on the seam. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +// ---- mocks ---- + +const fetchSpy = vi.fn(); + +const mockFitConvert = vi.fn(); +vi.mock("@trails-cool/fit", () => ({ + gpxToFitCourse: (input: unknown) => mockFitConvert(input), +})); + +// sync_pushes idempotency state — manipulated per-test. +let existingPushRow: + | { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; + } + | null = null; +const insertedPushes: unknown[] = []; +const updatedPushes: unknown[] = []; + +const mockDb = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(existingPushRow ? [existingPushRow] : []), + }), + }), + }), + insert: () => ({ + values: (v: unknown) => { + insertedPushes.push(v); + return Promise.resolve(); + }, + }), + update: () => ({ + set: (patch: unknown) => ({ + where: () => { + updatedPushes.push(patch); + return Promise.resolve(); + }, + }), + }), +}; + +vi.mock("../../../db.ts", () => ({ getDb: () => mockDb })); + +vi.mock("../../manager.ts", () => ({ + getServiceById: async () => ({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + credentialKind: "oauth", + credentials: {}, + status: "active", + providerUserId: null, + grantedScopes: [], + createdAt: new Date(), + }), +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockFitConvert.mockReset(); + mockFitConvert.mockResolvedValue(new Uint8Array([0xfe, 0xed, 0xfa, 0xce])); + existingPushRow = null; + insertedPushes.length = 0; + updatedPushes.length = 0; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctx(): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(stubCreds), + }; +} + +const pushInput = { + routeId: "route-abc", + routeName: "Berlin loop", + description: "morning ride", + gpx: ` + 34 + 40 + `, + startLat: 52.5, + startLng: 13.4, + distance: 1234, + ascent: 56, + localVersion: 3, +}; + +const { wahooPusher } = await import("./pusher.ts"); + +describe("wahooPusher.pushRoute — first push (no existing row)", () => { + it("POSTs to /v1/routes with FIT body and external_id=route:", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9001 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes"); + expect((init as RequestInit).method).toBe("POST"); + const body = (init as RequestInit).body as string; + expect(body).toContain("route%5Bexternal_id%5D=route%3Aroute-abc"); + expect(body).toContain("route%5Bfile%5D=data%3Aapplication%2Fvnd.fit%3Bbase64%2C"); + expect(insertedPushes).toHaveLength(1); + }); +}); + +describe("wahooPusher.pushRoute — re-push of an unchanged route", () => { + it("short-circuits without calling Wahoo when last_pushed_version matches", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 3, + pushedAt: new Date("2026-01-01"), + error: null, + }; + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("wahooPusher.pushRoute — re-push after edit", () => { + it("PUTs to /v1/routes/ when lastPushedVersion < localVersion", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes/9001"); + expect((init as RequestInit).method).toBe("PUT"); + }); +}); + +describe("wahooPusher.pushRoute — PUT 404 fallback", () => { + it("falls back to POST and overwrites remoteId when PUT returns 404", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy + .mockResolvedValueOnce(new Response("not found", { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9999 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9999"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [, secondInit] = fetchSpy.mock.calls[1]!; + expect((secondInit as RequestInit).method).toBe("POST"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts new file mode 100644 index 0000000..365347b --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts @@ -0,0 +1,217 @@ +// Wahoo RoutePusher capability adapter. +// +// Exposes the seam shape `pushRoute(ctx, input) -> {remoteId, version}` per +// ADR-0003. Wahoo specifics — FIT-Course conversion, the `route:` +// external_id convention, the PUT-vs-POST decision based on sync_pushes, +// and the PUT-on-404 → POST fallback — live entirely inside this module +// and never appear on the seam. +// +// Idempotency state lives in `journal.sync_pushes`. The seam returns the +// remote id and the local version that was pushed; the caller is free to +// inspect the table for richer state. + +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { gpxToFitCourse } from "@trails-cool/fit"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getDb } from "../../../db.ts"; +import { getServiceById } from "../../manager.ts"; +import type { + CapabilityContext, + RoutePushInput, + RoutePushResult, + RoutePusher, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooErrorShape { + status: number; + body: string; +} + +class WahooHttpError extends Error { + shape: WahooErrorShape; + constructor(shape: WahooErrorShape) { + super(`Wahoo route ${shape.status}: ${shape.body}`); + this.shape = shape; + } +} + +function externalIdFor(routeId: string): string { + return `route:${routeId}`; +} + +function buildBody( + fit: Uint8Array, + input: RoutePushInput, +): URLSearchParams { + // Wahoo expects route[file] as a data URI, not raw base64. Sending raw + // base64 results in a route with file.url = null; the app shows + // metadata but renders no track. + const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`; + const body = new URLSearchParams({ + "route[external_id]": externalIdFor(input.routeId), + "route[provider_updated_at]": new Date().toISOString(), + "route[name]": input.routeName, + "route[workout_type_family_id]": "0", + "route[start_lat]": input.startLat.toString(), + "route[start_lng]": input.startLng.toString(), + "route[distance]": input.distance.toString(), + "route[ascent]": input.ascent.toString(), + "route[file]": fitDataUri, + }); + if (input.description) body.set("route[description]", input.description); + return body; +} + +async function postOrPut( + method: "POST" | "PUT", + url: string, + accessToken: string, + body: URLSearchParams, + fallbackRemoteId?: string, +): Promise<{ remoteId: string }> { + const resp = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (resp.ok) { + let remoteId: string | undefined; + if (resp.status !== 204) { + const data = (await resp.json().catch(() => null)) as { id?: number | string } | null; + remoteId = data?.id?.toString(); + } + remoteId ??= fallbackRemoteId; + if (!remoteId) throw new Error(`Wahoo response missing route id`); + return { remoteId }; + } + + const text = await resp.text().catch(() => ""); + throw new WahooHttpError({ status: resp.status, body: text }); +} + +interface ExistingPush { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; +} + +async function findExistingPush( + userId: string, + routeId: string, +): Promise { + const db = getDb(); + const rows = (await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, userId), + eq(syncPushes.routeId, routeId), + eq(syncPushes.provider, "wahoo"), + ), + ) + .limit(1)) as ExistingPush[]; + return rows[0] ?? null; +} + +async function recordPush( + existing: ExistingPush | null, + userId: string, + input: RoutePushInput, + remoteId: string, +): Promise { + const db = getDb(); + const now = new Date(); + if (existing) { + await db + .update(syncPushes) + .set({ + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + updatedAt: now, + }) + .where(eq(syncPushes.id, existing.id)); + } else { + await db.insert(syncPushes).values({ + id: randomUUID(), + userId, + routeId: input.routeId, + provider: "wahoo", + externalId: externalIdFor(input.routeId), + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + }); + } +} + +export const wahooPusher: RoutePusher = { + async pushRoute( + ctx: CapabilityContext, + input: RoutePushInput, + ): Promise { + // Resolve the user from the connected service so we can read/write + // sync_pushes for the right (user, route, provider) tuple. + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const existing = await findExistingPush(service.userId, input.routeId); + if ( + existing?.pushedAt && + existing.remoteId && + existing.lastPushedVersion === input.localVersion + ) { + return { remoteId: existing.remoteId, version: input.localVersion }; + } + + const fit = await gpxToFitCourse({ + gpx: input.gpx, + name: input.routeName, + description: input.description, + }); + const body = buildBody(fit, input); + + const result = await ctx.withFreshCredentials(async (creds) => { + const accessToken = (creds as OAuthCredentials).access_token; + // PUT-vs-POST: PUT in place when we have a remoteId on file. + if (existing?.remoteId) { + try { + return await postOrPut( + "PUT", + `${WAHOO_API}/v1/routes/${encodeURIComponent(existing.remoteId)}`, + accessToken, + body, + existing.remoteId, + ); + } catch (err) { + // 404 means the user deleted the route on Wahoo's side. Fall + // back to POST and overwrite the local remoteId. + if (err instanceof WahooHttpError && err.shape.status === 404) { + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + } + throw err; + } + } + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + }); + + await recordPush(existing, service.userId, input, result.remoteId); + return { remoteId: result.remoteId, version: input.localVersion }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts new file mode 100644 index 0000000..525d719 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -0,0 +1,133 @@ +// Contract tests for the Wahoo WebhookReceiver capability adapter. +// +// Seam: parseWebhook(body) -> WebhookEvent | null +// handle(event) -> void (creates an activity if file present, dedups via sync_imports) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const fetchSpy = vi.fn(); +const mockCreateActivity = vi.fn(); +const mockIsAlreadyImported = vi.fn(); +const mockRecordImport = vi.fn(); +const mockGetServiceByProviderUser = vi.fn(); +const mockWithFreshCredentials = vi.fn(); + +vi.mock("../../../activities.server.ts", () => ({ + createActivity: mockCreateActivity, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: mockIsAlreadyImported, + recordImport: mockRecordImport, +})); +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + withFreshCredentials: mockWithFreshCredentials, +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockCreateActivity.mockReset(); + mockIsAlreadyImported.mockReset(); + mockRecordImport.mockReset(); + mockGetServiceByProviderUser.mockReset(); + mockWithFreshCredentials.mockReset(); +}); + +const { wahooWebhook } = await import("./webhook.ts"); + +describe("wahooWebhook.parseWebhook", () => { + it("returns a WebhookEvent for workout_summary payloads", () => { + const event = wahooWebhook.parseWebhook({ + event_type: "workout_summary", + user: { id: 7 }, + workout_summary: { + workout: { id: 42 }, + file: { url: "https://cdn.example/42.fit" }, + }, + }); + expect(event).toEqual({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }); + }); + + it("returns null for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull(); + }); + + it("returns null when user.id is missing", () => { + expect( + wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), + ).toBeNull(); + }); +}); + +describe("wahooWebhook.handle", () => { + it("creates an activity and records the import for a known user", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(false); + mockCreateActivity.mockResolvedValue("act-1"); + // withFreshCredentials passes the credentials to fn — we don't need to + // download a file to assert the basic flow; pass a no-op file URL test + // via parseWebhook output containing fileUrl undefined to skip download. + mockWithFreshCredentials.mockImplementation( + async (_id: string, fn: (creds: unknown) => Promise) => + fn({ + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }), + ); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + // no fileUrl — the activity is created without GPX + }); + + expect(mockCreateActivity).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ name: expect.stringContaining("Wahoo") }), + ); + expect(mockRecordImport).toHaveBeenCalledWith("u1", "wahoo", "42", "act-1"); + }); + + it("silently skips when the providerUserId is unknown (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "999", + workoutId: "42", + }); + + expect(mockCreateActivity).not.toHaveBeenCalled(); + expect(mockRecordImport).not.toHaveBeenCalled(); + }); + + it("silently skips when the workout was already imported (idempotency)", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(true); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + }); + + expect(mockCreateActivity).not.toHaveBeenCalled(); + expect(mockRecordImport).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts new file mode 100644 index 0000000..2b11b68 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -0,0 +1,100 @@ +// Wahoo WebhookReceiver capability adapter. +// +// Wahoo posts `workout_summary` events to /api/sync/webhook/wahoo when a +// workout completes. We route the event to the right local user via +// provider_user_id, deduplicate via sync_imports, then download + convert +// the FIT file (if present) and create an activity. + +import FitParser from "fit-file-parser"; +import { generateGpx } from "@trails-cool/gpx"; +import { createActivity } from "../../../activities.server.ts"; +import { isAlreadyImported, recordImport } from "../../../sync/imports.server.ts"; +import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; + +interface WahooWebhookBody { + event_type?: string; + user?: { id?: number }; + workout_summary?: { + id?: number; + workout?: { id?: number }; + file?: { url?: string }; + }; +} + +async function fitToGpx(buffer: Buffer): Promise { + const parsed = await new Promise>((resolve, reject) => { + const parser = new FitParser({ force: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parser.parse(buffer as any, (error: unknown, data: any) => { + if (error) reject(error); + else resolve(data ?? {}); + }); + }); + + const records = (parsed.records ?? []) as Array<{ + position_lat?: number; + position_long?: number; + altitude?: number; + timestamp?: string | Date; + }>; + + const trackPoints = records + .filter((r) => r.position_lat != null && r.position_long != null) + .map((r) => ({ + lat: r.position_lat!, + lon: r.position_long!, + ele: r.altitude, + time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, + })); + + if (trackPoints.length < 2) return null; + return generateGpx({ name: "Wahoo workout", tracks: [trackPoints] }); +} + +export const wahooWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent | null { + const payload = body as WahooWebhookBody; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; + + return { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }; + }, + + async handle(event: WebhookEvent): Promise { + // Match incoming webhooks to local users via provider_user_id. + // Unknown users return silently — no leak. + const service = await getServiceByProviderUser("wahoo", event.providerUserId); + if (!service) return; + + if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return; + + let gpx: string | null = null; + if (event.fileUrl) { + // Wahoo CDN URLs are pre-signed; no auth header needed. We still go + // through withFreshCredentials so the manager has a chance to refresh + // a near-expired credential before any subsequent Wahoo call this + // handler might make. + const buffer = await withFreshCredentials(service.id, async (_creds) => { + void (_creds as unknown as OAuthCredentials); + const resp = await fetch(event.fileUrl!); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); + }); + gpx = await fitToGpx(buffer); + } + + const activityId = await createActivity(service.userId, { + name: `Wahoo workout`, + gpx: gpx ?? undefined, + }); + await recordImport(service.userId, "wahoo", event.workoutId, activityId); + }, +}; diff --git a/apps/journal/app/lib/connected-services/push-action.server.ts b/apps/journal/app/lib/connected-services/push-action.server.ts new file mode 100644 index 0000000..a42039f --- /dev/null +++ b/apps/journal/app/lib/connected-services/push-action.server.ts @@ -0,0 +1,115 @@ +// Orchestrates a route push: load route, check ownership, check scopes, +// build the RoutePushInput, and invoke the provider's RoutePusher +// capability. Replaces the legacy pushRouteToProvider in lib/sync. +// +// The pusher (per-provider) handles HTTP, FIT conversion, idempotency, +// and PUT/POST/404 fallback. This module is the orchestration layer +// callers use (route handlers, OAuth callback resume). + +import { desc, eq } from "drizzle-orm"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { routeVersions } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { getRoute } from "../routes.server.ts"; +import { + ConnectionNotActiveError, + NeedsRelinkError, +} from "./types.ts"; +import { capabilityContextFor, getService } from "./manager.ts"; +import { getManifest, type RoutePushInput } from "./registry.ts"; + +export type PushOutcome = + | { status: "success"; remoteId: string; pushedAt: Date } + | { status: "scope_missing" } + | { status: "no_connection" } + | { status: "not_owner" } + | { status: "not_found" } + | { status: "unsupported_provider" } + | { status: "no_geometry" } + | { status: "needs_relink" } + | { + status: "error"; + code: "validation" | "rate_limit" | "token_expired" | "generic"; + message: string; + }; + +export interface PushRouteOptions { + userId: string; + providerId: string; + routeId: string; +} + +export async function pushRouteToProvider( + opts: PushRouteOptions, +): Promise { + const { userId, providerId, routeId } = opts; + const db = getDb(); + + const manifest = getManifest(providerId); + if (!manifest) return { status: "not_found" }; + if (!manifest.routePusher) return { status: "unsupported_provider" }; + + const route = await getRoute(routeId); + if (!route) return { status: "not_found" }; + if (route.ownerId !== userId) return { status: "not_owner" }; + + const service = await getService(userId, providerId); + if (!service) return { status: "no_connection" }; + if (!service.grantedScopes.includes("routes_write")) { + return { status: "scope_missing" }; + } + + // Pull the locked-in version GPX (not routes.gpx, which is the working copy). + const [latestVersion] = await db + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, routeId)) + .orderBy(desc(routeVersions.version)) + .limit(1); + + const versionGpx = latestVersion?.gpx ?? route.gpx; + const versionNumber = latestVersion?.version ?? 1; + if (!versionGpx) return { status: "no_geometry" }; + + const parsed = await parseGpxAsync(versionGpx); + const points = parsed.tracks.flat(); + if (points.length === 0) return { status: "no_geometry" }; + + const input: RoutePushInput = { + routeId, + routeName: route.name, + description: route.description ?? undefined, + gpx: versionGpx, + startLat: points[0]!.lat, + startLng: points[0]!.lon, + distance: parsed.distance, + ascent: parsed.elevation.gain, + localVersion: versionNumber, + }; + + try { + const ctx = capabilityContextFor(service.id); + const result = await manifest.routePusher.pushRoute(ctx, input); + return { + status: "success", + remoteId: result.remoteId, + pushedAt: new Date(), + }; + } catch (err) { + if (err instanceof NeedsRelinkError) return { status: "needs_relink" }; + if (err instanceof ConnectionNotActiveError) return { status: "needs_relink" }; + const message = err instanceof Error ? err.message : String(err); + // Map known HTTP-shape errors. The pusher throws Error / WahooHttpError; + // we don't try to recover further here. + if (message.includes("422")) { + return { status: "error", code: "validation", message }; + } + if (message.includes("429")) { + return { status: "error", code: "rate_limit", message }; + } + if (message.includes("401") || message.includes("403")) { + return { status: "error", code: "token_expired", message }; + } + return { status: "error", code: "generic", message }; + } +} diff --git a/apps/journal/app/lib/sync/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts deleted file mode 100644 index 0067491..0000000 --- a/apps/journal/app/lib/sync/connections.server.ts +++ /dev/null @@ -1,127 +0,0 @@ -// COMPATIBILITY SHIM — translates the legacy TokenSet-shaped API onto the -// new connected_services / JSONB-credentials schema introduced by -// deepen-connected-services. Once the routes and pushes.server.ts migrate -// to ConnectedServiceManager (tasks 5.x of the change), this whole file -// (and the rest of apps/journal/app/lib/sync/) goes away. -// -// Do not add new callers. Use apps/journal/app/lib/connected-services/ -// directly. - -import { randomUUID } from "node:crypto"; -import { eq, and } from "drizzle-orm"; -import { getDb } from "../db.ts"; -import { connectedServices } from "@trails-cool/db/schema/journal"; -import type { TokenSet } from "./types.ts"; - -interface OAuthBlob { - access_token: string; - refresh_token: string; - expires_at: string; -} - -interface LegacyConnection { - id: string; - userId: string; - provider: string; - accessToken: string; - refreshToken: string; - expiresAt: Date; - providerUserId: string | null; - grantedScopes: string[]; - createdAt: Date; -} - -function toLegacy(row: typeof connectedServices.$inferSelect): LegacyConnection { - const blob = row.credentials as OAuthBlob; - return { - id: row.id, - userId: row.userId, - provider: row.provider, - accessToken: blob.access_token, - refreshToken: blob.refresh_token, - expiresAt: new Date(blob.expires_at), - providerUserId: row.providerUserId, - grantedScopes: row.grantedScopes, - createdAt: row.createdAt, - }; -} - -function toBlob(tokens: TokenSet): OAuthBlob { - return { - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken, - expires_at: tokens.expiresAt.toISOString(), - }; -} - -export async function saveConnection( - userId: string, - provider: string, - tokens: TokenSet, - grantedScopes: string[] = [], -) { - const db = getDb(); - await db - .delete(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); - await db.insert(connectedServices).values({ - id: randomUUID(), - userId, - provider, - credentialKind: "oauth", - credentials: toBlob(tokens), - status: "active", - providerUserId: tokens.providerUserId ?? null, - grantedScopes, - }); -} - -export async function getConnection( - userId: string, - provider: string, -): Promise { - const db = getDb(); - const [row] = await db - .select() - .from(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); - return row ? toLegacy(row) : null; -} - -export async function getConnectionByProviderUser( - provider: string, - providerUserId: string, -): Promise { - const db = getDb(); - const [row] = await db - .select() - .from(connectedServices) - .where( - and( - eq(connectedServices.provider, provider), - eq(connectedServices.providerUserId, providerUserId), - ), - ); - return row ? toLegacy(row) : null; -} - -export async function updateTokens(connectionId: string, tokens: TokenSet) { - const db = getDb(); - await db - .update(connectedServices) - .set({ credentials: toBlob(tokens) }) - .where(eq(connectedServices.id, connectionId)); -} - -export async function deleteConnection(userId: string, provider: string) { - const db = getDb(); - await db - .delete(connectedServices) - .where( - and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), - ); -} diff --git a/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit b/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit deleted file mode 100644 index 94e96ef0fafeb46c8da9545a9b8a50792e76b8d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34734 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 OZ->2lWmlD0`~Lu3eFd}t diff --git a/apps/journal/app/lib/sync/providers/wahoo.test.ts b/apps/journal/app/lib/sync/providers/wahoo.test.ts deleted file mode 100644 index ebc7de9..0000000 --- a/apps/journal/app/lib/sync/providers/wahoo.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { wahooProvider } from "./wahoo"; -import { PushError, OAuthError } from "../types.ts"; - -const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit"); -const fitBuffer = readFileSync(fixturePath); - -describe("wahooProvider.convertToGpx", () => { - it("converts a FIT file to valid GPX with correct coordinates", async () => { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - - expect(gpx).not.toBeNull(); - expect(gpx).toContain(' { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - expect(gpx).not.toBeNull(); - - const latMatches = gpx!.matchAll(/lat="([^"]+)"/g); - const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g); - - const lats = [...latMatches].map((m) => parseFloat(m[1]!)); - const lons = [...lonMatches].map((m) => parseFloat(m[1]!)); - - expect(lats.length).toBeGreaterThan(10); - expect(lons.length).toBeGreaterThan(10); - - for (const lat of lats) { - expect(lat).toBeGreaterThan(-90); - expect(lat).toBeLessThan(90); - // Should be real-world coords, not near-zero from double-conversion - expect(Math.abs(lat)).toBeGreaterThan(1); - } - for (const lon of lons) { - expect(lon).toBeGreaterThan(-180); - expect(lon).toBeLessThan(180); - } - }); - - it("includes ISO 8601 timestamps (not Date objects)", async () => { - const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); - expect(gpx).not.toBeNull(); - - const timeMatches = gpx!.matchAll(/