trails/apps/journal/app/lib/sync/pushes.server.ts
Ullrich Schäfer db3eeed60f
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>
2026-05-01 12:08:16 +02:00

222 lines
7.4 KiB
TypeScript

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<PushOutcome> {
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 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)
.where(
and(
eq(syncPushes.userId, userId),
eq(syncPushes.routeId, routeId),
eq(syncPushes.provider, providerId),
),
)
.limit(1);
const existingRow = existing[0];
// Already-pushed unchanged route: short-circuit.
if (
existingRow?.pushedAt &&
existingRow.remoteId &&
existingRow.lastPushedVersion === versionNumber
) {
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}`;
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<void> => {
const now = new Date();
if (existingRow) {
await db
.update(syncPushes)
.set({
remoteId,
lastPushedVersion: success ? versionNumber : existingRow.lastPushedVersion,
pushedAt: success ? now : null,
error,
updatedAt: now,
})
.where(eq(syncPushes.id, existingRow.id));
} else {
await db.insert(syncPushes).values({
id: randomUUID(),
userId,
routeId,
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 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 callProvider(tokens);
} 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" };
// 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}`);
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 {};
}
}