Completes the .server.ts split started in #418. Every route that mixes a default-export component with a server-only loader/action now has a sibling <route>.server.ts holding the data-fetching helpers; the route .tsx is a thin delegator. Routes converted (21): activities._index, activities.$id, activities.new, auth.accept-terms, auth.verify, explore, feed, notifications, routes._index, routes.$id, routes.$id.edit, routes.new, settings, settings.account, settings.connections.komoot, settings.profile, settings.security, sync.import.$provider, sync.import.komoot, users.$username.followers, users.$username.following Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx): - loader → `return data(await loadX(request, params?))` - action → `return await xAction(request, params?)` - All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the .server.ts sibling. - `throw redirect(...)` and `throw data(...)` propagate through the delegator unchanged. No behavior changes — pure module-graph cleanup. Component modules no longer transitively import the DB client; Vite's tree-shake of server-only code is now backed by an explicit, file-local contract. Verified: - pnpm typecheck — green - pnpm lint — green - pnpm test — 181 passed, 31 integration-gated skipped - pnpm --filter @trails-cool/journal build — succeeds Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
// Server-only loader/action for /sync/import/:provider. See `home.server.ts`.
|
|
|
|
import { data, redirect } from "react-router";
|
|
import { requireSessionUser } from "~/lib/auth/session.server";
|
|
import {
|
|
getManifest,
|
|
getService,
|
|
capabilityContextFor,
|
|
} from "~/lib/connected-services";
|
|
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
|
import { createActivity } from "~/lib/activities.server";
|
|
|
|
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
|
|
const user = await requireSessionUser(request);
|
|
|
|
const manifest = getManifest(provider ?? "");
|
|
if (!manifest || !manifest.importer) {
|
|
throw data({ error: "Unknown provider" }, { status: 404 });
|
|
}
|
|
|
|
const service = await getService(user.id, manifest.id);
|
|
if (!service) throw redirect("/settings");
|
|
|
|
const url = new URL(request.url);
|
|
const page = parseInt(url.searchParams.get("page") ?? "1");
|
|
|
|
const ctx = capabilityContextFor(service.id);
|
|
const workoutList = await manifest.importer.listImportable(ctx, page);
|
|
|
|
const importedIds = await getImportedIds(
|
|
user.id,
|
|
manifest.id,
|
|
workoutList.workouts.map((w) => w.id),
|
|
);
|
|
|
|
return {
|
|
provider: { id: manifest.id, name: manifest.displayName },
|
|
workouts: workoutList.workouts.map((w) => ({
|
|
...w,
|
|
imported: importedIds.has(w.id),
|
|
})),
|
|
page: workoutList.page,
|
|
totalPages: Math.ceil(workoutList.total / workoutList.perPage),
|
|
};
|
|
}
|
|
|
|
export async function syncImportProviderAction(request: Request, provider: string | undefined) {
|
|
const user = await requireSessionUser(request);
|
|
|
|
const manifest = getManifest(provider ?? "");
|
|
if (!manifest || !manifest.importer) {
|
|
throw data({ error: "Unknown provider" }, { status: 404 });
|
|
}
|
|
|
|
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;
|
|
const workoutName = formData.get("workoutName") as string;
|
|
const fileUrl = formData.get("fileUrl") as string;
|
|
const startedAt = formData.get("startedAt") as string;
|
|
const distance = formData.get("distance") as string;
|
|
const duration = formData.get("duration") as string;
|
|
|
|
// 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 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 || `${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, manifest.id, workoutId, activityId);
|
|
|
|
return { imported: workoutId };
|
|
}
|