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 <noreply@anthropic.com>
50 lines
1.8 KiB
TypeScript
50 lines
1.8 KiB
TypeScript
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 });
|
|
}
|