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:
parent
32c5fbde8f
commit
5fd60ba07d
24 changed files with 982 additions and 51 deletions
55
apps/journal/app/jobs/komoot-import.ts
Normal file
55
apps/journal/app/jobs/komoot-import.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
14
apps/journal/app/lib/boss.server.ts
Normal file
14
apps/journal/app/lib/boss.server.ts
Normal 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;
|
||||
}
|
||||
40
apps/journal/app/lib/crypto.server.test.ts
Normal file
40
apps/journal/app/lib/crypto.server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
31
apps/journal/app/lib/crypto.server.ts
Normal file
31
apps/journal/app/lib/crypto.server.ts
Normal 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");
|
||||
}
|
||||
143
apps/journal/app/lib/komoot-import.server.ts
Normal file
143
apps/journal/app/lib/komoot-import.server.ts
Normal 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;
|
||||
}
|
||||
54
apps/journal/app/lib/komoot.server.test.ts
Normal file
54
apps/journal/app/lib/komoot.server.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
141
apps/journal/app/lib/komoot.server.ts
Normal file
141
apps/journal/app/lib/komoot.server.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
44
apps/journal/app/routes/api.integrations.komoot.connect.ts
Normal file
44
apps/journal/app/routes/api.integrations.komoot.connect.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
45
apps/journal/app/routes/api.integrations.komoot.import.ts
Normal file
45
apps/journal/app/routes/api.integrations.komoot.import.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
195
apps/journal/app/routes/integrations.tsx
Normal file
195
apps/journal/app/routes/integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ export default function PrivacyPage() {
|
|||
<li><strong>OpenStreetMap</strong> — map tiles are loaded from OSM tile servers. OSM'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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue