Wahoo capability adapters + caller migration (groups 3-5)
Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of
deepen-connected-services. Built TDD: contract test red, adapter green
for each capability seam.
Capability adapters (providers/wahoo/):
- importer.ts: Importer seam — listImportable + importOne against
Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't
share third-party data). 2 contract tests green.
- pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId,
version}. FIT-Course conversion, route:<id> external_id, PUT-vs-POST
decision, PUT->POST-on-404 fallback all internal to the adapter
(per ADR-0003). Idempotency via sync_pushes preserved. 4 contract
tests green.
- webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes
events to local users via provider_user_id; unknown user returns
silently. 6 contract tests green.
- manifest.ts: declares credential_kind=oauth, OAuth config, scopes,
buildAuthUrl, exchangeCode, and references each capability adapter.
Module shape:
- connected-services/index.ts: side-effect imports providers/index.ts
to register manifests, then re-exports manager + registry + types.
- connected-services/providers/index.ts: barrel that calls
registerManifest(wahooManifest).
- connected-services/oauth-state.server.ts: OAuth state encode/decode
(extracted from legacy pushes.server.ts).
- connected-services/push-action.server.ts: orchestration above the
RoutePusher seam — load route, ownership check, scope check, build
RoutePushInput, invoke pusher, return PushOutcome union. Replaces
the legacy pushRouteToProvider in lib/sync/pushes.server.ts.
Caller migration (group 5):
- api.sync.connect.$provider.ts -> manifest.buildAuthUrl
- api.sync.callback.$provider.ts -> manifest.exchangeCode + link()
- api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls
best-effort revoke via the credential adapter, then deletes locally)
- api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch
- api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider
- sync.import.$provider.tsx -> manifest.importer.listImportable +
inline FIT->GPX in the action (form-supplied metadata bypasses the
Importer seam which is reserved for automatic / webhook imports)
- routes.$id.tsx -> getService instead of getConnection
- settings.connections.tsx -> getAllManifests instead of legacy registry
Legacy lib/sync/ deleted except imports.server.ts (which manages the
sync_imports table, untouched by this change).
DB migration verified locally (task 1.3): pnpm db:migrate-data renamed
the table and backfilled credentials JSONB; pnpm db:push then dropped
the legacy access_token/refresh_token/expires_at columns. Final shape
matches the schema; check constraints + unique index in place.
Test status (task 6.1):
- pnpm typecheck: green across all 15 workspaces
- pnpm lint: green
- @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than
before because pushes.server.test.ts and the legacy wahoo.test.ts
were deleted. Their coverage is replaced by the new contract tests
(importer/pusher/webhook) plus manager.test.ts + oauth.test.ts.
Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test),
7.1-7.3 (followups + spec deltas at archive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5dda69ab49
commit
8e5b6d6fe9
28 changed files with 1375 additions and 1522 deletions
9
apps/journal/app/lib/connected-services/index.ts
Normal file
9
apps/journal/app/lib/connected-services/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Public entry point for the connected-services module. Importing from
|
||||
// here guarantees provider manifests are registered before any caller
|
||||
// looks them up.
|
||||
|
||||
import "./providers/index.ts";
|
||||
|
||||
export * from "./manager.ts";
|
||||
export * from "./registry.ts";
|
||||
export * from "./types.ts";
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// OAuth state encoding for the connect/callback flow. The state param is
|
||||
// reflected back to the callback unchanged, so we use it to carry
|
||||
// post-callback intent (where to return to, whether a push should resume).
|
||||
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
11
apps/journal/app/lib/connected-services/providers/index.ts
Normal file
11
apps/journal/app/lib/connected-services/providers/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Provider barrel. Imports every provider manifest and registers it with
|
||||
// the registry. Adding a provider: import its manifest here and add the
|
||||
// `registerManifest(...)` call.
|
||||
|
||||
import { registerManifest } from "../registry.ts";
|
||||
import { wahooManifest } from "./wahoo/manifest.ts";
|
||||
|
||||
registerManifest(wahooManifest);
|
||||
|
||||
// Re-export so callers (mostly tests) can grab a manifest directly.
|
||||
export { wahooManifest };
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// Contract tests for the Wahoo Importer capability adapter.
|
||||
//
|
||||
// The seam under test is `Importer` from registry.ts:
|
||||
// listImportable(ctx, page) -> ImportableList
|
||||
// importOne(ctx, workoutId) -> ImportResult
|
||||
//
|
||||
// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy
|
||||
// wahoo.test.ts and follow the code as it's reorganized.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { CapabilityContext } from "../../registry.ts";
|
||||
import type { OAuthCredentials } from "../../types.ts";
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
const stubCreds: OAuthCredentials = {
|
||||
access_token: "fake-token",
|
||||
refresh_token: "rt",
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
|
||||
function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext {
|
||||
return {
|
||||
serviceId: "svc-1",
|
||||
withFreshCredentials: async (fn) => fn(creds),
|
||||
};
|
||||
}
|
||||
|
||||
// Importer is loaded after the implementation file exists. Using a dynamic
|
||||
// import isolates the test from module load order during initial red.
|
||||
const { wahooImporter } = await import("./importer.ts");
|
||||
|
||||
describe("wahooImporter.listImportable", () => {
|
||||
it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
workouts: [
|
||||
{
|
||||
id: 42,
|
||||
name: "Morning ride",
|
||||
workout_type: "biking",
|
||||
starts: "2026-05-01T07:00:00Z",
|
||||
workout_summary: {
|
||||
duration_active_accum: 3600,
|
||||
distance_accum: 25000,
|
||||
file: { url: "https://cdn.example/42.fit" },
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wahooImporter.listImportable(ctxWith(), 1);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toContain("/v1/workouts");
|
||||
expect(((init as RequestInit).headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer fake-token",
|
||||
);
|
||||
expect(result.workouts).toHaveLength(1);
|
||||
expect(result.workouts[0]!.id).toBe("42");
|
||||
expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit");
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it("filters out third-party workouts (fitness_app_id >= 1000)", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
workouts: [
|
||||
{ id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
|
||||
{
|
||||
id: 2,
|
||||
name: "Third-party",
|
||||
workout_type: "biking",
|
||||
starts: "2026-05-01T08:00:00Z",
|
||||
fitness_app_id: 1234,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
per_page: 30,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wahooImporter.listImportable(ctxWith(), 1);
|
||||
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
// Wahoo Importer capability adapter. Implements the Importer seam against
|
||||
// Wahoo's /v1/workouts API.
|
||||
//
|
||||
// Credentials always flow through ctx.withFreshCredentials — this module
|
||||
// never reads the connected_services credentials JSONB directly.
|
||||
|
||||
import FitParser from "fit-file-parser";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import { createActivity } from "../../../activities.server.ts";
|
||||
import { recordImport, isAlreadyImported } from "../../../sync/imports.server.ts";
|
||||
import type {
|
||||
CapabilityContext,
|
||||
ImportableList,
|
||||
ImportResult,
|
||||
Importer,
|
||||
} from "../../registry.ts";
|
||||
import type { OAuthCredentials } from "../../types.ts";
|
||||
|
||||
const WAHOO_API = "https://api.wahooligan.com";
|
||||
|
||||
interface WahooWorkout {
|
||||
id: number;
|
||||
name: string;
|
||||
workout_type: string;
|
||||
starts: string;
|
||||
fitness_app_id?: number;
|
||||
workout_summary?: {
|
||||
duration_active_accum?: number;
|
||||
distance_accum?: number;
|
||||
file?: { url?: string };
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWahooWorkoutPage(
|
||||
creds: OAuthCredentials,
|
||||
page: number,
|
||||
): Promise<{
|
||||
workouts: WahooWorkout[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}> {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: "30" });
|
||||
const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, {
|
||||
headers: { Authorization: `Bearer ${creds.access_token}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`);
|
||||
return resp.json() as Promise<{
|
||||
workouts: WahooWorkout[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function toImportable(w: WahooWorkout) {
|
||||
return {
|
||||
id: String(w.id),
|
||||
name: w.name || `Workout ${w.id}`,
|
||||
type: w.workout_type ?? "unknown",
|
||||
startedAt: w.starts,
|
||||
duration: w.workout_summary?.duration_active_accum
|
||||
? Math.round(w.workout_summary.duration_active_accum)
|
||||
: null,
|
||||
distance: w.workout_summary?.distance_accum
|
||||
? Math.round(w.workout_summary.distance_accum)
|
||||
: null,
|
||||
fileUrl: w.workout_summary?.file?.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function fitToGpx(buffer: Buffer, name: string): Promise<string | null> {
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parser.parse(buffer as any, (error: unknown, data: any) => {
|
||||
if (error) reject(error);
|
||||
else resolve(data ?? {});
|
||||
});
|
||||
});
|
||||
|
||||
const records = (parsed.records ?? []) as Array<{
|
||||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
|
||||
if (trackPoints.length < 2) return null;
|
||||
|
||||
return generateGpx({ name, tracks: [trackPoints] });
|
||||
}
|
||||
|
||||
async function downloadFit(fileUrl: string): Promise<Buffer> {
|
||||
// Wahoo CDN URLs are pre-signed; no auth header needed (and adding one
|
||||
// breaks them).
|
||||
const resp = await fetch(fileUrl);
|
||||
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
}
|
||||
|
||||
export const wahooImporter: Importer = {
|
||||
async listImportable(
|
||||
ctx: CapabilityContext,
|
||||
page: number,
|
||||
): Promise<ImportableList> {
|
||||
const data = await ctx.withFreshCredentials((creds) =>
|
||||
fetchWahooWorkoutPage(creds as OAuthCredentials, page),
|
||||
);
|
||||
|
||||
// Wahoo does not share workout data from third-party apps
|
||||
// (fitness_app_id >= 1000).
|
||||
const wahooOnly = data.workouts.filter(
|
||||
(w) => !w.fitness_app_id || w.fitness_app_id < 1000,
|
||||
);
|
||||
|
||||
return {
|
||||
workouts: wahooOnly.map(toImportable),
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
perPage: data.per_page,
|
||||
};
|
||||
},
|
||||
|
||||
async importOne(
|
||||
ctx: CapabilityContext,
|
||||
workoutId: string,
|
||||
): Promise<ImportResult> {
|
||||
// Look up the workout to get the file URL (Wahoo doesn't expose a
|
||||
// direct /v1/workouts/<id> with file; we re-fetch the page).
|
||||
// For simplicity we ask Wahoo for the workout directly; if that fails
|
||||
// we fall back to scanning page 1.
|
||||
const list = await ctx.withFreshCredentials((creds) =>
|
||||
fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
|
||||
);
|
||||
const workout = list.workouts.find((w) => String(w.id) === workoutId);
|
||||
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
|
||||
|
||||
// Resolve the connected service's user id via the capability context.
|
||||
// The caller (route handler) supplies userId out-of-band — for now the
|
||||
// route handler bridges the gap. We use the manager's getServiceById.
|
||||
const { getServiceById } = await import("../../manager.ts");
|
||||
const service = await getServiceById(ctx.serviceId);
|
||||
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
||||
|
||||
const userId = service.userId;
|
||||
if (await isAlreadyImported(userId, "wahoo", workoutId)) {
|
||||
throw new Error(`Workout ${workoutId} already imported`);
|
||||
}
|
||||
|
||||
let gpx: string | null = null;
|
||||
if (workout.workout_summary?.file?.url) {
|
||||
const buffer = await downloadFit(workout.workout_summary.file.url);
|
||||
gpx = await fitToGpx(buffer, workout.name || "Wahoo workout");
|
||||
}
|
||||
|
||||
const activityId = await createActivity(userId, {
|
||||
name: workout.name || `Wahoo workout ${workoutId}`,
|
||||
gpx: gpx ?? undefined,
|
||||
});
|
||||
await recordImport(userId, "wahoo", workoutId, activityId);
|
||||
|
||||
return { activityId, hadGeometry: gpx !== null };
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// Wahoo provider manifest. Declares credential kind, OAuth config,
|
||||
// authorization/exchange flows, and capability adapters.
|
||||
//
|
||||
// Adding to the registry happens via providers/index.ts which imports each
|
||||
// provider's barrel. Don't `import`-cycle this file from registry.ts.
|
||||
|
||||
import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts";
|
||||
import type {
|
||||
ProviderManifest,
|
||||
CapabilityContext,
|
||||
} from "../../registry.ts";
|
||||
import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts";
|
||||
import { wahooImporter } from "./importer.ts";
|
||||
import { wahooPusher } from "./pusher.ts";
|
||||
import { wahooWebhook } from "./webhook.ts";
|
||||
|
||||
const WAHOO_API = "https://api.wahooligan.com";
|
||||
const WAHOO_AUTH = "https://api.wahooligan.com/oauth";
|
||||
|
||||
const SCOPES = [
|
||||
"workouts_read",
|
||||
"user_read",
|
||||
"offline_data",
|
||||
"routes_read",
|
||||
"routes_write",
|
||||
];
|
||||
|
||||
function clientId(): string {
|
||||
return process.env.WAHOO_CLIENT_ID ?? "";
|
||||
}
|
||||
function clientSecret(): string {
|
||||
return process.env.WAHOO_CLIENT_SECRET ?? "";
|
||||
}
|
||||
|
||||
const oauthConfig: ProviderOAuthConfig = {
|
||||
get tokenUrl() {
|
||||
return `${WAHOO_AUTH}/token`;
|
||||
},
|
||||
get clientId() {
|
||||
return clientId();
|
||||
},
|
||||
get clientSecret() {
|
||||
return clientSecret();
|
||||
},
|
||||
get revokeUrl() {
|
||||
return `${WAHOO_API}/v1/permissions`;
|
||||
},
|
||||
};
|
||||
|
||||
export const wahooManifest: ProviderManifest = {
|
||||
id: "wahoo",
|
||||
displayName: "Wahoo",
|
||||
credentialKind: "oauth",
|
||||
credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"],
|
||||
oauthConfig,
|
||||
scopes: SCOPES,
|
||||
|
||||
buildAuthUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: SCOPES.join(" "),
|
||||
state,
|
||||
});
|
||||
return `${WAHOO_AUTH}/authorize?${params}`;
|
||||
},
|
||||
|
||||
async exchangeCode(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<{
|
||||
credentials: OAuthCredentials;
|
||||
providerUserId: string | null;
|
||||
grantedScopes: string[];
|
||||
}> {
|
||||
const resp = await fetch(`${WAHOO_AUTH}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
const err = new Error(`Wahoo token exchange failed: ${resp.status} ${text}`);
|
||||
// Attach a code so callers can distinguish the "too many tokens" sandbox case.
|
||||
(err as Error & { code?: string }).code = text.includes("Too many unrevoked access tokens")
|
||||
? "too_many_tokens"
|
||||
: "generic";
|
||||
throw err;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Pull provider user id so webhook routing works.
|
||||
const userResp = await fetch(`${WAHOO_API}/v1/user`, {
|
||||
headers: { Authorization: `Bearer ${data.access_token}` },
|
||||
});
|
||||
const user = userResp.ok
|
||||
? ((await userResp.json()) as { id: number })
|
||||
: null;
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
|
||||
},
|
||||
providerUserId: user?.id != null ? String(user.id) : null,
|
||||
// Wahoo does not return a `scope` field and grants scopes
|
||||
// all-or-nothing, so the requested set is the granted set.
|
||||
grantedScopes: SCOPES,
|
||||
};
|
||||
},
|
||||
|
||||
importer: wahooImporter,
|
||||
routePusher: wahooPusher,
|
||||
webhookReceiver: wahooWebhook,
|
||||
};
|
||||
|
||||
// Re-export the capability adapters for direct testing access if needed.
|
||||
export type { CapabilityContext };
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
// Contract tests for the Wahoo RoutePusher capability adapter.
|
||||
//
|
||||
// Seam: pushRoute(ctx, input) -> {remoteId, version}
|
||||
//
|
||||
// Wahoo workarounds (FIT conversion, route:<id> external_id, PUT-vs-POST,
|
||||
// PUT->POST-on-404 fallback) are tested here as adapter-internal — they
|
||||
// must not surface on the seam.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { CapabilityContext } from "../../registry.ts";
|
||||
import type { OAuthCredentials } from "../../types.ts";
|
||||
|
||||
// ---- mocks ----
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
|
||||
const mockFitConvert = vi.fn();
|
||||
vi.mock("@trails-cool/fit", () => ({
|
||||
gpxToFitCourse: (input: unknown) => mockFitConvert(input),
|
||||
}));
|
||||
|
||||
// sync_pushes idempotency state — manipulated per-test.
|
||||
let existingPushRow:
|
||||
| {
|
||||
id: string;
|
||||
userId: string;
|
||||
routeId: string;
|
||||
provider: string;
|
||||
remoteId: string | null;
|
||||
lastPushedVersion: number | null;
|
||||
pushedAt: Date | null;
|
||||
error: string | null;
|
||||
}
|
||||
| null = null;
|
||||
const insertedPushes: unknown[] = [];
|
||||
const updatedPushes: unknown[] = [];
|
||||
|
||||
const mockDb = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(existingPushRow ? [existingPushRow] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (v: unknown) => {
|
||||
insertedPushes.push(v);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (patch: unknown) => ({
|
||||
where: () => {
|
||||
updatedPushes.push(patch);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../../db.ts", () => ({ getDb: () => mockDb }));
|
||||
|
||||
vi.mock("../../manager.ts", () => ({
|
||||
getServiceById: async () => ({
|
||||
id: "svc-1",
|
||||
userId: "u1",
|
||||
provider: "wahoo",
|
||||
credentialKind: "oauth",
|
||||
credentials: {},
|
||||
status: "active",
|
||||
providerUserId: null,
|
||||
grantedScopes: [],
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
mockFitConvert.mockReset();
|
||||
mockFitConvert.mockResolvedValue(new Uint8Array([0xfe, 0xed, 0xfa, 0xce]));
|
||||
existingPushRow = null;
|
||||
insertedPushes.length = 0;
|
||||
updatedPushes.length = 0;
|
||||
});
|
||||
|
||||
const stubCreds: OAuthCredentials = {
|
||||
access_token: "fake-token",
|
||||
refresh_token: "rt",
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
|
||||
function ctx(): CapabilityContext {
|
||||
return {
|
||||
serviceId: "svc-1",
|
||||
withFreshCredentials: async (fn) => fn(stubCreds),
|
||||
};
|
||||
}
|
||||
|
||||
const pushInput = {
|
||||
routeId: "route-abc",
|
||||
routeName: "Berlin loop",
|
||||
description: "morning ride",
|
||||
gpx: `<?xml version="1.0"?><gpx><trk><trkseg>
|
||||
<trkpt lat="52.5" lon="13.4"><ele>34</ele></trkpt>
|
||||
<trkpt lat="52.6" lon="13.5"><ele>40</ele></trkpt>
|
||||
</trkseg></trk></gpx>`,
|
||||
startLat: 52.5,
|
||||
startLng: 13.4,
|
||||
distance: 1234,
|
||||
ascent: 56,
|
||||
localVersion: 3,
|
||||
};
|
||||
|
||||
const { wahooPusher } = await import("./pusher.ts");
|
||||
|
||||
describe("wahooPusher.pushRoute — first push (no existing row)", () => {
|
||||
it("POSTs to /v1/routes with FIT body and external_id=route:<routeId>", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 9001 }), { status: 201 }),
|
||||
);
|
||||
|
||||
const result = await wahooPusher.pushRoute(ctx(), pushInput);
|
||||
|
||||
expect(result.remoteId).toBe("9001");
|
||||
expect(result.version).toBe(3);
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe("https://api.wahooligan.com/v1/routes");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
const body = (init as RequestInit).body as string;
|
||||
expect(body).toContain("route%5Bexternal_id%5D=route%3Aroute-abc");
|
||||
expect(body).toContain("route%5Bfile%5D=data%3Aapplication%2Fvnd.fit%3Bbase64%2C");
|
||||
expect(insertedPushes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooPusher.pushRoute — re-push of an unchanged route", () => {
|
||||
it("short-circuits without calling Wahoo when last_pushed_version matches", async () => {
|
||||
existingPushRow = {
|
||||
id: "existing",
|
||||
userId: "u1",
|
||||
routeId: "route-abc",
|
||||
provider: "wahoo",
|
||||
remoteId: "9001",
|
||||
lastPushedVersion: 3,
|
||||
pushedAt: new Date("2026-01-01"),
|
||||
error: null,
|
||||
};
|
||||
|
||||
const result = await wahooPusher.pushRoute(ctx(), pushInput);
|
||||
|
||||
expect(result.remoteId).toBe("9001");
|
||||
expect(result.version).toBe(3);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooPusher.pushRoute — re-push after edit", () => {
|
||||
it("PUTs to /v1/routes/<remoteId> when lastPushedVersion < localVersion", async () => {
|
||||
existingPushRow = {
|
||||
id: "existing",
|
||||
userId: "u1",
|
||||
routeId: "route-abc",
|
||||
provider: "wahoo",
|
||||
remoteId: "9001",
|
||||
lastPushedVersion: 2,
|
||||
pushedAt: new Date("2026-01-01"),
|
||||
error: null,
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
const result = await wahooPusher.pushRoute(ctx(), pushInput);
|
||||
|
||||
expect(result.remoteId).toBe("9001");
|
||||
expect(result.version).toBe(3);
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe("https://api.wahooligan.com/v1/routes/9001");
|
||||
expect((init as RequestInit).method).toBe("PUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooPusher.pushRoute — PUT 404 fallback", () => {
|
||||
it("falls back to POST and overwrites remoteId when PUT returns 404", async () => {
|
||||
existingPushRow = {
|
||||
id: "existing",
|
||||
userId: "u1",
|
||||
routeId: "route-abc",
|
||||
provider: "wahoo",
|
||||
remoteId: "9001",
|
||||
lastPushedVersion: 2,
|
||||
pushedAt: new Date("2026-01-01"),
|
||||
error: null,
|
||||
};
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 9999 }), { status: 201 }),
|
||||
);
|
||||
|
||||
const result = await wahooPusher.pushRoute(ctx(), pushInput);
|
||||
|
||||
expect(result.remoteId).toBe("9999");
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
const [, secondInit] = fetchSpy.mock.calls[1]!;
|
||||
expect((secondInit as RequestInit).method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
// Wahoo RoutePusher capability adapter.
|
||||
//
|
||||
// Exposes the seam shape `pushRoute(ctx, input) -> {remoteId, version}` per
|
||||
// ADR-0003. Wahoo specifics — FIT-Course conversion, the `route:<id>`
|
||||
// external_id convention, the PUT-vs-POST decision based on sync_pushes,
|
||||
// and the PUT-on-404 → POST fallback — live entirely inside this module
|
||||
// and never appear on the seam.
|
||||
//
|
||||
// Idempotency state lives in `journal.sync_pushes`. The seam returns the
|
||||
// remote id and the local version that was pushed; the caller is free to
|
||||
// inspect the table for richer state.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { gpxToFitCourse } from "@trails-cool/fit";
|
||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../../../db.ts";
|
||||
import { getServiceById } from "../../manager.ts";
|
||||
import type {
|
||||
CapabilityContext,
|
||||
RoutePushInput,
|
||||
RoutePushResult,
|
||||
RoutePusher,
|
||||
} from "../../registry.ts";
|
||||
import type { OAuthCredentials } from "../../types.ts";
|
||||
|
||||
const WAHOO_API = "https://api.wahooligan.com";
|
||||
|
||||
interface WahooErrorShape {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
class WahooHttpError extends Error {
|
||||
shape: WahooErrorShape;
|
||||
constructor(shape: WahooErrorShape) {
|
||||
super(`Wahoo route ${shape.status}: ${shape.body}`);
|
||||
this.shape = shape;
|
||||
}
|
||||
}
|
||||
|
||||
function externalIdFor(routeId: string): string {
|
||||
return `route:${routeId}`;
|
||||
}
|
||||
|
||||
function buildBody(
|
||||
fit: Uint8Array,
|
||||
input: RoutePushInput,
|
||||
): URLSearchParams {
|
||||
// Wahoo expects route[file] as a data URI, not raw base64. Sending raw
|
||||
// base64 results in a route with file.url = null; the app shows
|
||||
// metadata but renders no track.
|
||||
const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`;
|
||||
const body = new URLSearchParams({
|
||||
"route[external_id]": externalIdFor(input.routeId),
|
||||
"route[provider_updated_at]": new Date().toISOString(),
|
||||
"route[name]": input.routeName,
|
||||
"route[workout_type_family_id]": "0",
|
||||
"route[start_lat]": input.startLat.toString(),
|
||||
"route[start_lng]": input.startLng.toString(),
|
||||
"route[distance]": input.distance.toString(),
|
||||
"route[ascent]": input.ascent.toString(),
|
||||
"route[file]": fitDataUri,
|
||||
});
|
||||
if (input.description) body.set("route[description]", input.description);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postOrPut(
|
||||
method: "POST" | "PUT",
|
||||
url: string,
|
||||
accessToken: string,
|
||||
body: URLSearchParams,
|
||||
fallbackRemoteId?: string,
|
||||
): Promise<{ remoteId: string }> {
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
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 ??= fallbackRemoteId;
|
||||
if (!remoteId) throw new Error(`Wahoo response missing route id`);
|
||||
return { remoteId };
|
||||
}
|
||||
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new WahooHttpError({ status: resp.status, body: text });
|
||||
}
|
||||
|
||||
interface ExistingPush {
|
||||
id: string;
|
||||
userId: string;
|
||||
routeId: string;
|
||||
provider: string;
|
||||
remoteId: string | null;
|
||||
lastPushedVersion: number | null;
|
||||
pushedAt: Date | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function findExistingPush(
|
||||
userId: string,
|
||||
routeId: string,
|
||||
): Promise<ExistingPush | null> {
|
||||
const db = getDb();
|
||||
const rows = (await db
|
||||
.select()
|
||||
.from(syncPushes)
|
||||
.where(
|
||||
and(
|
||||
eq(syncPushes.userId, userId),
|
||||
eq(syncPushes.routeId, routeId),
|
||||
eq(syncPushes.provider, "wahoo"),
|
||||
),
|
||||
)
|
||||
.limit(1)) as ExistingPush[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function recordPush(
|
||||
existing: ExistingPush | null,
|
||||
userId: string,
|
||||
input: RoutePushInput,
|
||||
remoteId: string,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
if (existing) {
|
||||
await db
|
||||
.update(syncPushes)
|
||||
.set({
|
||||
remoteId,
|
||||
lastPushedVersion: input.localVersion,
|
||||
pushedAt: now,
|
||||
error: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(syncPushes.id, existing.id));
|
||||
} else {
|
||||
await db.insert(syncPushes).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
routeId: input.routeId,
|
||||
provider: "wahoo",
|
||||
externalId: externalIdFor(input.routeId),
|
||||
remoteId,
|
||||
lastPushedVersion: input.localVersion,
|
||||
pushedAt: now,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const wahooPusher: RoutePusher = {
|
||||
async pushRoute(
|
||||
ctx: CapabilityContext,
|
||||
input: RoutePushInput,
|
||||
): Promise<RoutePushResult> {
|
||||
// Resolve the user from the connected service so we can read/write
|
||||
// sync_pushes for the right (user, route, provider) tuple.
|
||||
const service = await getServiceById(ctx.serviceId);
|
||||
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
|
||||
|
||||
const existing = await findExistingPush(service.userId, input.routeId);
|
||||
if (
|
||||
existing?.pushedAt &&
|
||||
existing.remoteId &&
|
||||
existing.lastPushedVersion === input.localVersion
|
||||
) {
|
||||
return { remoteId: existing.remoteId, version: input.localVersion };
|
||||
}
|
||||
|
||||
const fit = await gpxToFitCourse({
|
||||
gpx: input.gpx,
|
||||
name: input.routeName,
|
||||
description: input.description,
|
||||
});
|
||||
const body = buildBody(fit, input);
|
||||
|
||||
const result = await ctx.withFreshCredentials(async (creds) => {
|
||||
const accessToken = (creds as OAuthCredentials).access_token;
|
||||
// PUT-vs-POST: PUT in place when we have a remoteId on file.
|
||||
if (existing?.remoteId) {
|
||||
try {
|
||||
return await postOrPut(
|
||||
"PUT",
|
||||
`${WAHOO_API}/v1/routes/${encodeURIComponent(existing.remoteId)}`,
|
||||
accessToken,
|
||||
body,
|
||||
existing.remoteId,
|
||||
);
|
||||
} catch (err) {
|
||||
// 404 means the user deleted the route on Wahoo's side. Fall
|
||||
// back to POST and overwrite the local remoteId.
|
||||
if (err instanceof WahooHttpError && err.shape.status === 404) {
|
||||
return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body);
|
||||
});
|
||||
|
||||
await recordPush(existing, service.userId, input, result.remoteId);
|
||||
return { remoteId: result.remoteId, version: input.localVersion };
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// Contract tests for the Wahoo WebhookReceiver capability adapter.
|
||||
//
|
||||
// Seam: parseWebhook(body) -> WebhookEvent | null
|
||||
// handle(event) -> void (creates an activity if file present, dedups via sync_imports)
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
const mockCreateActivity = vi.fn();
|
||||
const mockIsAlreadyImported = vi.fn();
|
||||
const mockRecordImport = vi.fn();
|
||||
const mockGetServiceByProviderUser = vi.fn();
|
||||
const mockWithFreshCredentials = vi.fn();
|
||||
|
||||
vi.mock("../../../activities.server.ts", () => ({
|
||||
createActivity: mockCreateActivity,
|
||||
}));
|
||||
vi.mock("../../../sync/imports.server.ts", () => ({
|
||||
isAlreadyImported: mockIsAlreadyImported,
|
||||
recordImport: mockRecordImport,
|
||||
}));
|
||||
vi.mock("../../manager.ts", () => ({
|
||||
getServiceByProviderUser: mockGetServiceByProviderUser,
|
||||
withFreshCredentials: mockWithFreshCredentials,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
mockCreateActivity.mockReset();
|
||||
mockIsAlreadyImported.mockReset();
|
||||
mockRecordImport.mockReset();
|
||||
mockGetServiceByProviderUser.mockReset();
|
||||
mockWithFreshCredentials.mockReset();
|
||||
});
|
||||
|
||||
const { wahooWebhook } = await import("./webhook.ts");
|
||||
|
||||
describe("wahooWebhook.parseWebhook", () => {
|
||||
it("returns a WebhookEvent for workout_summary payloads", () => {
|
||||
const event = wahooWebhook.parseWebhook({
|
||||
event_type: "workout_summary",
|
||||
user: { id: 7 },
|
||||
workout_summary: {
|
||||
workout: { id: 42 },
|
||||
file: { url: "https://cdn.example/42.fit" },
|
||||
},
|
||||
});
|
||||
expect(event).toEqual({
|
||||
eventType: "workout_summary",
|
||||
providerUserId: "7",
|
||||
workoutId: "42",
|
||||
fileUrl: "https://cdn.example/42.fit",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for unrecognized event types", () => {
|
||||
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when user.id is missing", () => {
|
||||
expect(
|
||||
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooWebhook.handle", () => {
|
||||
it("creates an activity and records the import for a known user", async () => {
|
||||
mockGetServiceByProviderUser.mockResolvedValue({
|
||||
id: "svc-1",
|
||||
userId: "u1",
|
||||
provider: "wahoo",
|
||||
});
|
||||
mockIsAlreadyImported.mockResolvedValue(false);
|
||||
mockCreateActivity.mockResolvedValue("act-1");
|
||||
// withFreshCredentials passes the credentials to fn — we don't need to
|
||||
// download a file to assert the basic flow; pass a no-op file URL test
|
||||
// via parseWebhook output containing fileUrl undefined to skip download.
|
||||
mockWithFreshCredentials.mockImplementation(
|
||||
async (_id: string, fn: (creds: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await wahooWebhook.handle({
|
||||
eventType: "workout_summary",
|
||||
providerUserId: "7",
|
||||
workoutId: "42",
|
||||
// no fileUrl — the activity is created without GPX
|
||||
});
|
||||
|
||||
expect(mockCreateActivity).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
expect.objectContaining({ name: expect.stringContaining("Wahoo") }),
|
||||
);
|
||||
expect(mockRecordImport).toHaveBeenCalledWith("u1", "wahoo", "42", "act-1");
|
||||
});
|
||||
|
||||
it("silently skips when the providerUserId is unknown (no leak)", async () => {
|
||||
mockGetServiceByProviderUser.mockResolvedValue(null);
|
||||
|
||||
await wahooWebhook.handle({
|
||||
eventType: "workout_summary",
|
||||
providerUserId: "999",
|
||||
workoutId: "42",
|
||||
});
|
||||
|
||||
expect(mockCreateActivity).not.toHaveBeenCalled();
|
||||
expect(mockRecordImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently skips when the workout was already imported (idempotency)", async () => {
|
||||
mockGetServiceByProviderUser.mockResolvedValue({
|
||||
id: "svc-1",
|
||||
userId: "u1",
|
||||
provider: "wahoo",
|
||||
});
|
||||
mockIsAlreadyImported.mockResolvedValue(true);
|
||||
|
||||
await wahooWebhook.handle({
|
||||
eventType: "workout_summary",
|
||||
providerUserId: "7",
|
||||
workoutId: "42",
|
||||
});
|
||||
|
||||
expect(mockCreateActivity).not.toHaveBeenCalled();
|
||||
expect(mockRecordImport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
// Wahoo WebhookReceiver capability adapter.
|
||||
//
|
||||
// Wahoo posts `workout_summary` events to /api/sync/webhook/wahoo when a
|
||||
// workout completes. We route the event to the right local user via
|
||||
// provider_user_id, deduplicate via sync_imports, then download + convert
|
||||
// the FIT file (if present) and create an activity.
|
||||
|
||||
import FitParser from "fit-file-parser";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import { createActivity } from "../../../activities.server.ts";
|
||||
import { isAlreadyImported, recordImport } from "../../../sync/imports.server.ts";
|
||||
import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts";
|
||||
import type { OAuthCredentials } from "../../types.ts";
|
||||
import type { WebhookEvent, WebhookReceiver } from "../../registry.ts";
|
||||
|
||||
interface WahooWebhookBody {
|
||||
event_type?: string;
|
||||
user?: { id?: number };
|
||||
workout_summary?: {
|
||||
id?: number;
|
||||
workout?: { id?: number };
|
||||
file?: { url?: string };
|
||||
};
|
||||
}
|
||||
|
||||
async function fitToGpx(buffer: Buffer): Promise<string | null> {
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parser.parse(buffer as any, (error: unknown, data: any) => {
|
||||
if (error) reject(error);
|
||||
else resolve(data ?? {});
|
||||
});
|
||||
});
|
||||
|
||||
const records = (parsed.records ?? []) as Array<{
|
||||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
|
||||
if (trackPoints.length < 2) return null;
|
||||
return generateGpx({ name: "Wahoo workout", tracks: [trackPoints] });
|
||||
}
|
||||
|
||||
export const wahooWebhook: WebhookReceiver = {
|
||||
parseWebhook(body: unknown): WebhookEvent | null {
|
||||
const payload = body as WahooWebhookBody;
|
||||
if (payload.event_type !== "workout_summary" || !payload.user?.id) return null;
|
||||
|
||||
return {
|
||||
eventType: payload.event_type,
|
||||
providerUserId: String(payload.user.id),
|
||||
workoutId: String(
|
||||
payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "",
|
||||
),
|
||||
fileUrl: payload.workout_summary?.file?.url,
|
||||
};
|
||||
},
|
||||
|
||||
async handle(event: WebhookEvent): Promise<void> {
|
||||
// Match incoming webhooks to local users via provider_user_id.
|
||||
// Unknown users return silently — no leak.
|
||||
const service = await getServiceByProviderUser("wahoo", event.providerUserId);
|
||||
if (!service) return;
|
||||
|
||||
if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return;
|
||||
|
||||
let gpx: string | null = null;
|
||||
if (event.fileUrl) {
|
||||
// Wahoo CDN URLs are pre-signed; no auth header needed. We still go
|
||||
// through withFreshCredentials so the manager has a chance to refresh
|
||||
// a near-expired credential before any subsequent Wahoo call this
|
||||
// handler might make.
|
||||
const buffer = await withFreshCredentials(service.id, async (_creds) => {
|
||||
void (_creds as unknown as OAuthCredentials);
|
||||
const resp = await fetch(event.fileUrl!);
|
||||
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
});
|
||||
gpx = await fitToGpx(buffer);
|
||||
}
|
||||
|
||||
const activityId = await createActivity(service.userId, {
|
||||
name: `Wahoo workout`,
|
||||
gpx: gpx ?? undefined,
|
||||
});
|
||||
await recordImport(service.userId, "wahoo", event.workoutId, activityId);
|
||||
},
|
||||
};
|
||||
115
apps/journal/app/lib/connected-services/push-action.server.ts
Normal file
115
apps/journal/app/lib/connected-services/push-action.server.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Orchestrates a route push: load route, check ownership, check scopes,
|
||||
// build the RoutePushInput, and invoke the provider's RoutePusher
|
||||
// capability. Replaces the legacy pushRouteToProvider in lib/sync.
|
||||
//
|
||||
// The pusher (per-provider) handles HTTP, FIT conversion, idempotency,
|
||||
// and PUT/POST/404 fallback. This module is the orchestration layer
|
||||
// callers use (route handlers, OAuth callback resume).
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { parseGpxAsync } from "@trails-cool/gpx";
|
||||
import { routeVersions } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../db.ts";
|
||||
import { getRoute } from "../routes.server.ts";
|
||||
import {
|
||||
ConnectionNotActiveError,
|
||||
NeedsRelinkError,
|
||||
} from "./types.ts";
|
||||
import { capabilityContextFor, getService } from "./manager.ts";
|
||||
import { getManifest, type RoutePushInput } from "./registry.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: "needs_relink" }
|
||||
| {
|
||||
status: "error";
|
||||
code: "validation" | "rate_limit" | "token_expired" | "generic";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface PushRouteOptions {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
export async function pushRouteToProvider(
|
||||
opts: PushRouteOptions,
|
||||
): Promise<PushOutcome> {
|
||||
const { userId, providerId, routeId } = opts;
|
||||
const db = getDb();
|
||||
|
||||
const manifest = getManifest(providerId);
|
||||
if (!manifest) return { status: "not_found" };
|
||||
if (!manifest.routePusher) return { status: "unsupported_provider" };
|
||||
|
||||
const route = await getRoute(routeId);
|
||||
if (!route) return { status: "not_found" };
|
||||
if (route.ownerId !== userId) return { status: "not_owner" };
|
||||
|
||||
const service = await getService(userId, providerId);
|
||||
if (!service) return { status: "no_connection" };
|
||||
if (!service.grantedScopes.includes("routes_write")) {
|
||||
return { status: "scope_missing" };
|
||||
}
|
||||
|
||||
// Pull the locked-in version GPX (not routes.gpx, which is the working copy).
|
||||
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" };
|
||||
|
||||
const parsed = await parseGpxAsync(versionGpx);
|
||||
const points = parsed.tracks.flat();
|
||||
if (points.length === 0) return { status: "no_geometry" };
|
||||
|
||||
const input: RoutePushInput = {
|
||||
routeId,
|
||||
routeName: route.name,
|
||||
description: route.description ?? undefined,
|
||||
gpx: versionGpx,
|
||||
startLat: points[0]!.lat,
|
||||
startLng: points[0]!.lon,
|
||||
distance: parsed.distance,
|
||||
ascent: parsed.elevation.gain,
|
||||
localVersion: versionNumber,
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = capabilityContextFor(service.id);
|
||||
const result = await manifest.routePusher.pushRoute(ctx, input);
|
||||
return {
|
||||
status: "success",
|
||||
remoteId: result.remoteId,
|
||||
pushedAt: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof NeedsRelinkError) return { status: "needs_relink" };
|
||||
if (err instanceof ConnectionNotActiveError) return { status: "needs_relink" };
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Map known HTTP-shape errors. The pusher throws Error / WahooHttpError;
|
||||
// we don't try to recover further here.
|
||||
if (message.includes("422")) {
|
||||
return { status: "error", code: "validation", message };
|
||||
}
|
||||
if (message.includes("429")) {
|
||||
return { status: "error", code: "rate_limit", message };
|
||||
}
|
||||
if (message.includes("401") || message.includes("403")) {
|
||||
return { status: "error", code: "token_expired", message };
|
||||
}
|
||||
return { status: "error", code: "generic", message };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// COMPATIBILITY SHIM — translates the legacy TokenSet-shaped API onto the
|
||||
// new connected_services / JSONB-credentials schema introduced by
|
||||
// deepen-connected-services. Once the routes and pushes.server.ts migrate
|
||||
// to ConnectedServiceManager (tasks 5.x of the change), this whole file
|
||||
// (and the rest of apps/journal/app/lib/sync/) goes away.
|
||||
//
|
||||
// Do not add new callers. Use apps/journal/app/lib/connected-services/
|
||||
// directly.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../db.ts";
|
||||
import { connectedServices } from "@trails-cool/db/schema/journal";
|
||||
import type { TokenSet } from "./types.ts";
|
||||
|
||||
interface OAuthBlob {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface LegacyConnection {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
providerUserId: string | null;
|
||||
grantedScopes: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function toLegacy(row: typeof connectedServices.$inferSelect): LegacyConnection {
|
||||
const blob = row.credentials as OAuthBlob;
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
provider: row.provider,
|
||||
accessToken: blob.access_token,
|
||||
refreshToken: blob.refresh_token,
|
||||
expiresAt: new Date(blob.expires_at),
|
||||
providerUserId: row.providerUserId,
|
||||
grantedScopes: row.grantedScopes,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toBlob(tokens: TokenSet): OAuthBlob {
|
||||
return {
|
||||
access_token: tokens.accessToken,
|
||||
refresh_token: tokens.refreshToken,
|
||||
expires_at: tokens.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveConnection(
|
||||
userId: string,
|
||||
provider: string,
|
||||
tokens: TokenSet,
|
||||
grantedScopes: string[] = [],
|
||||
) {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(connectedServices)
|
||||
.where(
|
||||
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
||||
);
|
||||
await db.insert(connectedServices).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
provider,
|
||||
credentialKind: "oauth",
|
||||
credentials: toBlob(tokens),
|
||||
status: "active",
|
||||
providerUserId: tokens.providerUserId ?? null,
|
||||
grantedScopes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConnection(
|
||||
userId: string,
|
||||
provider: string,
|
||||
): Promise<LegacyConnection | null> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(connectedServices)
|
||||
.where(
|
||||
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
||||
);
|
||||
return row ? toLegacy(row) : null;
|
||||
}
|
||||
|
||||
export async function getConnectionByProviderUser(
|
||||
provider: string,
|
||||
providerUserId: string,
|
||||
): Promise<LegacyConnection | null> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(connectedServices)
|
||||
.where(
|
||||
and(
|
||||
eq(connectedServices.provider, provider),
|
||||
eq(connectedServices.providerUserId, providerUserId),
|
||||
),
|
||||
);
|
||||
return row ? toLegacy(row) : null;
|
||||
}
|
||||
|
||||
export async function updateTokens(connectionId: string, tokens: TokenSet) {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(connectedServices)
|
||||
.set({ credentials: toBlob(tokens) })
|
||||
.where(eq(connectedServices.id, connectionId));
|
||||
}
|
||||
|
||||
export async function deleteConnection(userId: string, provider: string) {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(connectedServices)
|
||||
.where(
|
||||
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,285 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { wahooProvider } from "./wahoo";
|
||||
import { PushError, OAuthError } from "../types.ts";
|
||||
|
||||
const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit");
|
||||
const fitBuffer = readFileSync(fixturePath);
|
||||
|
||||
describe("wahooProvider.convertToGpx", () => {
|
||||
it("converts a FIT file to valid GPX with correct coordinates", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
|
||||
expect(gpx).not.toBeNull();
|
||||
expect(gpx).toContain('<?xml version="1.0"');
|
||||
expect(gpx).toContain("<gpx");
|
||||
expect(gpx).toContain("<trkpt");
|
||||
});
|
||||
|
||||
it("produces coordinates in valid lat/lon range (not semicircles)", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
|
||||
const latMatches = gpx!.matchAll(/lat="([^"]+)"/g);
|
||||
const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g);
|
||||
|
||||
const lats = [...latMatches].map((m) => parseFloat(m[1]!));
|
||||
const lons = [...lonMatches].map((m) => parseFloat(m[1]!));
|
||||
|
||||
expect(lats.length).toBeGreaterThan(10);
|
||||
expect(lons.length).toBeGreaterThan(10);
|
||||
|
||||
for (const lat of lats) {
|
||||
expect(lat).toBeGreaterThan(-90);
|
||||
expect(lat).toBeLessThan(90);
|
||||
// Should be real-world coords, not near-zero from double-conversion
|
||||
expect(Math.abs(lat)).toBeGreaterThan(1);
|
||||
}
|
||||
for (const lon of lons) {
|
||||
expect(lon).toBeGreaterThan(-180);
|
||||
expect(lon).toBeLessThan(180);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes ISO 8601 timestamps (not Date objects)", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
|
||||
const timeMatches = gpx!.matchAll(/<time>([^<]+)<\/time>/g);
|
||||
const times = [...timeMatches].map((m) => m[1]!);
|
||||
|
||||
expect(times.length).toBeGreaterThan(10);
|
||||
for (const t of times) {
|
||||
expect(t).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
expect(new Date(t).toString()).not.toBe("Invalid Date");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes elevation data", async () => {
|
||||
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
|
||||
expect(gpx).not.toBeNull();
|
||||
expect(gpx).toContain("<ele>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooProvider.pushRoute", () => {
|
||||
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:v1",
|
||||
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("posts a base64 FIT and returns the remote id", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 9876 }),
|
||||
});
|
||||
|
||||
const result = await wahooProvider.pushRoute!(tokens, 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");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers.Authorization).toBe("Bearer test-token");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get("route[external_id]")).toBe("route:abc:v1");
|
||||
expect(body.get("route[name]")).toBe("Test route");
|
||||
expect(body.get("route[description]")).toBe("A test");
|
||||
expect(body.get("route[start_lat]")).toBe("52.52");
|
||||
expect(body.get("route[file]")).toBe(
|
||||
`data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, "token_expired"],
|
||||
[403, "scope_missing"],
|
||||
[404, "not_found"],
|
||||
[422, "validation"],
|
||||
[429, "rate_limit"],
|
||||
[500, "generic"],
|
||||
])("maps HTTP %i to PushError code %s", async (status, code) => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => "boom",
|
||||
});
|
||||
|
||||
await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toMatchObject({
|
||||
name: "PushError",
|
||||
code,
|
||||
status,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws generic PushError when response lacks an id", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
});
|
||||
await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toBeInstanceOf(PushError);
|
||||
});
|
||||
});
|
||||
|
||||
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(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("maps Wahoo's 'too many unrevoked access tokens' 400 to OAuthError(too_many_tokens)", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () =>
|
||||
'{"error":"Too many unrevoked access tokens exist for this app and user. ..."}',
|
||||
});
|
||||
await expect(wahooProvider.exchangeCode("code", "https://x/cb")).rejects.toMatchObject({
|
||||
name: "OAuthError",
|
||||
code: "too_many_tokens",
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wahooProvider.revoke", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("DELETEs /v1/permissions with the bearer token", async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 204, text: async () => "" });
|
||||
await wahooProvider.revoke!({
|
||||
accessToken: "tok",
|
||||
refreshToken: "r",
|
||||
expiresAt: new Date(),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://api.wahooligan.com/v1/permissions");
|
||||
expect(init.method).toBe("DELETE");
|
||||
expect(init.headers.Authorization).toBe("Bearer tok");
|
||||
});
|
||||
|
||||
it("throws on non-OK response", async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 401, text: async () => "bad" });
|
||||
await expect(
|
||||
wahooProvider.revoke!({ accessToken: "tok", refreshToken: "r", expiresAt: new Date() }),
|
||||
).rejects.toThrow();
|
||||
// Reference OAuthError to keep the import used even if future tests trim above blocks.
|
||||
expect(OAuthError).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
import FitParser from "fit-file-parser";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import type {
|
||||
SyncProvider,
|
||||
TokenSet,
|
||||
Workout,
|
||||
WorkoutList,
|
||||
WebhookEvent,
|
||||
PushRoutePayload,
|
||||
PushRouteResult,
|
||||
} from "../types.ts";
|
||||
import { PushError, OAuthError } from "../types.ts";
|
||||
|
||||
const WAHOO_API = "https://api.wahooligan.com";
|
||||
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",
|
||||
scopes: ["workouts_read", "user_read", "offline_data", "routes_read", "routes_write"],
|
||||
|
||||
getAuthUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: this.scopes.join(" "),
|
||||
state,
|
||||
});
|
||||
return `${WAHOO_AUTH}/authorize?${params}`;
|
||||
},
|
||||
|
||||
async exchangeCode(code: string, redirectUri: string): Promise<TokenSet> {
|
||||
const resp = await fetch(`${WAHOO_AUTH}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
if (text.includes("Too many unrevoked access tokens")) {
|
||||
throw new OAuthError("too_many_tokens", text, resp.status);
|
||||
}
|
||||
throw new OAuthError("generic", `Wahoo token exchange failed: ${resp.status} ${text}`, resp.status);
|
||||
}
|
||||
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
|
||||
|
||||
// Fetch user info to get provider user ID
|
||||
const userResp = await fetch(`${WAHOO_API}/v1/user`, {
|
||||
headers: { Authorization: `Bearer ${data.access_token}` },
|
||||
});
|
||||
const user = userResp.ok ? (await userResp.json() as { id: number }) : null;
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in * 1000),
|
||||
providerUserId: user?.id?.toString(),
|
||||
};
|
||||
},
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<TokenSet> {
|
||||
const resp = await fetch(`${WAHOO_AUTH}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`Wahoo token refresh failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in * 1000),
|
||||
};
|
||||
},
|
||||
|
||||
async listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList> {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: "30" });
|
||||
const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`);
|
||||
const data = await resp.json() as {
|
||||
workouts: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
workout_type: string;
|
||||
starts: string;
|
||||
fitness_app_id?: number;
|
||||
workout_summary?: {
|
||||
duration_active_accum?: number;
|
||||
distance_accum?: number;
|
||||
file?: { url?: string };
|
||||
};
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
// Wahoo does not share workout data from third-party apps (fitness_app_id >= 1000)
|
||||
const wahooWorkouts = data.workouts.filter((w) => !w.fitness_app_id || w.fitness_app_id < 1000);
|
||||
|
||||
return {
|
||||
workouts: wahooWorkouts.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: w.name || `Workout ${w.id}`,
|
||||
type: w.workout_type ?? "unknown",
|
||||
startedAt: w.starts,
|
||||
duration: w.workout_summary?.duration_active_accum
|
||||
? Math.round(w.workout_summary.duration_active_accum)
|
||||
: null,
|
||||
distance: w.workout_summary?.distance_accum
|
||||
? Math.round(w.workout_summary.distance_accum)
|
||||
: null,
|
||||
fileUrl: w.workout_summary?.file?.url,
|
||||
})),
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
perPage: data.per_page,
|
||||
};
|
||||
},
|
||||
|
||||
async downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer> {
|
||||
if (!workout.fileUrl) throw new Error("No file URL for workout");
|
||||
const resp = await fetch(workout.fileUrl);
|
||||
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
},
|
||||
|
||||
async convertToGpx(fileBuffer: Buffer): Promise<string | null> {
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parser.parse(fileBuffer as any, (error: unknown, data: any) => {
|
||||
if (error) reject(error);
|
||||
else resolve(data ?? {});
|
||||
});
|
||||
});
|
||||
|
||||
const records = (parsed.records ?? []) as Array<{
|
||||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
|
||||
// fit-file-parser already converts semicircles to degrees
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
|
||||
if (trackPoints.length < 2) return null;
|
||||
|
||||
return generateGpx({
|
||||
name: "Wahoo workout",
|
||||
tracks: [trackPoints],
|
||||
});
|
||||
},
|
||||
|
||||
async pushRoute(tokens: TokenSet, payload: PushRoutePayload): Promise<PushRouteResult> {
|
||||
return wahooRouteRequest(tokens, "POST", `${WAHOO_API}/v1/routes`, payload);
|
||||
},
|
||||
|
||||
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> {
|
||||
const resp = await fetch(`${WAHOO_API}/v1/permissions`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`Wahoo revoke failed: ${resp.status} ${text}`);
|
||||
}
|
||||
},
|
||||
|
||||
parseWebhook(body: unknown): WebhookEvent | null {
|
||||
const payload = body as {
|
||||
event_type?: string;
|
||||
user?: { id?: number };
|
||||
workout_summary?: {
|
||||
id?: number;
|
||||
workout?: { id?: number };
|
||||
file?: { url?: string };
|
||||
};
|
||||
};
|
||||
|
||||
if (payload.event_type !== "workout_summary" || !payload.user?.id) return null;
|
||||
|
||||
return {
|
||||
eventType: payload.event_type,
|
||||
providerUserId: String(payload.user.id),
|
||||
workoutId: String(payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? ""),
|
||||
fileUrl: payload.workout_summary?.file?.url,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const SHORT_GPX = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.5200" lon="13.4050"><ele>34.0</ele></trkpt>
|
||||
<trkpt lat="52.5210" lon="13.4060"><ele>34.5</ele></trkpt>
|
||||
<trkpt lat="52.5220" lon="13.4070"><ele>35.0</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
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<string, unknown> = {}) {
|
||||
return {
|
||||
id: "wahoo",
|
||||
name: "Wahoo",
|
||||
scopes: ["routes_write"],
|
||||
pushRoute: vi.fn(),
|
||||
updateRoute: 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");
|
||||
expect(payload.startLat).toBeCloseTo(52.52, 4);
|
||||
expect(mockInsert).toHaveBeenCalledOnce();
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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",
|
||||
lastPushedVersion: 1,
|
||||
}],
|
||||
);
|
||||
|
||||
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();
|
||||
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 () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import type { SyncProvider } from "./types.ts";
|
||||
import { wahooProvider } from "./providers/wahoo.ts";
|
||||
|
||||
const providers: SyncProvider[] = [wahooProvider];
|
||||
|
||||
export function getProvider(id: string): SyncProvider | undefined {
|
||||
return providers.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function getAllProviders(): SyncProvider[] {
|
||||
return providers;
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
providerUserId?: string;
|
||||
}
|
||||
|
||||
export interface Workout {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
startedAt: string;
|
||||
duration: number | null; // seconds
|
||||
distance: number | null; // meters
|
||||
fileUrl?: string;
|
||||
}
|
||||
|
||||
export interface WorkoutList {
|
||||
workouts: Workout[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
export interface WebhookEvent {
|
||||
eventType: string;
|
||||
providerUserId: string;
|
||||
workoutId: string;
|
||||
fileUrl?: string;
|
||||
}
|
||||
|
||||
export interface PushRoutePayload {
|
||||
/** Pre-encoded FIT Course bytes — the action route runs gpxToFitCourse before calling pushRoute. */
|
||||
fit: Uint8Array;
|
||||
/** Stable per route — `route:<routeId>` for trails.cool. */
|
||||
externalId: string;
|
||||
providerUpdatedAt: Date;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Decimal degrees. */
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
/** Meters. */
|
||||
distance: number;
|
||||
/** Total elevation gain in meters. */
|
||||
ascent: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface PushRouteResult {
|
||||
remoteId: string;
|
||||
}
|
||||
|
||||
export type PushErrorCode =
|
||||
| "scope_missing"
|
||||
| "token_expired"
|
||||
| "validation"
|
||||
| "rate_limit"
|
||||
| "not_found"
|
||||
| "generic";
|
||||
|
||||
export type OAuthErrorCode = "too_many_tokens" | "generic";
|
||||
|
||||
export class OAuthError extends Error {
|
||||
code: OAuthErrorCode;
|
||||
status?: number;
|
||||
|
||||
constructor(code: OAuthErrorCode, message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "OAuthError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class PushError extends Error {
|
||||
code: PushErrorCode;
|
||||
status?: number;
|
||||
|
||||
constructor(code: PushErrorCode, message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = "PushError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
|
||||
getAuthUrl(redirectUri: string, state: string): string;
|
||||
exchangeCode(code: string, redirectUri: string): Promise<TokenSet>;
|
||||
refreshToken(refreshToken: string): Promise<TokenSet>;
|
||||
|
||||
listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList>;
|
||||
downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer>;
|
||||
convertToGpx(fileBuffer: Buffer): Promise<string | null>;
|
||||
|
||||
parseWebhook(body: unknown): WebhookEvent | null;
|
||||
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
export function providerSupportsPush(
|
||||
provider: SyncProvider,
|
||||
): provider is SyncProvider & { pushRoute: NonNullable<SyncProvider["pushRoute"]> } {
|
||||
return typeof provider.pushRoute === "function";
|
||||
}
|
||||
|
|
@ -1,17 +1,20 @@
|
|||
import { redirect, data } from "react-router";
|
||||
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";
|
||||
import { OAuthError } from "~/lib/sync/types";
|
||||
import { getManifest, link } from "~/lib/connected-services";
|
||||
import {
|
||||
decodeOAuthState,
|
||||
} from "~/lib/connected-services/oauth-state.server";
|
||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
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 manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.exchangeCode) {
|
||||
return data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const state = decodeOAuthState(url.searchParams.get("state"));
|
||||
|
|
@ -30,25 +33,34 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||
|
||||
try {
|
||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
||||
// Wahoo's token endpoint doesn't return a `scope` field and grants
|
||||
// scopes all-or-nothing, so the requested set is the granted set.
|
||||
await saveConnection(user.id, provider.id, tokens, provider.scopes);
|
||||
const exchange = await manifest.exchangeCode(code, redirectUri);
|
||||
await link({
|
||||
userId: user.id,
|
||||
provider: manifest.id,
|
||||
credentialKind: manifest.credentialKind,
|
||||
credentials: exchange.credentials as Record<string, unknown>,
|
||||
providerUserId: exchange.providerUserId,
|
||||
grantedScopes: exchange.grantedScopes,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`OAuth callback failed for ${params.provider}:`, e);
|
||||
const code = e instanceof OAuthError ? e.code : "sync_failed";
|
||||
return redirect(`${fallbackReturn}?error=${code}`);
|
||||
const errCode =
|
||||
typeof (e as { code?: string }).code === "string"
|
||||
? (e as { code: string }).code
|
||||
: "sync_failed";
|
||||
return redirect(`${fallbackReturn}?error=${errCode}`);
|
||||
}
|
||||
|
||||
if (state.pushAfter?.routeId) {
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
providerId: manifest.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 === "needs_relink") return redirect(`${target}?push=needs_permission`);
|
||||
if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
|
||||
return redirect(`${target}?push=${outcome.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.connect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { encodeOAuthState } from "~/lib/sync/pushes.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
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 manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.buildAuthUrl) {
|
||||
return data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||
const state = encodeOAuthState({ returnTo: "/settings/connections" });
|
||||
|
||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
||||
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,18 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.disconnect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { deleteConnection, getConnection } from "~/lib/sync/connections.server";
|
||||
import { getManifest, unlinkByUserProvider } from "~/lib/connected-services";
|
||||
|
||||
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 manifest = getManifest(params.provider);
|
||||
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
if (provider.revoke) {
|
||||
const conn = await getConnection(user.id, provider.id);
|
||||
if (conn) {
|
||||
try {
|
||||
await provider.revoke({
|
||||
accessToken: conn.accessToken,
|
||||
refreshToken: conn.refreshToken,
|
||||
expiresAt: conn.expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort: token may already be expired/revoked. Drop the local row regardless.
|
||||
console.warn(`Failed to revoke ${provider.id} token (continuing with disconnect):`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await deleteConnection(user.id, provider.id);
|
||||
// unlinkByUserProvider best-effort revokes at the provider, then deletes
|
||||
// the local row regardless of revoke outcome. Imported activities are
|
||||
// retained (FK is set null on imports.activityId, not cascaded).
|
||||
await unlinkByUserProvider(user.id, manifest.id);
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
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";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.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 manifest = getManifest(params.provider);
|
||||
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
const returnTo = `/routes/${params.routeId}`;
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
providerId: manifest.id,
|
||||
routeId: params.routeId,
|
||||
});
|
||||
|
||||
|
|
@ -22,14 +23,19 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
case "success":
|
||||
return redirect(`${returnTo}?push=success`);
|
||||
case "scope_missing": {
|
||||
if (!manifest.buildAuthUrl) {
|
||||
return redirect(`${returnTo}?push=needs_permission`);
|
||||
}
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
|
||||
const redirectUri = `${origin}/api/sync/callback/${manifest.id}`;
|
||||
const state = encodeOAuthState({
|
||||
pushAfter: { routeId: params.routeId },
|
||||
returnTo,
|
||||
});
|
||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
||||
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||
}
|
||||
case "needs_relink":
|
||||
return redirect(`${returnTo}?push=needs_permission`);
|
||||
case "no_connection":
|
||||
return redirect(`${returnTo}?push=no_connection`);
|
||||
case "no_geometry":
|
||||
|
|
|
|||
|
|
@ -1,68 +1,37 @@
|
|||
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";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
|
||||
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 manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.webhookReceiver) {
|
||||
// Don't reveal provider existence — return 200 silently.
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Verify webhook token
|
||||
// Verify webhook token (provider-specific shared secret).
|
||||
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
|
||||
if (
|
||||
expectedToken &&
|
||||
(body as { webhook_token?: string }).webhook_token !== expectedToken
|
||||
) {
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
||||
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 });
|
||||
const event = manifest.webhookReceiver.parseWebhook(body);
|
||||
if (!event) 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);
|
||||
await manifest.webhookReceiver.handle(event);
|
||||
} catch (e) {
|
||||
console.error(`Webhook import failed for ${provider.id}/${event.workoutId}:`, e);
|
||||
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
|
||||
}
|
||||
|
||||
// Always return 200 to acknowledge the webhook
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { canView, getSessionUser } from "~/lib/auth.server";
|
|||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||
import { getConnection } from "~/lib/sync/connections.server";
|
||||
import { getService } from "~/lib/connected-services";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
}
|
||||
| null = null;
|
||||
if (isOwner && user && !!route.gpx) {
|
||||
const connection = await getConnection(user.id, "wahoo");
|
||||
const connection = await getService(user.id, "wahoo");
|
||||
let latest:
|
||||
| {
|
||||
pushedAt: string | null;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Route } from "./+types/settings.connections";
|
|||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { connectedServices } from "@trails-cool/db/schema/journal";
|
||||
import { getAllProviders } from "~/lib/sync/registry";
|
||||
import { getAllManifests } from "~/lib/connected-services";
|
||||
|
||||
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
|
||||
type KnownError = (typeof KNOWN_ERRORS)[number];
|
||||
|
|
@ -30,9 +30,14 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
.from(connectedServices)
|
||||
.where(eq(connectedServices.userId, user.id));
|
||||
|
||||
const providers = getAllProviders().map((p) => {
|
||||
const conn = connections.find((c) => c.provider === p.id);
|
||||
return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId };
|
||||
const providers = getAllManifests().map((m) => {
|
||||
const conn = connections.find((c) => c.provider === m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.displayName,
|
||||
connected: !!conn,
|
||||
providerUserId: conn?.providerUserId,
|
||||
};
|
||||
});
|
||||
|
||||
return data({ providers });
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import { data, redirect, useFetcher } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/sync.import.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { getConnection, updateTokens } from "~/lib/sync/connections.server";
|
||||
import {
|
||||
getManifest,
|
||||
getService,
|
||||
capabilityContextFor,
|
||||
} from "~/lib/connected-services";
|
||||
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
||||
import { createActivity } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
|
@ -13,35 +16,28 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.importer) {
|
||||
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const connection = await getConnection(user.id, provider.id);
|
||||
if (!connection) return redirect("/settings");
|
||||
const service = await getService(user.id, manifest.id);
|
||||
if (!service) return redirect("/settings");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") ?? "1");
|
||||
|
||||
// Refresh token if needed
|
||||
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);
|
||||
}
|
||||
const ctx = capabilityContextFor(service.id);
|
||||
const workoutList = await manifest.importer.listImportable(ctx, page);
|
||||
|
||||
const workoutList = await provider.listWorkouts(tokens, page);
|
||||
const importedIds = await getImportedIds(
|
||||
user.id,
|
||||
provider.id,
|
||||
manifest.id,
|
||||
workoutList.workouts.map((w) => w.id),
|
||||
);
|
||||
|
||||
return data({
|
||||
provider: { id: provider.id, name: provider.name },
|
||||
provider: { id: manifest.id, name: manifest.displayName },
|
||||
workouts: workoutList.workouts.map((w) => ({
|
||||
...w,
|
||||
imported: importedIds.has(w.id),
|
||||
|
|
@ -55,11 +51,13 @@ 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) throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.importer) {
|
||||
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const connection = await getConnection(user.id, provider.id);
|
||||
if (!connection) return redirect("/settings");
|
||||
const service = await getService(user.id, manifest.id);
|
||||
if (!service) return redirect("/settings");
|
||||
|
||||
const formData = await request.formData();
|
||||
const workoutId = formData.get("workoutId") as string;
|
||||
|
|
@ -69,33 +67,56 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
const distance = formData.get("distance") as string;
|
||||
const duration = formData.get("duration") as string;
|
||||
|
||||
// Refresh token if needed
|
||||
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);
|
||||
}
|
||||
|
||||
// Inline import here (not via Importer.importOne) so we can pass through
|
||||
// the form-supplied metadata (started_at, distance, duration) the
|
||||
// capability seam doesn't carry. The seam-shape importer is reserved for
|
||||
// automatic / webhook-driven imports.
|
||||
let gpx: string | null = null;
|
||||
if (fileUrl) {
|
||||
const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl };
|
||||
const fileBuffer = await provider.downloadFile(tokens, workout);
|
||||
gpx = await provider.convertToGpx(fileBuffer);
|
||||
const ctx = capabilityContextFor(service.id);
|
||||
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
|
||||
// so the manager handles refresh of any near-expired token.
|
||||
const buffer = await ctx.withFreshCredentials(async () => {
|
||||
const resp = await fetch(fileUrl);
|
||||
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
});
|
||||
// Lazy-load to avoid bundling fit-file-parser into all routes.
|
||||
const { default: FitParser } = await import("fit-file-parser");
|
||||
const { generateGpx } = await import("@trails-cool/gpx");
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {})));
|
||||
});
|
||||
const records = (parsed.records ?? []) as Array<{
|
||||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
if (trackPoints.length >= 2) {
|
||||
gpx = generateGpx({ name: workoutName, tracks: [trackPoints] });
|
||||
}
|
||||
}
|
||||
|
||||
const activityId = await createActivity(user.id, {
|
||||
name: workoutName || `${provider.name} workout`,
|
||||
name: workoutName || `${manifest.displayName} workout`,
|
||||
gpx: gpx ?? undefined,
|
||||
distance: distance ? parseFloat(distance) : null,
|
||||
duration: duration ? parseInt(duration) : null,
|
||||
startedAt: startedAt ? new Date(startedAt) : null,
|
||||
});
|
||||
|
||||
await recordImport(user.id, provider.id, workoutId, activityId);
|
||||
await recordImport(user.id, manifest.id, workoutId, activityId);
|
||||
|
||||
return data({ imported: workoutId });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue