From a9c0093877cd53ac56baa618388858c879734f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 30 Apr 2026 22:41:24 +0200 Subject: [PATCH] Add Wahoo push action route + OAuth callback resume Adds /api/sync/push/:provider/:routeId as the user-triggered entry point for pushing a route. The route action delegates to a new pushes.server.ts pipeline that: - resolves the latest route_versions row and uses that GPX (not routes.gpx) so the bytes Wahoo gets match the snapshot the user sees - short-circuits on (user, route, version, provider) idempotency: a successful prior push returns the existing remote_id without re-calling Wahoo - detects scope_missing before hitting Wahoo and redirects through getAuthUrl with a base64url-encoded state carrying pushAfter + returnTo - refreshes tokens once on PushError({ code: "token_expired" }) and retries the push, then updates sync_connections in place - records every outcome in sync_pushes (insert on first attempt, update on retry) so the UI can show success/failure state The OAuth callback handler now decodes the state, resumes a pushAfter pipeline server-side after exchangeCode, and handles the ?error=access_denied path with a needs_permission notice. Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect shim so journal's tsc picks up the @garmin/fitsdk module declaration when consuming the workspace package via source. Co-Authored-By: Claude Opus 4.7 --- .../app/lib/sync/pushes.server.test.ts | 245 ++++++++++++++++++ apps/journal/app/lib/sync/pushes.server.ts | 194 ++++++++++++++ apps/journal/app/routes.ts | 1 + .../app/routes/api.sync.callback.$provider.ts | 27 +- .../api.sync.push.$provider.$routeId.ts | 50 ++++ openspec/changes/wahoo-route-push/tasks.md | 20 +- .../fit/src/{fitsdk.d.ts => fitsdk-shim.ts} | 0 packages/fit/src/gpx-to-fit-course.ts | 1 + 8 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 apps/journal/app/lib/sync/pushes.server.test.ts create mode 100644 apps/journal/app/lib/sync/pushes.server.ts create mode 100644 apps/journal/app/routes/api.sync.push.$provider.$routeId.ts rename packages/fit/src/{fitsdk.d.ts => fitsdk-shim.ts} (100%) diff --git a/apps/journal/app/lib/sync/pushes.server.test.ts b/apps/journal/app/lib/sync/pushes.server.test.ts new file mode 100644 index 0000000..2f6fc3c --- /dev/null +++ b/apps/journal/app/lib/sync/pushes.server.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const SHORT_GPX = ` + + + 34.0 + 34.5 + 35.0 + +`; + +const mockGetRoute = vi.fn(); +const mockGetConnection = vi.fn(); +const mockUpdateTokens = vi.fn(); +const mockGetProvider = vi.fn(); + +const mockSelect = vi.fn(); +const mockInsert = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock("../db.ts", () => ({ + getDb: () => ({ + select: mockSelect, + insert: mockInsert, + update: mockUpdate, + }), +})); +vi.mock("../routes.server.ts", () => ({ getRoute: mockGetRoute })); +vi.mock("./connections.server.ts", () => ({ + getConnection: mockGetConnection, + updateTokens: mockUpdateTokens, +})); +vi.mock("./registry.ts", () => ({ getProvider: mockGetProvider })); + +// Each call to db.select returns a chainable thenable resolving to the next +// queued result. The pipeline does two selects: routeVersions, sync_pushes. +function queueSelectResults(...results: unknown[][]) { + for (const r of results) { + mockSelect.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: () => Promise.resolve(r), + }), + limit: () => Promise.resolve(r), + }), + }), + }); + } +} + +function chainNoop() { + return { + values: () => Promise.resolve(), + set: () => ({ where: () => Promise.resolve() }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockImplementation(() => chainNoop()); + mockUpdate.mockImplementation(() => chainNoop()); +}); + +async function importPipeline() { + const mod = await import("./pushes.server.ts"); + return mod; +} + +const baseRoute = { + id: "r1", + ownerId: "u1", + name: "Test route", + description: "A test", + gpx: SHORT_GPX, +}; +const baseConnection = { + id: "c1", + userId: "u1", + provider: "wahoo", + accessToken: "tok", + refreshToken: "ref", + expiresAt: new Date(Date.now() + 3600_000), + providerUserId: "42", + grantedScopes: ["routes_write", "workouts_read"], +}; + +function makeProvider(over: Record = {}) { + return { + id: "wahoo", + name: "Wahoo", + scopes: ["routes_write"], + pushRoute: vi.fn(), + refreshToken: vi.fn(), + ...over, + }; +} + +describe("pushRouteToProvider", () => { + it("performs a fresh push and records sync_pushes", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(baseConnection); + queueSelectResults([{ version: 2, gpx: SHORT_GPX }], []); // route version, sync_pushes existing + + const provider = makeProvider(); + provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-9" }); + mockGetProvider.mockReturnValue(provider); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toMatchObject({ status: "success", remoteId: "wahoo-9" }); + expect(provider.pushRoute).toHaveBeenCalledOnce(); + const [, payload] = provider.pushRoute.mock.calls[0]!; + expect(payload.externalId).toBe("route:r1:v2"); + expect(payload.startLat).toBeCloseTo(52.52, 4); + expect(mockInsert).toHaveBeenCalledOnce(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("short-circuits when the version is already pushed", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(baseConnection); + queueSelectResults( + [{ version: 1, gpx: SHORT_GPX }], + [{ id: "p1", pushedAt: new Date("2026-04-01T00:00:00Z"), remoteId: "wahoo-7" }], + ); + + const provider = makeProvider(); + mockGetProvider.mockReturnValue(provider); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toMatchObject({ status: "success", remoteId: "wahoo-7" }); + expect(provider.pushRoute).not.toHaveBeenCalled(); + }); + + it("retries a failed prior attempt and updates the existing row", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(baseConnection); + queueSelectResults( + [{ version: 1, gpx: SHORT_GPX }], + [{ id: "p1", pushedAt: null, remoteId: null, error: "generic: boom" }], + ); + + const provider = makeProvider(); + provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-77" }); + mockGetProvider.mockReturnValue(provider); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toMatchObject({ status: "success", remoteId: "wahoo-77" }); + expect(mockUpdate).toHaveBeenCalledOnce(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("redirects via scope_missing when grantedScopes lacks routes_write", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue({ ...baseConnection, grantedScopes: ["workouts_read"] }); + mockGetProvider.mockReturnValue(makeProvider()); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toEqual({ status: "scope_missing" }); + }); + + it("returns no_connection when no sync_connections row exists", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(null); + mockGetProvider.mockReturnValue(makeProvider()); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + expect(out).toEqual({ status: "no_connection" }); + }); + + it("returns not_owner when the requesting user isn't the owner", async () => { + mockGetRoute.mockResolvedValue({ ...baseRoute, ownerId: "other" }); + mockGetProvider.mockReturnValue(makeProvider()); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + expect(out).toEqual({ status: "not_owner" }); + }); + + it("refreshes tokens and retries once on token_expired", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(baseConnection); + queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []); + + const provider = makeProvider(); + const { PushError } = await import("./types.ts"); + provider.pushRoute + .mockRejectedValueOnce(new PushError("token_expired", "401", 401)) + .mockResolvedValueOnce({ remoteId: "wahoo-after-refresh" }); + provider.refreshToken.mockResolvedValue({ + accessToken: "new-tok", + refreshToken: "new-ref", + expiresAt: new Date(Date.now() + 3600_000), + }); + mockGetProvider.mockReturnValue(provider); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toMatchObject({ status: "success", remoteId: "wahoo-after-refresh" }); + expect(provider.refreshToken).toHaveBeenCalledOnce(); + expect(provider.pushRoute).toHaveBeenCalledTimes(2); + expect(mockUpdateTokens).toHaveBeenCalledWith("c1", expect.objectContaining({ accessToken: "new-tok" })); + }); + + it("records validation errors and surfaces them to the caller", async () => { + mockGetRoute.mockResolvedValue(baseRoute); + mockGetConnection.mockResolvedValue(baseConnection); + queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []); + + const provider = makeProvider(); + const { PushError } = await import("./types.ts"); + provider.pushRoute.mockRejectedValue(new PushError("validation", "bad name", 422)); + mockGetProvider.mockReturnValue(provider); + + const { pushRouteToProvider } = await importPipeline(); + const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" }); + + expect(out).toMatchObject({ status: "error", code: "validation" }); + expect(mockInsert).toHaveBeenCalledOnce(); + }); +}); + +describe("encodeOAuthState / decodeOAuthState", () => { + it("round-trips a push-after state", async () => { + const { encodeOAuthState, decodeOAuthState } = await importPipeline(); + const encoded = encodeOAuthState({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" }); + expect(decodeOAuthState(encoded)).toEqual({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" }); + }); + + it("returns empty object for missing or malformed state", async () => { + const { decodeOAuthState } = await importPipeline(); + expect(decodeOAuthState(null)).toEqual({}); + expect(decodeOAuthState("not-base64-json!!")).toEqual({}); + }); +}); diff --git a/apps/journal/app/lib/sync/pushes.server.ts b/apps/journal/app/lib/sync/pushes.server.ts new file mode 100644 index 0000000..3100a51 --- /dev/null +++ b/apps/journal/app/lib/sync/pushes.server.ts @@ -0,0 +1,194 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq } from "drizzle-orm"; +import { gpxToFitCourse } from "@trails-cool/fit"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { routeVersions, syncPushes } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { getRoute } from "../routes.server.ts"; +import { getConnection, updateTokens } from "./connections.server.ts"; +import { getProvider } from "./registry.ts"; +import { PushError, providerSupportsPush } from "./types.ts"; +import type { TokenSet } from "./types.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: "error"; code: "validation" | "rate_limit" | "token_expired" | "generic"; message: string }; + +export interface PushRouteOptions { + userId: string; + providerId: string; + routeId: string; +} + +/** + * End-to-end push pipeline. Resolves the route, checks ownership and scopes, + * short-circuits on already-pushed versions, refreshes tokens on 401, and + * records the outcome in `sync_pushes`. + */ +export async function pushRouteToProvider(opts: PushRouteOptions): Promise { + const { userId, providerId, routeId } = opts; + const db = getDb(); + + const provider = getProvider(providerId); + if (!provider) return { status: "not_found" }; + if (!providerSupportsPush(provider)) return { status: "unsupported_provider" }; + + const route = await getRoute(routeId); + if (!route) return { status: "not_found" }; + if (route.ownerId !== userId) return { status: "not_owner" }; + + const connection = await getConnection(userId, providerId); + if (!connection) return { status: "no_connection" }; + if (!connection.grantedScopes.includes("routes_write")) return { status: "scope_missing" }; + + 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" }; + + // Idempotency: if this exact (user, route, version) was already pushed, return that result. + const existing = await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, userId), + eq(syncPushes.routeId, routeId), + eq(syncPushes.routeVersion, versionNumber), + eq(syncPushes.provider, providerId), + ), + ) + .limit(1); + const existingRow = existing[0]; + if (existingRow?.pushedAt && existingRow.remoteId) { + return { status: "success", remoteId: existingRow.remoteId, pushedAt: existingRow.pushedAt }; + } + + // Build payload from the locked-in version GPX, not routes.gpx. + const parsed = await parseGpxAsync(versionGpx); + const points = parsed.tracks.flat(); + if (points.length === 0) return { status: "no_geometry" }; + + const fit = await gpxToFitCourse({ + gpx: versionGpx, + name: route.name, + description: route.description ?? undefined, + }); + + const externalId = `route:${routeId}:v${versionNumber}`; + const payload = { + fit, + externalId, + providerUpdatedAt: new Date(), + name: route.name, + description: route.description ?? undefined, + startLat: points[0]!.lat, + startLng: points[0]!.lon, + distance: parsed.distance, + ascent: parsed.elevation.gain, + }; + + let tokens: TokenSet = { + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt, + providerUserId: connection.providerUserId ?? undefined, + }; + + if (Date.now() >= tokens.expiresAt.getTime()) { + tokens = await provider.refreshToken(tokens.refreshToken); + await updateTokens(connection.id, tokens); + } + + const recordResult = async ( + success: boolean, + remoteId: string | null, + error: string | null, + ): Promise => { + const now = new Date(); + if (existingRow) { + await db + .update(syncPushes) + .set({ + remoteId, + pushedAt: success ? now : null, + error, + updatedAt: now, + }) + .where(eq(syncPushes.id, existingRow.id)); + } else { + await db.insert(syncPushes).values({ + id: randomUUID(), + userId, + routeId, + routeVersion: versionNumber, + provider: providerId, + externalId, + remoteId, + pushedAt: success ? now : null, + error, + }); + } + }; + + try { + let result; + try { + result = await provider.pushRoute(tokens, payload); + } catch (e) { + if (e instanceof PushError && e.code === "token_expired") { + // 401 mid-call — refresh once and retry. + tokens = await provider.refreshToken(tokens.refreshToken); + await updateTokens(connection.id, tokens); + result = await provider.pushRoute(tokens, payload); + } else { + throw e; + } + } + await recordResult(true, result.remoteId, null); + return { status: "success", remoteId: result.remoteId, pushedAt: new Date() }; + } catch (e) { + if (e instanceof PushError) { + await recordResult(false, null, `${e.code}: ${e.message}`); + if (e.code === "scope_missing") return { status: "scope_missing" }; + return { status: "error", code: e.code, message: e.message }; + } + const message = e instanceof Error ? e.message : String(e); + await recordResult(false, null, `generic: ${message}`); + return { status: "error", code: "generic", message }; + } +} + +// State payload encoded into the OAuth `state` query param so the callback +// can resume a push that was interrupted by a re-auth redirect. +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/routes.ts b/apps/journal/app/routes.ts index 28aec4c..4c51727 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -52,6 +52,7 @@ export default [ route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), + route("api/sync/push/:provider/:routeId", "routes/api.sync.push.$provider.$routeId.ts"), route("privacy", "routes/privacy.tsx"), route("legal/imprint", "routes/legal.imprint.tsx"), route("legal/terms", "routes/legal.terms.tsx"), diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 7f3b88b..4ad85c5 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -3,6 +3,7 @@ import type { Route } from "./+types/api.sync.callback.$provider"; import { getSessionUser } from "~/lib/auth.server"; import { getProvider } from "~/lib/sync/registry"; import { saveConnection } from "~/lib/sync/connections.server"; +import { decodeOAuthState, pushRouteToProvider } from "~/lib/sync/pushes.server"; export async function loader({ params, request }: Route.LoaderArgs) { const user = await getSessionUser(request); @@ -12,6 +13,15 @@ export async function loader({ params, request }: Route.LoaderArgs) { if (!provider) return data({ error: "Unknown provider" }, { status: 404 }); const url = new URL(request.url); + const state = decodeOAuthState(url.searchParams.get("state")); + const fallbackReturn = state.returnTo ?? "/settings"; + + // User denied the new scope at Wahoo. Send them back to the originating + // page with a notice instead of looping them through OAuth again. + if (url.searchParams.get("error") === "access_denied") { + return redirect(`${fallbackReturn}?push=needs_permission`); + } + const code = url.searchParams.get("code"); if (!code) return data({ error: "Missing authorization code" }, { status: 400 }); @@ -25,8 +35,21 @@ export async function loader({ params, request }: Route.LoaderArgs) { await saveConnection(user.id, provider.id, tokens, provider.scopes); } catch (e) { console.error(`OAuth callback failed for ${params.provider}:`, e); - return redirect("/settings?error=sync_failed"); + return redirect(`${fallbackReturn}?error=sync_failed`); } - return redirect("/settings"); + if (state.pushAfter?.routeId) { + const outcome = await pushRouteToProvider({ + userId: user.id, + providerId: provider.id, + routeId: state.pushAfter.routeId, + }); + const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`; + if (outcome.status === "success") return redirect(`${target}?push=success`); + if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`); + if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`); + return redirect(`${target}?push=${outcome.status}`); + } + + return redirect(state.returnTo ?? "/settings"); } diff --git a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts new file mode 100644 index 0000000..c865ee2 --- /dev/null +++ b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts @@ -0,0 +1,50 @@ +import { redirect, data } from "react-router"; +import type { Route } from "./+types/api.sync.push.$provider.$routeId"; +import { getSessionUser } from "~/lib/auth.server"; +import { getProvider } from "~/lib/sync/registry"; +import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server"; + +export async function action({ params, request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const provider = getProvider(params.provider); + if (!provider) return data({ error: "Unknown provider" }, { status: 404 }); + + const returnTo = `/routes/${params.routeId}`; + const outcome = await pushRouteToProvider({ + userId: user.id, + providerId: provider.id, + routeId: params.routeId, + }); + + switch (outcome.status) { + case "success": + return redirect(`${returnTo}?push=success`); + case "scope_missing": { + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const redirectUri = `${origin}/api/sync/callback/${provider.id}`; + const state = encodeOAuthState({ + pushAfter: { routeId: params.routeId }, + returnTo, + }); + return redirect(provider.getAuthUrl(redirectUri, state)); + } + case "no_connection": + return redirect(`${returnTo}?push=no_connection`); + case "no_geometry": + return redirect(`${returnTo}?push=no_geometry`); + case "not_owner": + return data({ error: "Forbidden" }, { status: 403 }); + case "not_found": + return data({ error: "Not found" }, { status: 404 }); + case "unsupported_provider": + return data({ error: "Provider does not support push" }, { status: 400 }); + case "error": + return redirect(`${returnTo}?push=error&code=${outcome.code}`); + } +} + +export function loader() { + return data({ error: "Method not allowed" }, { status: 405 }); +} diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 9d3fdcc..0749fa1 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -31,17 +31,17 @@ ## 5. Push action route and idempotency layer -- [ ] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push -- [ ] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }` -- [ ] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo -- [ ] 5.4 On a fresh push, build the payload (`external_id = "route::v"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt) -- [ ] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry +- [x] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push +- [x] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }` +- [x] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo +- [x] 5.4 On a fresh push, build the payload (`external_id = "route::v"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt) +- [x] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry ## 6. OAuth callback resumes pending push -- [ ] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to` -- [ ] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message -- [ ] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice +- [x] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to` +- [x] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message +- [x] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice ## 7. Route detail page UI @@ -60,8 +60,8 @@ ## 9. Tests - [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7 -- [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit -- [ ] 9.3 Unit tests for the scope-mismatch redirect flow +- [x] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit +- [x] 9.3 Unit tests for the scope-mismatch redirect flow - [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear - [ ] 9.5 E2E test for the re-auth flow: user without `routes_write` clicks Send to Wahoo, redirects to OAuth (mocked), returns, push completes, status renders diff --git a/packages/fit/src/fitsdk.d.ts b/packages/fit/src/fitsdk-shim.ts similarity index 100% rename from packages/fit/src/fitsdk.d.ts rename to packages/fit/src/fitsdk-shim.ts diff --git a/packages/fit/src/gpx-to-fit-course.ts b/packages/fit/src/gpx-to-fit-course.ts index 066bd0c..7f877eb 100644 --- a/packages/fit/src/gpx-to-fit-course.ts +++ b/packages/fit/src/gpx-to-fit-course.ts @@ -1,3 +1,4 @@ +import "./fitsdk-shim.ts"; import { Encoder, Profile } from "@garmin/fitsdk"; import { parseGpxAsync } from "@trails-cool/gpx"; import type { TrackPoint } from "@trails-cool/gpx";