Apply wahoo-route-update: PUT on re-push instead of duplicate POST

Re-pushing an edited route to Wahoo now updates the existing remote
route via PUT against the stored remote_id instead of POSTing a new
copy. external_id drops the version suffix and identifies the logical
route. sync_pushes is keyed by (user, route, provider) and tracks
last_pushed_version for the "local newer" UI state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-01 12:08:16 +02:00
parent 4853a116ff
commit db3eeed60f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
14 changed files with 435 additions and 99 deletions

View file

@ -122,6 +122,7 @@ describe("wahooProvider.pushRoute", () => {
it.each([
[401, "token_expired"],
[403, "scope_missing"],
[404, "not_found"],
[422, "validation"],
[429, "rate_limit"],
[500, "generic"],
@ -149,6 +150,81 @@ describe("wahooProvider.pushRoute", () => {
});
});
describe("wahooProvider.updateRoute", () => {
const tokens = {
accessToken: "test-token",
refreshToken: "refresh",
expiresAt: new Date(Date.now() + 3600_000),
};
const fit = new Uint8Array([0x01, 0x02, 0x03]);
const basePayload = {
fit,
externalId: "route:abc",
providerUpdatedAt: new Date("2026-04-30T12:00:00Z"),
name: "Test route",
description: "A test",
startLat: 52.52,
startLng: 13.405,
distance: 12345,
ascent: 200,
};
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("PUTs to /v1/routes/:id and keeps the same remote id", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 9876 }),
});
const result = await wahooProvider.updateRoute!(tokens, "9876", basePayload);
expect(result.remoteId).toBe("9876");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.wahooligan.com/v1/routes/9876");
expect(init.method).toBe("PUT");
const body = new URLSearchParams(init.body as string);
expect(body.get("route[external_id]")).toBe("route:abc");
expect(body.get("route[file]")).toBe(
`data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`,
);
});
it("falls back to the targeted remote id when PUT returns 204 (no body)", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 204,
json: async () => null,
});
const result = await wahooProvider.updateRoute!(tokens, "555", basePayload);
expect(result.remoteId).toBe("555");
});
it("throws PushError(not_found) on 404 so the pipeline can fall back to POST", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => "gone",
});
await expect(wahooProvider.updateRoute!(tokens, "missing", basePayload)).rejects.toMatchObject({
name: "PushError",
code: "not_found",
status: 404,
});
});
});
describe("wahooProvider.exchangeCode", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {

View file

@ -17,6 +17,65 @@ const WAHOO_AUTH = "https://api.wahooligan.com/oauth";
const clientId = () => process.env.WAHOO_CLIENT_ID ?? "";
const clientSecret = () => process.env.WAHOO_CLIENT_SECRET ?? "";
function buildRouteFormBody(payload: PushRoutePayload): URLSearchParams {
// Wahoo expects route[file] as a data URI, not raw base64. Sending plain
// base64 results in a route record where file.url is null and the Wahoo
// app shows the route in the list (with metadata) but renders no track.
const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(payload.fit).toString("base64")}`;
const body = new URLSearchParams({
"route[external_id]": payload.externalId,
"route[provider_updated_at]": payload.providerUpdatedAt.toISOString(),
"route[name]": payload.name,
"route[workout_type_family_id]": "0",
"route[start_lat]": payload.startLat.toString(),
"route[start_lng]": payload.startLng.toString(),
"route[distance]": payload.distance.toString(),
"route[ascent]": payload.ascent.toString(),
"route[file]": fitDataUri,
});
if (payload.description) body.set("route[description]", payload.description);
if (payload.filename) body.set("route[filename]", payload.filename);
return body;
}
async function wahooRouteRequest(
tokens: TokenSet,
method: "POST" | "PUT",
url: string,
payload: PushRoutePayload,
opts: { fallbackRemoteId?: string } = {},
): Promise<PushRouteResult> {
const resp = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: buildRouteFormBody(payload).toString(),
});
if (resp.ok) {
// PUT may respond 200/204 without a body. The remote id is unchanged in
// that case, so fall back to the one we just targeted.
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 ??= opts.fallbackRemoteId;
if (!remoteId) throw new PushError("generic", "Wahoo response missing route id", resp.status);
return { remoteId };
}
const text = await resp.text().catch(() => "");
if (resp.status === 401) throw new PushError("token_expired", text || "Unauthorized", 401);
if (resp.status === 403) throw new PushError("scope_missing", text || "Forbidden", 403);
if (resp.status === 404) throw new PushError("not_found", text || "Not Found", 404);
if (resp.status === 422) throw new PushError("validation", text || "Validation failed", 422);
if (resp.status === 429) throw new PushError("rate_limit", text || "Rate limited", 429);
throw new PushError("generic", `Wahoo route ${method} failed: ${resp.status} ${text}`, resp.status);
}
export const wahooProvider: SyncProvider = {
id: "wahoo",
name: "Wahoo",
@ -181,46 +240,21 @@ export const wahooProvider: SyncProvider = {
},
async pushRoute(tokens: TokenSet, payload: PushRoutePayload): Promise<PushRouteResult> {
// Wahoo expects route[file] as a data URI, not raw base64. Sending plain
// base64 results in a route record where file.url is null and the Wahoo
// app shows the route in the list (with metadata) but renders no track.
const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(payload.fit).toString("base64")}`;
const body = new URLSearchParams({
"route[external_id]": payload.externalId,
"route[provider_updated_at]": payload.providerUpdatedAt.toISOString(),
"route[name]": payload.name,
"route[workout_type_family_id]": "0",
"route[start_lat]": payload.startLat.toString(),
"route[start_lng]": payload.startLng.toString(),
"route[distance]": payload.distance.toString(),
"route[ascent]": payload.ascent.toString(),
"route[file]": fitDataUri,
});
if (payload.description) body.set("route[description]", payload.description);
if (payload.filename) body.set("route[filename]", payload.filename);
return wahooRouteRequest(tokens, "POST", `${WAHOO_API}/v1/routes`, payload);
},
const resp = await fetch(`${WAHOO_API}/v1/routes`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
if (resp.ok) {
const data = (await resp.json()) as { id?: number | string };
const remoteId = data.id?.toString();
if (!remoteId) throw new PushError("generic", "Wahoo response missing route id", resp.status);
return { remoteId };
}
const text = await resp.text().catch(() => "");
if (resp.status === 401) throw new PushError("token_expired", text || "Unauthorized", 401);
if (resp.status === 403) throw new PushError("scope_missing", text || "Forbidden", 403);
if (resp.status === 422) throw new PushError("validation", text || "Validation failed", 422);
if (resp.status === 429) throw new PushError("rate_limit", text || "Rate limited", 429);
throw new PushError("generic", `Wahoo route push failed: ${resp.status} ${text}`, resp.status);
async updateRoute(
tokens: TokenSet,
remoteId: string,
payload: PushRoutePayload,
): Promise<PushRouteResult> {
return wahooRouteRequest(
tokens,
"PUT",
`${WAHOO_API}/v1/routes/${encodeURIComponent(remoteId)}`,
payload,
{ fallbackRemoteId: remoteId },
);
},
async revoke(tokens: TokenSet): Promise<void> {

View file

@ -91,6 +91,7 @@ function makeProvider(over: Record<string, unknown> = {}) {
name: "Wahoo",
scopes: ["routes_write"],
pushRoute: vi.fn(),
updateRoute: vi.fn(),
refreshToken: vi.fn(),
...over,
};
@ -112,18 +113,23 @@ describe("pushRouteToProvider", () => {
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.externalId).toBe("route:r1");
expect(payload.startLat).toBeCloseTo(52.52, 4);
expect(mockInsert).toHaveBeenCalledOnce();
expect(mockUpdate).not.toHaveBeenCalled();
});
it("short-circuits when the version is already pushed", async () => {
it("short-circuits when the current 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" }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-7",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
@ -134,6 +140,64 @@ describe("pushRouteToProvider", () => {
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-7" });
expect(provider.pushRoute).not.toHaveBeenCalled();
expect(provider.updateRoute).not.toHaveBeenCalled();
});
it("PUTs against the existing remote id when the local version has advanced", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 2, gpx: SHORT_GPX }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-7",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
provider.updateRoute.mockResolvedValue({ remoteId: "wahoo-7" });
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.updateRoute).toHaveBeenCalledOnce();
expect(provider.pushRoute).not.toHaveBeenCalled();
// Existing row updated in place — exactly one row exists afterwards.
expect(mockUpdate).toHaveBeenCalledOnce();
expect(mockInsert).not.toHaveBeenCalled();
});
it("falls back to POST when PUT returns not_found", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 2, gpx: SHORT_GPX }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-stale",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
const { PushError } = await import("./types.ts");
provider.updateRoute.mockRejectedValueOnce(new PushError("not_found", "gone", 404));
provider.pushRoute.mockResolvedValueOnce({ remoteId: "wahoo-fresh" });
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-fresh" });
expect(provider.updateRoute).toHaveBeenCalledOnce();
expect(provider.pushRoute).toHaveBeenCalledOnce();
expect(mockUpdate).toHaveBeenCalledOnce();
expect(mockInsert).not.toHaveBeenCalled();
});
it("retries a failed prior attempt and updates the existing row", async () => {

View file

@ -58,7 +58,9 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
const versionNumber = latestVersion?.version ?? 1;
if (!versionGpx) return { status: "no_geometry" };
// Idempotency: if this exact (user, route, version) was already pushed, return that result.
// Idempotency key is per (user, route, provider) — a logical Wahoo route is
// per-route, not per-version. The version that's currently on Wahoo is a
// property of the row, not part of the key.
const existing = await db
.select()
.from(syncPushes)
@ -66,13 +68,17 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
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) {
// Already-pushed unchanged route: short-circuit.
if (
existingRow?.pushedAt &&
existingRow.remoteId &&
existingRow.lastPushedVersion === versionNumber
) {
return { status: "success", remoteId: existingRow.remoteId, pushedAt: existingRow.pushedAt };
}
@ -87,7 +93,7 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
description: route.description ?? undefined,
});
const externalId = `route:${routeId}:v${versionNumber}`;
const externalId = `route:${routeId}`;
const payload = {
fit,
externalId,
@ -123,6 +129,7 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
.update(syncPushes)
.set({
remoteId,
lastPushedVersion: success ? versionNumber : existingRow.lastPushedVersion,
pushedAt: success ? now : null,
error,
updatedAt: now,
@ -133,26 +140,43 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
id: randomUUID(),
userId,
routeId,
routeVersion: versionNumber,
provider: providerId,
externalId,
remoteId,
lastPushedVersion: success ? versionNumber : null,
pushedAt: success ? now : null,
error,
});
}
};
// POST on first push (or after a failed push with no remote_id), PUT on
// subsequent pushes. PUT 404 means the user deleted the Wahoo route on
// their side — fall back to POST and overwrite remote_id.
const callProvider = async (toks: TokenSet) => {
if (existingRow?.remoteId && provider.updateRoute) {
try {
return await provider.updateRoute(toks, existingRow.remoteId, payload);
} catch (e) {
if (e instanceof PushError && e.code === "not_found") {
return provider.pushRoute(toks, payload);
}
throw e;
}
}
return provider.pushRoute(toks, payload);
};
try {
let result;
try {
result = await provider.pushRoute(tokens, payload);
result = await callProvider(tokens);
} 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);
result = await callProvider(tokens);
} else {
throw e;
}
@ -163,7 +187,11 @@ export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushO
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 };
// not_found should have been handled by the PUT→POST fallback; if it
// ever surfaces here it means even the POST returned 404. Surface as
// a generic error rather than widening the public PushOutcome union.
const code = e.code === "not_found" ? "generic" : e.code;
return { status: "error", code, message: e.message };
}
const message = e instanceof Error ? e.message : String(e);
await recordResult(false, null, `generic: ${message}`);

