trails/apps/journal/app/lib/komoot-import.server.ts
Ullrich Schäfer 5fd60ba07d WIP: Komoot import integration
Add Komoot API client, import logic, crypto helpers, integration routes,
DB schema changes, and i18n strings for the Komoot import feature.

Import processing runs as a durable pg-boss background job with retries
instead of blocking the HTTP request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:10:00 +02:00

143 lines
3.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { getDb } from "./db.ts";
import { importBatches } from "@trails-cool/db/schema/journal";
import { createActivity } from "./activities.server.ts";
import { createRoute, type RouteInput } from "./routes.server.ts";
import { isAlreadyImported, recordImport } from "./sync/imports.server.ts";
import { fetchTours, fetchTourGpx, type KomootCredentials, type KomootTour } from "./komoot.server.ts";
export async function importKomootTours(
userId: string,
connectionId: string,
creds: KomootCredentials,
existingBatchId?: string,
): Promise<string> {
const db = getDb();
const batchId = existingBatchId ?? randomUUID();
if (!existingBatchId) {
await db.insert(importBatches).values({
id: batchId,
userId,
connectionId,
status: "running",
});
} else {
await db
.update(importBatches)
.set({ status: "running" })
.where(eq(importBatches.id, batchId));
}
try {
let page = 0;
let totalFound = 0;
let importedCount = 0;
let duplicateCount = 0;
let hasMore = true;
while (hasMore) {
const result = await fetchTours(creds, page);
totalFound += result.tours.length;
await db
.update(importBatches)
.set({ totalFound })
.where(eq(importBatches.id, batchId));
for (const tour of result.tours) {
const alreadyImported = await isAlreadyImported(userId, "komoot", tour.id);
if (alreadyImported) {
duplicateCount++;
continue;
}
try {
await importSingleTour(userId, creds, tour);
importedCount++;
} catch (err) {
console.error(`Failed to import Komoot tour ${tour.id}:`, err);
}
await db
.update(importBatches)
.set({ importedCount, duplicateCount })
.where(eq(importBatches.id, batchId));
}
hasMore = page + 1 < result.totalPages;
page++;
}
await db
.update(importBatches)
.set({
status: "completed",
totalFound,
importedCount,
duplicateCount,
completedAt: new Date(),
})
.where(eq(importBatches.id, batchId));
return batchId;
} catch (err) {
await db
.update(importBatches)
.set({
status: "failed",
errorMessage: err instanceof Error ? err.message : String(err),
completedAt: new Date(),
})
.where(eq(importBatches.id, batchId));
throw err;
}
}
async function importSingleTour(
userId: string,
creds: KomootCredentials,
tour: KomootTour,
): Promise<void> {
let gpx: string | undefined;
try {
gpx = await fetchTourGpx(creds, tour.id);
} catch {
// Tour without downloadable GPX — import metadata only
}
const routeInput: RouteInput = {
name: tour.name,
gpx,
};
const routeId = await createRoute(userId, routeInput);
// Mark the route source as komoot
const db = getDb();
const { routes } = await import("@trails-cool/db/schema/journal");
await db.update(routes).set({ source: "komoot" }).where(eq(routes.id, routeId));
const activityId = await createActivity(userId, {
name: tour.name,
gpx,
routeId,
distance: tour.distance,
duration: tour.duration,
startedAt: new Date(tour.date),
});
await recordImport(userId, "komoot", tour.id, activityId);
}
export async function getLatestBatch(userId: string, connectionId: string) {
const db = getDb();
const [batch] = await db
.select()
.from(importBatches)
.where(eq(importBatches.connectionId, connectionId))
.orderBy(importBatches.startedAt)
.limit(1);
return batch ?? null;
}