Some Wahoo workouts (indoor, manual entries) don't have a FIT file URL. Both the manual import action and webhook handler called downloadFile unconditionally, throwing "No file URL for workout". Now skips the download and creates the activity without GPX when no file is available. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
68 lines
2.6 KiB
TypeScript
68 lines
2.6 KiB
TypeScript
import { data } from "react-router";
|
|
import type { Route } from "./+types/api.sync.webhook.$provider";
|
|
import { getProvider } from "~/lib/sync/registry";
|
|
import { getConnectionByProviderUser, updateTokens } from "~/lib/sync/connections.server";
|
|
import { isAlreadyImported, recordImport } from "~/lib/sync/imports.server";
|
|
import { createActivity } from "~/lib/activities.server";
|
|
|
|
export async function action({ params, request }: Route.ActionArgs) {
|
|
if (request.method !== "POST") {
|
|
return data({ error: "Method not allowed" }, { status: 405 });
|
|
}
|
|
|
|
const provider = getProvider(params.provider);
|
|
if (!provider) return data({ ok: true }); // Don't reveal provider existence
|
|
|
|
const body = await request.json();
|
|
|
|
// Verify webhook token
|
|
const expectedToken = process.env[`${params.provider.toUpperCase()}_WEBHOOK_TOKEN`];
|
|
if (expectedToken && (body as { webhook_token?: string }).webhook_token !== expectedToken) {
|
|
return data({ ok: true }); // Invalid token — ignore silently
|
|
}
|
|
|
|
const event = provider.parseWebhook(body);
|
|
if (!event) return data({ ok: true }); // Unrecognized event — ignore silently
|
|
|
|
// Look up user by provider user ID
|
|
const connection = await getConnectionByProviderUser(provider.id, event.providerUserId);
|
|
if (!connection) return data({ ok: true }); // Unknown user — ignore
|
|
|
|
// Check duplicate
|
|
const alreadyImported = await isAlreadyImported(connection.userId, provider.id, event.workoutId);
|
|
if (alreadyImported) return data({ ok: true });
|
|
|
|
try {
|
|
// Refresh token if expired
|
|
let tokens = {
|
|
accessToken: connection.accessToken,
|
|
refreshToken: connection.refreshToken,
|
|
expiresAt: connection.expiresAt,
|
|
};
|
|
if (new Date() >= tokens.expiresAt) {
|
|
tokens = await provider.refreshToken(tokens.refreshToken);
|
|
await updateTokens(connection.id, tokens);
|
|
}
|
|
|
|
// Download and convert (if file available)
|
|
const workout = { id: event.workoutId, name: "", type: "", startedAt: "", duration: null, distance: null, fileUrl: event.fileUrl };
|
|
let gpx: string | null = null;
|
|
if (workout.fileUrl) {
|
|
const fileBuffer = await provider.downloadFile(tokens, workout);
|
|
gpx = await provider.convertToGpx(fileBuffer);
|
|
}
|
|
|
|
// Create activity
|
|
const activityId = await createActivity(connection.userId, {
|
|
name: `${provider.name} workout`,
|
|
gpx: gpx ?? undefined,
|
|
});
|
|
|
|
await recordImport(connection.userId, provider.id, event.workoutId, activityId);
|
|
} catch (e) {
|
|
console.error(`Webhook import failed for ${provider.id}/${event.workoutId}:`, e);
|
|
}
|
|
|
|
// Always return 200 to acknowledge the webhook
|
|
return data({ ok: true });
|
|
}
|