Followup to #447. The audit ran on \`as unknown as\` first; this PR closes out \`as any\` separately. After this, \`grep -rn ' as any\\b' apps/ packages/\` returns 0 (excluding tests and node_modules). ## Sites fixed **\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** — \`komootBulkImportJob as any\`. The job had a typed payload (\`JobDefinition<KomootBulkImportData>\`) but the worker's \`JobDefinition[]\` array forced a contravariance cast at every site that mixed typed and untyped jobs. Dropped the generic from \`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only one job (komoot-bulk-import) used the generic, so the surface is tiny. **\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in \`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any, (err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\` is structurally \`Buffer<ArrayBufferLike>\` (which includes SharedArrayBuffer). The runtime accepts either, so narrowed the cast to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc callback typings; the library exports a proper \`FitParserCallback\`. **\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler took \`(event: any)\`. maplibre-react-native v11 generates the event type via React Native codegen but doesn't re-export it as a public TypeScript type. Replaced with a local structural slice of the parts we actually read. Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` / \`test\` all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
124 lines
4.7 KiB
TypeScript
124 lines
4.7 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";
|
|
import { generateGpx } from "@trails-cool/gpx";
|
|
|
|
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 fit-file-parser only — it's heavy and only the FIT
|
|
// ingestion path needs it. @trails-cool/gpx is already pulled in
|
|
// statically by other modules in the chunk, so the dynamic import
|
|
// there was ineffective.
|
|
const { default: FitParser } = await import("fit-file-parser");
|
|
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
const parser = new FitParser({ force: true });
|
|
// See lib/connected-services/fit.ts for the same Buffer<ArrayBuffer>
|
|
// narrowing rationale.
|
|
parser.parse(buffer as Buffer<ArrayBuffer>, (err, d) =>
|
|
err ? reject(err) : resolve((d ?? {}) as Record<string, unknown>),
|
|
);
|
|
});
|
|
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 };
|
|
}
|