fix(journal): extract loaders/actions for the remaining 21 mixed routes
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>
This commit is contained in:
parent
a57637868b
commit
df562742e1
42 changed files with 1160 additions and 937 deletions
118
apps/journal/app/routes/sync.import.$provider.server.ts
Normal file
118
apps/journal/app/routes/sync.import.$provider.server.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue