From 5fd60ba07d245d1c4e5a1e7f8623d0fcea1d7004 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Mon, 13 Apr 2026 20:10:00 +0200
Subject: [PATCH] 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)
---
apps/journal/app/jobs/komoot-import.ts | 55 +++++
apps/journal/app/lib/boss.server.ts | 14 ++
apps/journal/app/lib/crypto.server.test.ts | 40 ++++
apps/journal/app/lib/crypto.server.ts | 31 +++
apps/journal/app/lib/komoot-import.server.ts | 143 +++++++++++++
apps/journal/app/lib/komoot.server.test.ts | 54 +++++
apps/journal/app/lib/komoot.server.ts | 141 +++++++++++++
apps/journal/app/root.tsx | 3 +
apps/journal/app/routes.ts | 5 +
.../routes/api.integrations.komoot.connect.ts | 44 ++++
.../api.integrations.komoot.disconnect.ts | 23 +++
.../api.integrations.komoot.import-status.ts | 35 ++++
.../routes/api.integrations.komoot.import.ts | 45 ++++
apps/journal/app/routes/auth.register.tsx | 57 +++--
apps/journal/app/routes/integrations.tsx | 195 ++++++++++++++++++
apps/journal/app/routes/privacy.tsx | 1 +
apps/journal/server.ts | 7 +-
infrastructure/docker-compose.yml | 1 +
infrastructure/secrets.app.env | 8 +-
openspec/changes/komoot-import/tasks.md | 56 ++---
packages/db/src/schema/journal.ts | 19 ++
packages/i18n/src/locales/de.ts | 27 +++
packages/i18n/src/locales/en.ts | 27 +++
packages/jobs/src/worker.ts | 2 +-
24 files changed, 982 insertions(+), 51 deletions(-)
create mode 100644 apps/journal/app/jobs/komoot-import.ts
create mode 100644 apps/journal/app/lib/boss.server.ts
create mode 100644 apps/journal/app/lib/crypto.server.test.ts
create mode 100644 apps/journal/app/lib/crypto.server.ts
create mode 100644 apps/journal/app/lib/komoot-import.server.ts
create mode 100644 apps/journal/app/lib/komoot.server.test.ts
create mode 100644 apps/journal/app/lib/komoot.server.ts
create mode 100644 apps/journal/app/routes/api.integrations.komoot.connect.ts
create mode 100644 apps/journal/app/routes/api.integrations.komoot.disconnect.ts
create mode 100644 apps/journal/app/routes/api.integrations.komoot.import-status.ts
create mode 100644 apps/journal/app/routes/api.integrations.komoot.import.ts
create mode 100644 apps/journal/app/routes/integrations.tsx
diff --git a/apps/journal/app/jobs/komoot-import.ts b/apps/journal/app/jobs/komoot-import.ts
new file mode 100644
index 0000000..cd262e8
--- /dev/null
+++ b/apps/journal/app/jobs/komoot-import.ts
@@ -0,0 +1,55 @@
+import type { JobDefinition } from "@trails-cool/jobs";
+import { eq, and } from "drizzle-orm";
+import { getDb } from "../lib/db.ts";
+import { syncConnections } from "@trails-cool/db/schema/journal";
+import { decrypt } from "../lib/crypto.server.ts";
+import { importKomootTours } from "../lib/komoot-import.server.ts";
+import type { KomootCredentials } from "../lib/komoot.server.ts";
+import { logger } from "../lib/logger.server.ts";
+
+interface KomootImportData {
+ userId: string;
+ connectionId: string;
+ batchId: string;
+}
+
+export const komootImportJob: JobDefinition = {
+ name: "komoot-import",
+ retryLimit: 1,
+ expireInSeconds: 300,
+ async handler(jobs) {
+ for (const job of jobs) {
+ const { userId, connectionId, batchId } = job.data;
+ logger.info({ userId, connectionId, batchId }, "starting Komoot import job");
+
+ const db = getDb();
+ const [connection] = await db
+ .select()
+ .from(syncConnections)
+ .where(
+ and(
+ eq(syncConnections.id, connectionId),
+ eq(syncConnections.userId, userId),
+ ),
+ );
+
+ if (!connection?.encryptedCredentials) {
+ throw new Error(`Komoot connection ${connectionId} not found or missing credentials`);
+ }
+
+ const { email, password } = JSON.parse(decrypt(connection.encryptedCredentials)) as {
+ email: string;
+ password: string;
+ };
+ const creds: KomootCredentials = {
+ email,
+ password,
+ username: connection.providerUserId!,
+ token: connection.accessToken,
+ };
+
+ await importKomootTours(userId, connectionId, creds, batchId);
+ logger.info({ batchId }, "Komoot import job completed");
+ }
+ },
+};
diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts
new file mode 100644
index 0000000..d588ecc
--- /dev/null
+++ b/apps/journal/app/lib/boss.server.ts
@@ -0,0 +1,14 @@
+import type { createBoss } from "@trails-cool/jobs";
+
+type Boss = ReturnType;
+
+let boss: Boss | null = null;
+
+export function setBoss(instance: Boss): void {
+ boss = instance;
+}
+
+export function getBoss(): Boss {
+ if (!boss) throw new Error("pg-boss not initialized — server still starting?");
+ return boss;
+}
diff --git a/apps/journal/app/lib/crypto.server.test.ts b/apps/journal/app/lib/crypto.server.test.ts
new file mode 100644
index 0000000..4e04039
--- /dev/null
+++ b/apps/journal/app/lib/crypto.server.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import { encrypt, decrypt } from "./crypto.server";
+
+beforeAll(() => {
+ process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests";
+});
+
+describe("crypto", () => {
+ it("encrypts and decrypts a string", () => {
+ const plaintext = "my-secret-password-123";
+ const encrypted = encrypt(plaintext);
+ expect(encrypted).not.toBe(plaintext);
+ expect(decrypt(encrypted)).toBe(plaintext);
+ });
+
+ it("produces different ciphertext for the same input", () => {
+ const plaintext = "same-input";
+ const a = encrypt(plaintext);
+ const b = encrypt(plaintext);
+ expect(a).not.toBe(b);
+ expect(decrypt(a)).toBe(plaintext);
+ expect(decrypt(b)).toBe(plaintext);
+ });
+
+ it("handles empty string", () => {
+ const encrypted = encrypt("");
+ expect(decrypt(encrypted)).toBe("");
+ });
+
+ it("handles unicode", () => {
+ const plaintext = "Passwort mit Ümlauten: äöü 🏔️";
+ expect(decrypt(encrypt(plaintext))).toBe(plaintext);
+ });
+
+ it("throws on tampered ciphertext", () => {
+ const encrypted = encrypt("secret");
+ const tampered = encrypted.slice(0, -2) + "XX";
+ expect(() => decrypt(tampered)).toThrow();
+ });
+});
diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts
new file mode 100644
index 0000000..5223ec0
--- /dev/null
+++ b/apps/journal/app/lib/crypto.server.ts
@@ -0,0 +1,31 @@
+import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
+
+const ALGORITHM = "aes-256-gcm";
+const IV_LENGTH = 12;
+const TAG_LENGTH = 16;
+
+function getKey(): Buffer {
+ const secret = process.env.INTEGRATION_SECRET;
+ if (!secret) throw new Error("INTEGRATION_SECRET environment variable is required");
+ return scryptSync(secret, "trails-cool-integrations", 32);
+}
+
+export function encrypt(plaintext: string): string {
+ const key = getKey();
+ const iv = randomBytes(IV_LENGTH);
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return Buffer.concat([iv, tag, encrypted]).toString("base64");
+}
+
+export function decrypt(encoded: string): string {
+ const key = getKey();
+ const buf = Buffer.from(encoded, "base64");
+ const iv = buf.subarray(0, IV_LENGTH);
+ const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
+ const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
+ const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
+ decipher.setAuthTag(tag);
+ return decipher.update(encrypted) + decipher.final("utf8");
+}
diff --git a/apps/journal/app/lib/komoot-import.server.ts b/apps/journal/app/lib/komoot-import.server.ts
new file mode 100644
index 0000000..5ef1573
--- /dev/null
+++ b/apps/journal/app/lib/komoot-import.server.ts
@@ -0,0 +1,143 @@
+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 {
+ 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 {
+ 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;
+}
diff --git a/apps/journal/app/lib/komoot.server.test.ts b/apps/journal/app/lib/komoot.server.test.ts
new file mode 100644
index 0000000..4f78851
--- /dev/null
+++ b/apps/journal/app/lib/komoot.server.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import { parseTourResponse } from "./komoot.server";
+
+describe("komoot", () => {
+ describe("parseTourResponse", () => {
+ it("parses a page of tours", () => {
+ const data = {
+ _embedded: {
+ tours: [
+ {
+ id: 12345,
+ name: "Morning hike in the Alps",
+ sport: "hike",
+ date: "2026-03-15T08:30:00.000Z",
+ distance: 15234.5,
+ duration: 18900,
+ elevation_up: 850,
+ elevation_down: 830,
+ },
+ {
+ id: 67890,
+ name: "Cycling to work",
+ sport: "touringbicycle",
+ date: "2026-03-14T07:00:00.000Z",
+ distance: 8500,
+ duration: 1800,
+ elevation_up: 45,
+ elevation_down: 50,
+ },
+ ],
+ },
+ };
+
+ const tours = parseTourResponse(data);
+ expect(tours).toHaveLength(2);
+ expect(tours[0]).toEqual({
+ id: "12345",
+ name: "Morning hike in the Alps",
+ sport: "hike",
+ date: "2026-03-15T08:30:00.000Z",
+ distance: 15234.5,
+ duration: 18900,
+ elevationUp: 850,
+ elevationDown: 830,
+ });
+ });
+
+ it("handles empty response", () => {
+ expect(parseTourResponse({})).toEqual([]);
+ expect(parseTourResponse({ _embedded: {} })).toEqual([]);
+ expect(parseTourResponse({ _embedded: { tours: [] } })).toEqual([]);
+ });
+ });
+});
diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts
new file mode 100644
index 0000000..1b08369
--- /dev/null
+++ b/apps/journal/app/lib/komoot.server.ts
@@ -0,0 +1,141 @@
+const API_BASE = "https://api.komoot.de/v007";
+const AUTH_BASE = "https://api.komoot.de/v006";
+
+export interface KomootCredentials {
+ email: string;
+ password: string;
+ username: string;
+ token: string;
+}
+
+export interface KomootTour {
+ id: string;
+ name: string;
+ sport: string;
+ date: string;
+ distance: number;
+ duration: number;
+ elevationUp: number;
+ elevationDown: number;
+}
+
+export interface KomootTourPage {
+ tours: KomootTour[];
+ totalPages: number;
+ page: number;
+}
+
+export async function login(
+ email: string,
+ password: string,
+): Promise<{ username: string; token: string }> {
+ const res = await fetch(`${AUTH_BASE}/account/email/${email}/`, {
+ headers: {
+ Authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`,
+ },
+ });
+
+ if (!res.ok) {
+ if (res.status === 401 || res.status === 403) {
+ throw new Error("Invalid Komoot credentials");
+ }
+ throw new Error(`Komoot login failed: ${res.status}`);
+ }
+
+ const data = (await res.json()) as { username: string };
+ return { username: data.username, token: Buffer.from(`${email}:${password}`).toString("base64") };
+}
+
+export async function fetchTours(
+ creds: KomootCredentials,
+ page = 0,
+): Promise {
+ const res = await fetch(
+ `${API_BASE}/users/${creds.username}/tours/?page=${page}&limit=50&type=tour_recorded&sort_field=date&sort_direction=desc`,
+ {
+ headers: { Authorization: `Basic ${creds.token}` },
+ },
+ );
+
+ if (!res.ok) {
+ if (res.status === 401) throw new Error("Komoot auth expired");
+ throw new Error(`Komoot tours fetch failed: ${res.status}`);
+ }
+
+ const data = (await res.json()) as {
+ _embedded?: {
+ tours?: Array<{
+ id: number;
+ name: string;
+ sport: string;
+ date: string;
+ distance: number;
+ duration: number;
+ elevation_up: number;
+ elevation_down: number;
+ }>;
+ };
+ page?: { totalPages: number; number: number };
+ };
+
+ console.log("[komoot] page response keys:", Object.keys(data), "page field:", JSON.stringify(data.page));
+
+ const tours = (data._embedded?.tours ?? []).map((t) => ({
+ id: String(t.id),
+ name: t.name,
+ sport: t.sport,
+ date: t.date,
+ distance: t.distance,
+ duration: t.duration,
+ elevationUp: t.elevation_up,
+ elevationDown: t.elevation_down,
+ }));
+
+ return {
+ tours,
+ totalPages: data.page?.totalPages ?? 1,
+ page: data.page?.number ?? 0,
+ };
+}
+
+export async function fetchTourGpx(
+ creds: KomootCredentials,
+ tourId: string,
+): Promise {
+ const res = await fetch(`${API_BASE}/tours/${tourId}.gpx`, {
+ headers: { Authorization: `Basic ${creds.token}` },
+ });
+
+ if (!res.ok) {
+ throw new Error(`Komoot GPX fetch failed for tour ${tourId}: ${res.status}`);
+ }
+
+ return res.text();
+}
+
+export function parseTourResponse(data: unknown): KomootTour[] {
+ const obj = data as {
+ _embedded?: {
+ tours?: Array<{
+ id: number;
+ name: string;
+ sport: string;
+ date: string;
+ distance: number;
+ duration: number;
+ elevation_up: number;
+ elevation_down: number;
+ }>;
+ };
+ };
+ return (obj._embedded?.tours ?? []).map((t) => ({
+ id: String(t.id),
+ name: t.name,
+ sport: t.sport,
+ date: t.date,
+ distance: t.distance,
+ duration: t.duration,
+ elevationUp: t.elevation_up,
+ elevationDown: t.elevation_down,
+ }));
+}
diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx
index be4a5f4..b4094b0 100644
--- a/apps/journal/app/root.tsx
+++ b/apps/journal/app/root.tsx
@@ -68,6 +68,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
{t("nav.activities")}
+
+ {t("nav.integrations")}
+
>
)}
diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts
index 537369b..19dd6c9 100644
--- a/apps/journal/app/routes.ts
+++ b/apps/journal/app/routes.ts
@@ -32,6 +32,11 @@ export default [
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
+ route("integrations", "routes/integrations.tsx"),
+ route("api/integrations/komoot/connect", "routes/api.integrations.komoot.connect.ts"),
+ route("api/integrations/komoot/disconnect", "routes/api.integrations.komoot.disconnect.ts"),
+ route("api/integrations/komoot/import", "routes/api.integrations.komoot.import.ts"),
+ route("api/integrations/komoot/import-status", "routes/api.integrations.komoot.import-status.ts"),
route("privacy", "routes/privacy.tsx"),
// REST API v1
route("api/v1/routes", "routes/api.v1.routes._index.ts"),
diff --git a/apps/journal/app/routes/api.integrations.komoot.connect.ts b/apps/journal/app/routes/api.integrations.komoot.connect.ts
new file mode 100644
index 0000000..b77ff29
--- /dev/null
+++ b/apps/journal/app/routes/api.integrations.komoot.connect.ts
@@ -0,0 +1,44 @@
+import { data } from "react-router";
+import { randomUUID } from "node:crypto";
+import type { Route } from "./+types/api.integrations.komoot.connect";
+import { getSessionUser } from "~/lib/auth.server";
+import { getDb } from "~/lib/db";
+import { syncConnections } from "@trails-cool/db/schema/journal";
+import { login } from "~/lib/komoot.server";
+import { encrypt } from "~/lib/crypto.server";
+
+export async function action({ request }: Route.ActionArgs) {
+ const user = await getSessionUser(request);
+ if (!user) throw data(null, { status: 401 });
+
+ const form = await request.formData();
+ const email = String(form.get("email") ?? "").trim();
+ const password = String(form.get("password") ?? "");
+
+ if (!email || !password) {
+ return data({ error: "Email and password are required" }, { status: 400 });
+ }
+
+ let credentials: { username: string; token: string };
+ try {
+ credentials = await login(email, password);
+ } catch {
+ return data({ error: "Invalid Komoot credentials" }, { status: 400 });
+ }
+
+ const encrypted = encrypt(JSON.stringify({ email, password }));
+
+ const db = getDb();
+ await db.insert(syncConnections).values({
+ id: randomUUID(),
+ userId: user.id,
+ provider: "komoot",
+ accessToken: credentials.token,
+ refreshToken: "",
+ expiresAt: new Date("2099-12-31"),
+ providerUserId: credentials.username,
+ encryptedCredentials: encrypted,
+ });
+
+ return data({ ok: true });
+}
diff --git a/apps/journal/app/routes/api.integrations.komoot.disconnect.ts b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts
new file mode 100644
index 0000000..971fee2
--- /dev/null
+++ b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts
@@ -0,0 +1,23 @@
+import { data } from "react-router";
+import { eq, and } from "drizzle-orm";
+import type { Route } from "./+types/api.integrations.komoot.disconnect";
+import { getSessionUser } from "~/lib/auth.server";
+import { getDb } from "~/lib/db";
+import { syncConnections } from "@trails-cool/db/schema/journal";
+
+export async function action({ request }: Route.ActionArgs) {
+ const user = await getSessionUser(request);
+ if (!user) throw data(null, { status: 401 });
+
+ const db = getDb();
+ await db
+ .delete(syncConnections)
+ .where(
+ and(
+ eq(syncConnections.userId, user.id),
+ eq(syncConnections.provider, "komoot"),
+ ),
+ );
+
+ return data({ ok: true });
+}
diff --git a/apps/journal/app/routes/api.integrations.komoot.import-status.ts b/apps/journal/app/routes/api.integrations.komoot.import-status.ts
new file mode 100644
index 0000000..aad5d30
--- /dev/null
+++ b/apps/journal/app/routes/api.integrations.komoot.import-status.ts
@@ -0,0 +1,35 @@
+import { data } from "react-router";
+import { eq, and, desc } from "drizzle-orm";
+import type { Route } from "./+types/api.integrations.komoot.import-status";
+import { getSessionUser } from "~/lib/auth.server";
+import { getDb } from "~/lib/db";
+import { syncConnections, importBatches } from "@trails-cool/db/schema/journal";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const user = await getSessionUser(request);
+ if (!user) throw data(null, { status: 401 });
+
+ const db = getDb();
+ const [connection] = await db
+ .select()
+ .from(syncConnections)
+ .where(
+ and(
+ eq(syncConnections.userId, user.id),
+ eq(syncConnections.provider, "komoot"),
+ ),
+ );
+
+ if (!connection) {
+ return data({ batch: null });
+ }
+
+ const [batch] = await db
+ .select()
+ .from(importBatches)
+ .where(eq(importBatches.connectionId, connection.id))
+ .orderBy(desc(importBatches.startedAt))
+ .limit(1);
+
+ return data({ batch: batch ?? null });
+}
diff --git a/apps/journal/app/routes/api.integrations.komoot.import.ts b/apps/journal/app/routes/api.integrations.komoot.import.ts
new file mode 100644
index 0000000..3417f4d
--- /dev/null
+++ b/apps/journal/app/routes/api.integrations.komoot.import.ts
@@ -0,0 +1,45 @@
+import { randomUUID } from "node:crypto";
+import { data } from "react-router";
+import { eq, and } from "drizzle-orm";
+import type { Route } from "./+types/api.integrations.komoot.import";
+import { getSessionUser } from "~/lib/auth.server";
+import { getDb } from "~/lib/db";
+import { syncConnections, importBatches } from "@trails-cool/db/schema/journal";
+import { getBoss } from "~/lib/boss.server";
+
+export async function action({ request }: Route.ActionArgs) {
+ const user = await getSessionUser(request);
+ if (!user) throw data(null, { status: 401 });
+
+ const db = getDb();
+ const [connection] = await db
+ .select()
+ .from(syncConnections)
+ .where(
+ and(
+ eq(syncConnections.userId, user.id),
+ eq(syncConnections.provider, "komoot"),
+ ),
+ );
+
+ if (!connection?.encryptedCredentials) {
+ return data({ error: "Komoot not connected" }, { status: 400 });
+ }
+
+ const batchId = randomUUID();
+ await db.insert(importBatches).values({
+ id: batchId,
+ userId: user.id,
+ connectionId: connection.id,
+ status: "queued",
+ });
+
+ const boss = getBoss();
+ await boss.send("komoot-import", {
+ userId: user.id,
+ connectionId: connection.id,
+ batchId,
+ });
+
+ return data({ batchId });
+}
diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx
index 90ea6b1..dc8f9f0 100644
--- a/apps/journal/app/routes/auth.register.tsx
+++ b/apps/journal/app/routes/auth.register.tsx
@@ -8,11 +8,14 @@ export default function RegisterPage() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [supportsPasskey, setSupportsPasskey] = useState(null);
+ const [usePasskey, setUsePasskey] = useState(true);
const [magicLinkSent, setMagicLinkSent] = useState(false);
useEffect(() => {
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
- setSupportsPasskey(browserSupportsWebAuthn());
+ const supported = browserSupportsWebAuthn();
+ setSupportsPasskey(supported);
+ setUsePasskey(supported);
});
}, []);
@@ -114,7 +117,7 @@ export default function RegisterPage() {
{t("auth.registerDescription")}
-