View file

@ -32,7 +32,7 @@ export interface WebhookEvent {
export interface PushRoutePayload {
/** Pre-encoded FIT Course bytes — the action route runs gpxToFitCourse before calling pushRoute. */
fit: Uint8Array;
/** Stable per (route, version) — `route:<routeId>:v<version>` for trails.cool. */
/** Stable per route — `route:<routeId>` for trails.cool. */
externalId: string;
providerUpdatedAt: Date;
name: string;
@ -56,6 +56,7 @@ export type PushErrorCode =
| "token_expired"
| "validation"
| "rate_limit"
| "not_found"
| "generic";
export type OAuthErrorCode = "too_many_tokens" | "generic";
@ -102,6 +103,16 @@ export interface SyncProvider {
/** Optional: providers that can accept routes implement this. UI hides the action when undefined. */
pushRoute?: (tokens: TokenSet, payload: PushRoutePayload) => Promise<PushRouteResult>;
/**
* Optional: update an already-pushed route in place. Throws PushError("not_found")
* if the remote route no longer exists; callers should fall back to pushRoute.
*/
updateRoute?: (
tokens: TokenSet,
remoteId: string,
payload: PushRoutePayload,
) => Promise<PushRouteResult>;
/** Optional: revoke the given access token at the provider. Best-effort — failures should be swallowed by callers. */
revoke?: (tokens: TokenSet) => Promise<void>;
}

View file

@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { and, desc, eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { canView, getSessionUser } from "~/lib/auth.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getDb } from "~/lib/db";
@ -44,20 +44,32 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const currentVersion = route.versions[0]?.version ?? 1;
// Wahoo push state — only meaningful for the owner. Includes the latest
// sync_pushes row for the current version (if any) and whether the user
// has a Wahoo connection with the routes_write scope.
// Wahoo push state — only meaningful for the owner. The single
// sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`,
// which we compare against the current local version to render one of
// three states: matches, local newer, or last attempt failed.
let wahooPush:
| {
canPush: boolean;
needsReauth: boolean;
latest: { pushedAt: string | null; remoteId: string | null; error: string | null } | null;
currentVersion: number;
latest: {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
} | null;
}
| null = null;
if (isOwner && user && !!route.gpx) {
const connection = await getConnection(user.id, "wahoo");
let latest:
| { pushedAt: string | null; remoteId: string | null; error: string | null }
| {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
}
| null = null;
if (connection) {
const db = getDb();
@ -68,16 +80,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
and(
eq(syncPushes.userId, user.id),
eq(syncPushes.routeId, route.id),
eq(syncPushes.routeVersion, currentVersion),
eq(syncPushes.provider, "wahoo"),
),
)
.orderBy(desc(syncPushes.createdAt))
.limit(1);
latest = row
? {
pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null,
remoteId: row.remoteId,
lastPushedVersion: row.lastPushedVersion,
error: row.error,
}
: null;
@ -85,6 +96,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
wahooPush = {
canPush: !!connection,
needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"),
currentVersion,
latest,
};
}
@ -237,33 +249,49 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
{t("routes.exportGpx")}
</a>
)}
{wahooPush?.canPush && (
wahooPush.latest?.pushedAt ? (
<span
className="rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-800"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sentToWahoo", {
date: new Date(wahooPush.latest.pushedAt).toLocaleDateString(i18n.language),
})}
</span>
) : (
{wahooPush?.canPush && (() => {
const latest = wahooPush.latest;
const matches =
latest?.pushedAt &&
latest.lastPushedVersion === wahooPush.currentVersion;
const localNewer =
latest?.pushedAt &&
latest.lastPushedVersion != null &&
latest.lastPushedVersion < wahooPush.currentVersion;
if (matches) {
return (
<span
className="rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-800"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sentToWahoo", {
date: new Date(latest!.pushedAt!).toLocaleDateString(i18n.language),
})}
</span>
);
}
return (
<form method="post" action={`/api/sync/push/wahoo/${route.id}`} className="inline">
{localNewer && (
<span className="mr-2 rounded-md bg-amber-50 px-3 py-1.5 text-sm text-amber-900">
{t("routes.onWahooNewer", { n: latest!.lastPushedVersion })}
</span>
)}
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sendToWahoo")}
{localNewer ? t("routes.sendUpdatedVersion") : t("routes.sendToWahoo")}
</button>
{wahooPush.latest?.error && (
{latest?.error && (
<span className="ml-2 text-sm text-red-700">
{t("routes.sendToWahooFailed", { error: wahooPush.latest.error })}
{t("routes.sendToWahooFailed", { error: latest.error })}
</span>
)}
</form>
)
)}
);
})()}
</div>
)}
</div>