trails/apps/journal/app/lib/sync/imports.server.ts
Ullrich Schäfer e387e1f798
Deepen three architectural seams: FIT consolidation, host election extraction, injectable db
- Extract shared fitToGpx into connected-services/fit.ts (FIT is a Garmin
  open standard used by Wahoo, Coros, Garmin — not provider-specific)
- Add importActivity() to sync/imports.server.ts so providers call one
  function instead of createActivity + recordImport separately; eliminates
  direct dependency on activities.server.ts from provider adapters
- Update wahoo importer + webhook to use both shared helpers
- Extract useHostElection(yjs) hook from useRouting so host election is
  independently testable without mounting the full routing stack
- Add setDb() to journal db.ts for module-level injection in unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:52:31 +02:00

79 lines
2.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq, and, inArray } from "drizzle-orm";
import { getDb } from "../db.ts";
import { syncImports } from "@trails-cool/db/schema/journal";
import { createActivity } from "../activities.server.ts";
export async function recordImport(
userId: string,
provider: string,
externalWorkoutId: string,
activityId: string,
) {
const db = getDb();
await db.insert(syncImports).values({
id: randomUUID(),
userId,
provider,
externalWorkoutId,
activityId,
});
}
export async function isAlreadyImported(
userId: string,
provider: string,
externalWorkoutId: string,
): Promise<boolean> {
const db = getDb();
const [row] = await db
.select({ id: syncImports.id })
.from(syncImports)
.where(
and(
eq(syncImports.userId, userId),
eq(syncImports.provider, provider),
eq(syncImports.externalWorkoutId, externalWorkoutId),
),
);
return !!row;
}
export async function deleteImportByActivity(activityId: string) {
const db = getDb();
await db.delete(syncImports).where(eq(syncImports.activityId, activityId));
}
// Single callsite for "create an activity from an imported workout and record
// the dedup entry". Providers call this instead of calling createActivity +
// recordImport separately, so the pair stays atomic from the provider's view.
export async function importActivity(
userId: string,
provider: string,
externalWorkoutId: string,
input: { name: string; gpx?: string },
): Promise<{ activityId: string }> {
const activityId = await createActivity(userId, { name: input.name, gpx: input.gpx });
await recordImport(userId, provider, externalWorkoutId, activityId);
return { activityId };
}
export async function getImportedIds(
userId: string,
provider: string,
externalWorkoutIds: string[],
): Promise<Set<string>> {
if (externalWorkoutIds.length === 0) return new Set();
const db = getDb();
const rows = await db
.select({ externalWorkoutId: syncImports.externalWorkoutId })
.from(syncImports)
.where(
and(
eq(syncImports.userId, userId),
eq(syncImports.provider, provider),
inArray(syncImports.externalWorkoutId, externalWorkoutIds),
),
);
return new Set(rows.map((r) => r.externalWorkoutId));
}