Add Wahoo push action route + OAuth callback resume
Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:
- resolves the latest route_versions row and uses that GPX (not
routes.gpx) so the bytes Wahoo gets match the snapshot the user
sees
- short-circuits on (user, route, version, provider) idempotency:
a successful prior push returns the existing remote_id without
re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
getAuthUrl with a base64url-encoded state carrying pushAfter +
returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
update on retry) so the UI can show success/failure state
The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.
Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5b0bf40b97
commit
a9c0093877
8 changed files with 526 additions and 12 deletions
245
apps/journal/app/lib/sync/pushes.server.test.ts
Normal file
245
apps/journal/app/lib/sync/pushes.server.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
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(),
|
||||
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:v2");
|
||||
expect(payload.startLat).toBeCloseTo(52.52, 4);
|
||||
expect(mockInsert).toHaveBeenCalledOnce();
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when the 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" }],
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
194
apps/journal/app/lib/sync/pushes.server.ts
Normal file
194
apps/journal/app/lib/sync/pushes.server.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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: if this exact (user, route, version) was already pushed, return that result.
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(syncPushes)
|
||||
.where(
|
||||
and(
|
||||
eq(syncPushes.userId, userId),
|
||||
eq(syncPushes.routeId, routeId),
|
||||
eq(syncPushes.routeVersion, versionNumber),
|
||||
eq(syncPushes.provider, providerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const existingRow = existing[0];
|
||||
if (existingRow?.pushedAt && existingRow.remoteId) {
|
||||
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}:v${versionNumber}`;
|
||||
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,
|
||||
pushedAt: success ? now : null,
|
||||
error,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(syncPushes.id, existingRow.id));
|
||||
} else {
|
||||
await db.insert(syncPushes).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
routeId,
|
||||
routeVersion: versionNumber,
|
||||
provider: providerId,
|
||||
externalId,
|
||||
remoteId,
|
||||
pushedAt: success ? now : null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let result;
|
||||
try {
|
||||
result = await provider.pushRoute(tokens, payload);
|
||||
} catch (e) {
|
||||
if (e instanceof PushError && e.code === "token_expired") {
|
||||
// 401 mid-call — refresh once and retry.
|
||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
||||
await updateTokens(connection.id, tokens);
|
||||
result = await provider.pushRoute(tokens, payload);
|
||||
} 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" };
|
||||
return { status: "error", code: e.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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ export default [
|
|||
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
|
||||
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
|
||||
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
|
||||
route("api/sync/push/:provider/:routeId", "routes/api.sync.push.$provider.$routeId.ts"),
|
||||
route("privacy", "routes/privacy.tsx"),
|
||||
route("legal/imprint", "routes/legal.imprint.tsx"),
|
||||
route("legal/terms", "routes/legal.terms.tsx"),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
|
|
@ -12,6 +13,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const state = decodeOAuthState(url.searchParams.get("state"));
|
||||
const fallbackReturn = state.returnTo ?? "/settings";
|
||||
|
||||
// User denied the new scope at Wahoo. Send them back to the originating
|
||||
// page with a notice instead of looping them through OAuth again.
|
||||
if (url.searchParams.get("error") === "access_denied") {
|
||||
return redirect(`${fallbackReturn}?push=needs_permission`);
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) return data({ error: "Missing authorization code" }, { status: 400 });
|
||||
|
||||
|
|
@ -25,8 +35,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
await saveConnection(user.id, provider.id, tokens, provider.scopes);
|
||||
} catch (e) {
|
||||
console.error(`OAuth callback failed for ${params.provider}:`, e);
|
||||
return redirect("/settings?error=sync_failed");
|
||||
return redirect(`${fallbackReturn}?error=sync_failed`);
|
||||
}
|
||||
|
||||
return redirect("/settings");
|
||||
if (state.pushAfter?.routeId) {
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.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 === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
|
||||
return redirect(`${target}?push=${outcome.status}`);
|
||||
}
|
||||
|
||||
return redirect(state.returnTo ?? "/settings");
|
||||
}
|
||||
|
|
|
|||
50
apps/journal/app/routes/api.sync.push.$provider.$routeId.ts
Normal file
50
apps/journal/app/routes/api.sync.push.$provider.$routeId.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server";
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
const returnTo = `/routes/${params.routeId}`;
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
routeId: params.routeId,
|
||||
});
|
||||
|
||||
switch (outcome.status) {
|
||||
case "success":
|
||||
return redirect(`${returnTo}?push=success`);
|
||||
case "scope_missing": {
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
|
||||
const state = encodeOAuthState({
|
||||
pushAfter: { routeId: params.routeId },
|
||||
returnTo,
|
||||
});
|
||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
||||
}
|
||||
case "no_connection":
|
||||
return redirect(`${returnTo}?push=no_connection`);
|
||||
case "no_geometry":
|
||||
return redirect(`${returnTo}?push=no_geometry`);
|
||||
case "not_owner":
|
||||
return data({ error: "Forbidden" }, { status: 403 });
|
||||
case "not_found":
|
||||
return data({ error: "Not found" }, { status: 404 });
|
||||
case "unsupported_provider":
|
||||
return data({ error: "Provider does not support push" }, { status: 400 });
|
||||
case "error":
|
||||
return redirect(`${returnTo}?push=error&code=${outcome.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function loader() {
|
||||
return data({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue