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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue