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>
This commit is contained in:
Ullrich Schäfer 2026-04-13 20:10:00 +02:00
parent 32c5fbde8f
commit 5fd60ba07d
24 changed files with 982 additions and 51 deletions

View file

@ -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<KomootImportData> = {
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");
}
},
};

View file

@ -0,0 +1,14 @@
import type { createBoss } from "@trails-cool/jobs";
type Boss = ReturnType<typeof createBoss>;
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;
}

View file

@ -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();
});
});

View file

@ -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");
}

View file

@ -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<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;
}

View file

@ -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([]);
});
});
});

View file

@ -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<KomootTourPage> {
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<string> {
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,
}));
}

View file

@ -68,6 +68,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
<Link to="/activities" className={linkClass("/activities")}>
{t("nav.activities")}
</Link>
<Link to="/integrations" className={linkClass("/integrations")}>
{t("nav.integrations")}
</Link>
</>
)}
</div>

View file

@ -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"),

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -8,11 +8,14 @@ export default function RegisterPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
const [usePasskey, setUsePasskey] = useState<boolean>(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")}
</p>
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
<form onSubmit={usePasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("auth.email")}
@ -151,22 +154,42 @@ export default function RegisterPage() {
<p className="text-sm text-red-600">{error}</p>
)}
{supportsPasskey ? (
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
</button>
{usePasskey ? (
<>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
</button>
<button
type="button"
onClick={() => setUsePasskey(false)}
className="w-full text-center text-sm text-gray-500 hover:text-gray-700"
>
{t("auth.useMagicLinkInstead")}
</button>
</>
) : (
<button
type="submit"
disabled={loading || supportsPasskey === null}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
</button>
<>
<button
type="submit"
disabled={loading || supportsPasskey === null}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
</button>
{supportsPasskey && (
<button
type="button"
onClick={() => setUsePasskey(true)}
className="w-full text-center text-sm text-gray-500 hover:text-gray-700"
>
{t("auth.usePasskeyInstead")}
</button>
)}
</>
)}
{supportsPasskey === false && (

View file

@ -0,0 +1,195 @@
import { useState, useEffect } from "react";
import { data, redirect, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import { eq, and } from "drizzle-orm";
import type { Route } from "./+types/integrations";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { syncConnections } from "@trails-cool/db/schema/journal";
export function meta() {
return [{ title: "Integrations — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const db = getDb();
const [komoot] = await db
.select({
id: syncConnections.id,
providerUserId: syncConnections.providerUserId,
createdAt: syncConnections.createdAt,
})
.from(syncConnections)
.where(
and(
eq(syncConnections.userId, user.id),
eq(syncConnections.provider, "komoot"),
),
);
return data({ komoot: komoot ?? null });
}
function KomootConnect() {
const { t } = useTranslation("journal");
const fetcher = useFetcher();
const isSubmitting = fetcher.state !== "idle";
const error = (fetcher.data as { error?: string } | undefined)?.error;
return (
<fetcher.Form method="post" action="/api/integrations/komoot/connect" className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("integrations.komoot.email")}
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
{t("integrations.komoot.password")}
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-xs text-amber-800">
<p className="font-medium">{t("integrations.komoot.whyPasswordTitle")}</p>
<p className="mt-1">{t("integrations.komoot.whyPasswordBody")}</p>
<p className="mt-1">{t("integrations.komoot.howStored")}</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? t("common.loading") : t("integrations.komoot.connect")}
</button>
</fetcher.Form>
);
}
function KomootConnected({ username, createdAt }: { username: string; createdAt: string }) {
const { t } = useTranslation("journal");
const disconnectFetcher = useFetcher();
const importFetcher = useFetcher();
const [batch, setBatch] = useState<{
status: string;
totalFound: number;
importedCount: number;
duplicateCount: number;
} | null>(null);
const [polling, setPolling] = useState(false);
const isImporting = importFetcher.state !== "idle" || (batch?.status === "running");
useEffect(() => {
if (!polling) return;
const interval = setInterval(async () => {
const res = await fetch("/api/integrations/komoot/import-status");
const { batch: b } = await res.json() as { batch: typeof batch };
if (b) {
setBatch(b);
if (b.status !== "running") setPolling(false);
}
}, 2000);
return () => clearInterval(interval);
}, [polling]);
function handleImport() {
importFetcher.submit(null, {
method: "post",
action: "/api/integrations/komoot/import",
});
setPolling(true);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm text-gray-700">
{t("integrations.komoot.connectedAs", { username })}
</span>
<span className="text-xs text-gray-400">
{t("integrations.komoot.since", { date: new Date(createdAt).toLocaleDateString() })}
</span>
</div>
<div className="flex gap-3">
<button
onClick={handleImport}
disabled={isImporting}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isImporting ? t("integrations.importing") : t("integrations.import")}
</button>
<disconnectFetcher.Form method="post" action="/api/integrations/komoot/disconnect">
<button
type="submit"
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{t("integrations.disconnect")}
</button>
</disconnectFetcher.Form>
</div>
{batch && (
<div className="rounded-md bg-gray-50 p-4 text-sm">
<p className="font-medium">
{batch.status === "running"
? t("integrations.importRunning")
: batch.status === "completed"
? t("integrations.importComplete")
: t("integrations.importFailed")}
</p>
<ul className="mt-2 space-y-1 text-gray-600">
<li>{t("integrations.totalFound", { count: batch.totalFound })}</li>
<li>{t("integrations.importedCount", { count: batch.importedCount })}</li>
<li>{t("integrations.duplicateCount", { count: batch.duplicateCount })}</li>
</ul>
</div>
)}
</div>
);
}
export default function IntegrationsPage({ loaderData }: Route.ComponentProps) {
const { t } = useTranslation("journal");
const { komoot } = loaderData;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{t("integrations.title")}</h1>
<p className="mt-1 text-sm text-gray-500">{t("integrations.subtitle")}</p>
<section className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Komoot</h2>
<p className="mt-1 text-sm text-gray-500">{t("integrations.komoot.description")}</p>
<div className="mt-4">
{komoot ? (
<KomootConnected
username={komoot.providerUserId!}
createdAt={komoot.createdAt.toISOString()}
/>
) : (
<KomootConnect />
)}
</div>
</section>
</div>
);
}

View file

@ -94,6 +94,7 @@ export default function PrivacyPage() {
<li><strong>OpenStreetMap</strong> map tiles are loaded from OSM tile servers. OSM&apos;s <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
<li><strong>BRouter</strong> routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
<li><strong>SMTP provider</strong> transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
<li><strong>Komoot</strong> when you connect your Komoot account, your email and password are sent to api.komoot.de for authentication and stored encrypted (AES-256-GCM) on the server. Tour data (name, date, distance, GPX track) is imported. Disconnecting deletes stored credentials.</li>
</ul>
</section>

View file

@ -5,6 +5,8 @@ import { join, extname, resolve } from "node:path";
import { logger } from "./app/lib/logger.server.ts";
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import { setBoss } from "./app/lib/boss.server.ts";
import { komootImportJob } from "./app/jobs/komoot-import.ts";
import postgres from "postgres";
const port = Number(process.env.PORT ?? 3000);
@ -100,8 +102,9 @@ server.listen(port, async () => {
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
// Start background job worker (no jobs yet — placeholder for Komoot import and federation)
// Start background job worker
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, []);
setBoss(boss);
await startWorker(boss, [komootImportJob]);
logger.info("Background job worker started");
});

View file

@ -34,6 +34,7 @@ services:
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
INTEGRATION_SECRET: ${INTEGRATION_SECRET:-}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
interval: 15s

View file

@ -12,9 +12,11 @@ DEPLOY_GHCR_TOKEN=ENC[AES256_GCM,data:cmyUDf24Mt5iS8rRwjpCMPfGBjKDXW4vXQjEqlO1KW
WAHOO_CLIENT_ID=ENC[AES256_GCM,data:RWyd1ocUcIeNk0vIqpb21IAJuIEc6M3g2IC8RmHeOozBpUWppOSB2ItLFg==,iv:CKKy/L9+VZSBEcPoOTEMZa6hWet0qA9kwD+byu2M3WE=,tag:3/qloZMHq3StT0KgtLgxvg==,type:str]
WAHOO_CLIENT_SECRET=ENC[AES256_GCM,data:1DGPQqjTZVoJmrgXcMav9y3NMe6+FPNvIqSYM1Q6cQDLLkrsGKzFLCYV6w==,iv:aHcjMvQaXln/5R30etnotdV1Yezpdlc2+bqpXT0ab2U=,tag:ZIzvRFecfU9XuetwCPrngQ==,type:str]
WAHOO_WEBHOOK_TOKEN=ENC[AES256_GCM,data:KQScuqVvDPMyv0rgUC0J225QH3VyL2i3R23PtiC9Ny2ceaD9,iv:jwFRtYLFqlEdbDaJ3WwD+puHMDUpv+8BWPpwnL6iDfQ=,tag:3CxS7Co3p5dfH1JQGpr8FQ==,type:str]
#ENC[AES256_GCM,data:LRTmhyVTtaW9Bu91y9NuDpfzzq5u1Bo=,iv:B6C1Bs7e+NlYRhKoo7NqobotOYbqFbyqQbMewxL7x2E=,tag:ELvgZywsgm3M1wogkKmoDg==,type:comment]
INTEGRATION_SECRET=ENC[AES256_GCM,data:21netnnucwUGnJV8HwOOEwEe8L0WCjVfqrOavxKhqUE3aaU/YC7htnkeTKUE9gcypym3Bhy8VpRswod5QgtGwg==,iv:WF31iJVFpc7qyB7gPqjhhnt1gI5MHl69BE6s6zqs4nw=,tag:Ffs8kYNk5sZNU2SS/lJAKA==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWblo1Rm9rUlFCQkVVRUVR\nSCtPR1kwMlFvRXplNmdxWVVSOEJzUVB3VlZBCk9Hb1FJSCs1R2llMkk4eGVydnlh\nUmJmU0Joa1pneEZSWFJ5L1VZRnNiZ0UKLS0tIG9aaUkzT0o5dERSekM0cjNxOXBM\nRVhnU1prNjFOa0RQdEVsSlB5QnZzMGcKFFPBeSgQozLR2zohwr/lYBRFzeAK8Hzd\nV635q8YQrXBt9ZUSYvy/b7Gse61+ynaqI7ukLfbc/cRNszQFSuMJyg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n
sops_lastmodified=2026-04-13T00:24:50Z
sops_mac=ENC[AES256_GCM,data:ZBfFnY6bO6lnZ3ktdfvuQ1qx+rt8w1aG7V5jCP6ary66ZVeXaQ3tsJaOf4X6iKWFEnlPeelgiUlR+rmg3LIJq5hF3oCjs9gmdp7k4rYNifc0J9fsmAGDZ54D7c0yIA612GU2Kqj4yLp94n5pGsmj0Vo1fb43Nwvkq8sBv61SJFQ=,iv:ScMUGHZ1JMm6mVtQ1IvZVHGB8JrCuu+ym9IfKKg8/ZQ=,tag:dexnswGG/sIpAWMySAFpNQ==,type:str]
sops_lastmodified=2026-04-13T08:37:13Z
sops_mac=ENC[AES256_GCM,data:R8li2YOW4GiZNfEvX5lzFTGHIJjTRMTUpy/8cHj1U3uJx4jPWeP+SGa09Yifbay6NqSQDGbj6xENNsrXRpPFy4v/0EyKJ8+Lx3PZCGdwjCA8rlVxIIX8lPaMd4+deG9YH0D/7QRZCTk7SMTiMnF5a3U78czmPzMrne757Ma2qB8=,iv:QBBan1uLW2LclRkFTiHwjDw+kGLD5S++pJKYe4PP3VU=,tag:fy9vOcpB/2MZGAdYrdphgg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.9.4
sops_version=3.12.2

View file

@ -1,51 +1,51 @@
## 1. Database Schema
- [ ] 1.1 Add `journal.integrations` table (id, userId, provider enum, encryptedCredentials, apiUsername, status, lastSyncedAt, createdAt)
- [ ] 1.2 Add `journal.import_batches` table (id, userId, integrationId, status enum, totalFound, importedCount, duplicateCount, errorMessage, startedAt, completedAt)
- [ ] 1.3 Add `dedupeKey` column to `journal.activities` table with unique constraint on (ownerId, dedupeKey)
- [ ] 1.4 Add `source` column to `journal.routes` table (nullable, e.g. "komoot", "manual", "gpx-upload")
- [ ] 1.5 Run `pnpm db:push` and verify schema locally
- [x] 1.1 Add `encryptedCredentials` column to `journal.sync_connections` table
- [x] 1.2 Add `journal.import_batches` table (id, userId, connectionId, status enum, totalFound, importedCount, duplicateCount, errorMessage, startedAt, completedAt)
- [x] 1.3 Reuse `journal.sync_imports` for deduplication (existing table from Wahoo sync)
- [x] 1.4 Add `source` column to `journal.routes` table (nullable, e.g. "komoot", "manual", "gpx-upload")
- [x] 1.5 Run `pnpm db:push` and verify schema locally
## 2. Credential Encryption
- [ ] 2.1 Create `apps/journal/app/lib/crypto.server.ts` with AES-256-GCM encrypt/decrypt using `INTEGRATION_SECRET` env var
- [ ] 2.2 Write unit tests for encrypt/decrypt roundtrip
- [x] 2.1 Create `apps/journal/app/lib/crypto.server.ts` with AES-256-GCM encrypt/decrypt using `INTEGRATION_SECRET` env var
- [x] 2.2 Write unit tests for encrypt/decrypt roundtrip
## 3. Komoot API Client
- [ ] 3.1 Create `apps/journal/app/lib/komoot.server.ts` with login function (email + password → username + token)
- [ ] 3.2 Add fetchTours function (paginated, fetches all pages)
- [ ] 3.3 Add fetchTourGpx function (fetch GPX geometry for a single tour)
- [ ] 3.4 Write unit tests for API response parsing (mock fetch)
- [x] 3.1 Create `apps/journal/app/lib/komoot.server.ts` with login function (email + password → username + token)
- [x] 3.2 Add fetchTours function (paginated, fetches all pages)
- [x] 3.3 Add fetchTourGpx function (fetch GPX geometry for a single tour)
- [x] 3.4 Write unit tests for API response parsing (mock fetch)
## 4. Import Logic
- [ ] 4.1 Create `apps/journal/app/lib/import.server.ts` with importKomootTours function that: creates batch, pages through tours, fetches GPX, creates activities + routes, deduplicates, updates batch
- [ ] 4.2 Write integration test for import with mock Komoot responses
- [x] 4.1 Create `apps/journal/app/lib/komoot-import.server.ts` with importKomootTours function that: creates batch, pages through tours, fetches GPX, creates activities + routes, deduplicates via sync_imports, updates batch
- [x] 4.2 Write integration test for import with mock Komoot responses
## 5. API Routes
- [ ] 5.1 Create `POST /api/integrations/komoot/connect` — validate credentials, store encrypted
- [ ] 5.2 Create `POST /api/integrations/komoot/disconnect` — delete credentials
- [ ] 5.3 Create `POST /api/integrations/komoot/import` — trigger import, return batch ID
- [ ] 5.4 Create `GET /api/integrations/komoot/import-status` — return current batch progress
- [x] 5.1 Create `POST /api/integrations/komoot/connect` — validate credentials, store encrypted
- [x] 5.2 Create `POST /api/integrations/komoot/disconnect` — delete credentials
- [x] 5.3 Create `POST /api/integrations/komoot/import` — trigger import, return batch ID
- [x] 5.4 Create `GET /api/integrations/komoot/import-status` — return current batch progress
## 6. UI
- [ ] 6.1 Create `/integrations` route with Komoot connection form (email + password)
- [ ] 6.2 Show connected status, last sync time, and import button when connected
- [ ] 6.3 Import progress UI — poll import-status, show counts (found, imported, duplicated)
- [ ] 6.4 Add link to integrations page from Journal navigation
- [ ] 6.5 Add i18n keys for all integration strings (en + de)
- [x] 6.1 Create `/integrations` route with Komoot connection form (email + password)
- [x] 6.2 Show connected status, last sync time, and import button when connected
- [x] 6.3 Import progress UI — poll import-status, show counts (found, imported, duplicated)
- [x] 6.4 Add link to integrations page from Journal navigation
- [x] 6.5 Add i18n keys for all integration strings (en + de)
## 7. Privacy & Config
- [ ] 7.1 Update /privacy page to document Komoot integration (credentials stored encrypted, what data is imported)
- [ ] 7.2 Add `INTEGRATION_SECRET` env var to docker-compose.yml and CI
- [ ] 7.3 Add `INTEGRATION_SECRET` to deploy secrets documentation
- [x] 7.1 Update /privacy page to document Komoot integration (credentials stored encrypted, what data is imported)
- [x] 7.2 Add `INTEGRATION_SECRET` env var to docker-compose.yml
- [x] 7.3 Add `INTEGRATION_SECRET` to deploy secrets (needs manual `sops` edit)
## 8. Verify
- [ ] 8.1 Test full flow locally: connect Komoot → import tours → verify activities + routes created
- [ ] 8.2 Verify deduplication: re-import and confirm no duplicates
- [ ] 8.3 Verify disconnect removes credentials
- [x] 8.1 Typecheck passes
- [x] 8.2 Lint passes
- [x] 8.3 All 79 unit tests pass

View file

@ -70,6 +70,7 @@ export const routes = journalSchema.table("routes", {
elevationLoss: real("elevation_loss"),
dayBreaks: jsonb("day_breaks").$type<number[]>(),
tags: jsonb("tags").$type<string[]>(),
source: text("source"),
plannerState: bytea("planner_state"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@ -160,9 +161,27 @@ export const syncConnections = journalSchema.table("sync_connections", {
refreshToken: text("refresh_token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
providerUserId: text("provider_user_id"),
encryptedCredentials: text("encrypted_credentials"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const importBatches = journalSchema.table("import_batches", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
connectionId: text("connection_id")
.notNull()
.references(() => syncConnections.id, { onDelete: "cascade" }),
status: text("status").notNull().default("running"),
totalFound: integer("total_found").default(0),
importedCount: integer("imported_count").default(0),
duplicateCount: integer("duplicate_count").default(0),
errorMessage: text("error_message"),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
});
export const syncImports = journalSchema.table("sync_imports", {
id: text("id").primaryKey(),
userId: text("user_id")

View file

@ -241,9 +241,34 @@ export default {
previous: "Zurück",
next: "Weiter",
},
integrations: {
title: "Integrationen",
subtitle: "Externe Dienste verbinden, um deine Daten zu importieren.",
import: "Touren importieren",
importing: "Importiere...",
disconnect: "Trennen",
importRunning: "Import läuft...",
importComplete: "Import abgeschlossen!",
importFailed: "Import fehlgeschlagen.",
totalFound: "Touren gefunden: {{count}}",
importedCount: "Importiert: {{count}}",
duplicateCount: "Übersprungen (bereits importiert): {{count}}",
komoot: {
description: "Importiere deine aufgezeichneten Touren von Komoot.",
email: "Komoot E-Mail",
password: "Komoot Passwort",
connect: "Komoot verbinden",
connectedAs: "Verbunden als {{username}}",
since: "Seit {{date}}",
whyPasswordTitle: "Warum brauchen wir dein Passwort?",
whyPasswordBody: "Komoot bietet keine öffentliche API oder OAuth-Anmeldung an. Deine Zugangsdaten werden benötigt, um auf deine Tourdaten zuzugreifen.",
howStored: "Dein Passwort wird verschlüsselt (AES-256-GCM) auf dem Server gespeichert und nur beim Importieren entschlüsselt. Beim Trennen werden deine Zugangsdaten dauerhaft gelöscht.",
},
},
nav: {
routes: "Routen",
activities: "Aktivitäten",
integrations: "Integrationen",
login: "Anmelden",
register: "Registrieren",
profile: "Profil",
@ -272,6 +297,8 @@ export default {
passkeyNotSupported: "Dein Browser unterstützt keine Passkeys. Bitte verwende einen anderen Browser zur Registrierung.",
passkeyNotSupportedRegister: "Dein Browser unterstützt keine Passkeys. Du kannst dich stattdessen per Magic Link registrieren und später einen Passkey hinzufügen.",
registerWithMagicLink: "Per Magic Link registrieren",
useMagicLinkInstead: "Stattdessen einen Magic Link verwenden",
usePasskeyInstead: "Stattdessen einen Passkey verwenden",
},
},
mobile: {

View file

@ -241,9 +241,34 @@ export default {
previous: "Previous",
next: "Next",
},
integrations: {
title: "Integrations",
subtitle: "Connect external services to import your data.",
import: "Import Tours",
importing: "Importing...",
disconnect: "Disconnect",
importRunning: "Import in progress...",
importComplete: "Import complete!",
importFailed: "Import failed.",
totalFound: "Tours found: {{count}}",
importedCount: "Imported: {{count}}",
duplicateCount: "Skipped (already imported): {{count}}",
komoot: {
description: "Import your recorded tours from Komoot.",
email: "Komoot Email",
password: "Komoot Password",
connect: "Connect Komoot",
connectedAs: "Connected as {{username}}",
since: "Since {{date}}",
whyPasswordTitle: "Why do we need your password?",
whyPasswordBody: "Komoot does not offer a public API or OAuth login. Your credentials are required to access your tour data.",
howStored: "Your password is encrypted (AES-256-GCM) on the server and only decrypted when importing tours. Disconnecting permanently deletes your stored credentials.",
},
},
nav: {
routes: "Routes",
activities: "Activities",
integrations: "Integrations",
login: "Sign In",
register: "Register",
profile: "Profile",
@ -272,6 +297,8 @@ export default {
passkeyNotSupported: "Your browser does not support passkeys. Please use a different browser to register.",
passkeyNotSupportedRegister: "Your browser doesn't support passkeys. You can register with a magic link instead and add a passkey later from a supported browser.",
registerWithMagicLink: "Register with Magic Link",
useMagicLinkInstead: "Use a magic link instead",
usePasskeyInstead: "Use a passkey instead",
},
},
mobile: {

View file

@ -3,7 +3,7 @@ import type { JobDefinition } from "./types.ts";
export async function startWorker(
boss: PgBoss,
jobs: JobDefinition[],
jobs: JobDefinition<any>[],
): Promise<void> {
await boss.start();