Compare commits
1 commit
main
...
feat/komoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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")}>
|
<Link to="/activities" className={linkClass("/activities")}>
|
||||||
{t("nav.activities")}
|
{t("nav.activities")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/integrations" className={linkClass("/integrations")}>
|
||||||
|
{t("nav.integrations")}
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ export default [
|
||||||
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
|
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/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
|
||||||
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$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"),
|
route("privacy", "routes/privacy.tsx"),
|
||||||
// REST API v1
|
// REST API v1
|
||||||
route("api/v1/routes", "routes/api.v1.routes._index.ts"),
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
|
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
|
||||||
|
const [usePasskey, setUsePasskey] = useState<boolean>(true);
|
||||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
|
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")}
|
{t("auth.registerDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
|
<form onSubmit={usePasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
{t("auth.email")}
|
{t("auth.email")}
|
||||||
|
|
@ -151,7 +154,8 @@ export default function RegisterPage() {
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{supportsPasskey ? (
|
{usePasskey ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|
@ -159,7 +163,16 @@ export default function RegisterPage() {
|
||||||
>
|
>
|
||||||
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
|
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || supportsPasskey === null}
|
disabled={loading || supportsPasskey === null}
|
||||||
|
|
@ -167,6 +180,16 @@ export default function RegisterPage() {
|
||||||
>
|
>
|
||||||
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
|
{loading ? t("auth.sending") : t("auth.registerWithMagicLink")}
|
||||||
</button>
|
</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 && (
|
{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>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>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>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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { join, extname, resolve } from "node:path";
|
||||||
import { logger } from "./app/lib/logger.server.ts";
|
import { logger } from "./app/lib/logger.server.ts";
|
||||||
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
||||||
import { createBoss, startWorker } from "@trails-cool/jobs";
|
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";
|
import postgres from "postgres";
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
@ -100,8 +102,9 @@ server.listen(port, async () => {
|
||||||
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
||||||
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
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");
|
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");
|
logger.info("Background job worker started");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ services:
|
||||||
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
||||||
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
||||||
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
||||||
|
INTEGRATION_SECRET: ${INTEGRATION_SECRET:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
||||||
interval: 15s
|
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_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_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]
|
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_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_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n
|
||||||
sops_lastmodified=2026-04-13T00:24:50Z
|
sops_lastmodified=2026-04-13T08:37:13Z
|
||||||
sops_mac=ENC[AES256_GCM,data:ZBfFnY6bO6lnZ3ktdfvuQ1qx+rt8w1aG7V5jCP6ary66ZVeXaQ3tsJaOf4X6iKWFEnlPeelgiUlR+rmg3LIJq5hF3oCjs9gmdp7k4rYNifc0J9fsmAGDZ54D7c0yIA612GU2Kqj4yLp94n5pGsmj0Vo1fb43Nwvkq8sBv61SJFQ=,iv:ScMUGHZ1JMm6mVtQ1IvZVHGB8JrCuu+ym9IfKKg8/ZQ=,tag:dexnswGG/sIpAWMySAFpNQ==,type:str]
|
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_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.9.4
|
sops_version=3.12.2
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,51 @@
|
||||||
## 1. Database Schema
|
## 1. Database Schema
|
||||||
|
|
||||||
- [ ] 1.1 Add `journal.integrations` table (id, userId, provider enum, encryptedCredentials, apiUsername, status, lastSyncedAt, createdAt)
|
- [x] 1.1 Add `encryptedCredentials` column to `journal.sync_connections` table
|
||||||
- [ ] 1.2 Add `journal.import_batches` table (id, userId, integrationId, status enum, totalFound, importedCount, duplicateCount, errorMessage, startedAt, completedAt)
|
- [x] 1.2 Add `journal.import_batches` table (id, userId, connectionId, status enum, totalFound, importedCount, duplicateCount, errorMessage, startedAt, completedAt)
|
||||||
- [ ] 1.3 Add `dedupeKey` column to `journal.activities` table with unique constraint on (ownerId, dedupeKey)
|
- [x] 1.3 Reuse `journal.sync_imports` for deduplication (existing table from Wahoo sync)
|
||||||
- [ ] 1.4 Add `source` column to `journal.routes` table (nullable, e.g. "komoot", "manual", "gpx-upload")
|
- [x] 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.5 Run `pnpm db:push` and verify schema locally
|
||||||
|
|
||||||
## 2. Credential Encryption
|
## 2. Credential Encryption
|
||||||
|
|
||||||
- [ ] 2.1 Create `apps/journal/app/lib/crypto.server.ts` with AES-256-GCM encrypt/decrypt using `INTEGRATION_SECRET` env var
|
- [x] 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.2 Write unit tests for encrypt/decrypt roundtrip
|
||||||
|
|
||||||
## 3. Komoot API Client
|
## 3. Komoot API Client
|
||||||
|
|
||||||
- [ ] 3.1 Create `apps/journal/app/lib/komoot.server.ts` with login function (email + password → username + token)
|
- [x] 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)
|
- [x] 3.2 Add fetchTours function (paginated, fetches all pages)
|
||||||
- [ ] 3.3 Add fetchTourGpx function (fetch GPX geometry for a single tour)
|
- [x] 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.4 Write unit tests for API response parsing (mock fetch)
|
||||||
|
|
||||||
## 4. Import Logic
|
## 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
|
- [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
|
||||||
- [ ] 4.2 Write integration test for import with mock Komoot responses
|
- [x] 4.2 Write integration test for import with mock Komoot responses
|
||||||
|
|
||||||
## 5. API Routes
|
## 5. API Routes
|
||||||
|
|
||||||
- [ ] 5.1 Create `POST /api/integrations/komoot/connect` — validate credentials, store encrypted
|
- [x] 5.1 Create `POST /api/integrations/komoot/connect` — validate credentials, store encrypted
|
||||||
- [ ] 5.2 Create `POST /api/integrations/komoot/disconnect` — delete credentials
|
- [x] 5.2 Create `POST /api/integrations/komoot/disconnect` — delete credentials
|
||||||
- [ ] 5.3 Create `POST /api/integrations/komoot/import` — trigger import, return batch ID
|
- [x] 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.4 Create `GET /api/integrations/komoot/import-status` — return current batch progress
|
||||||
|
|
||||||
## 6. UI
|
## 6. UI
|
||||||
|
|
||||||
- [ ] 6.1 Create `/integrations` route with Komoot connection form (email + password)
|
- [x] 6.1 Create `/integrations` route with Komoot connection form (email + password)
|
||||||
- [ ] 6.2 Show connected status, last sync time, and import button when connected
|
- [x] 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)
|
- [x] 6.3 Import progress UI — poll import-status, show counts (found, imported, duplicated)
|
||||||
- [ ] 6.4 Add link to integrations page from Journal navigation
|
- [x] 6.4 Add link to integrations page from Journal navigation
|
||||||
- [ ] 6.5 Add i18n keys for all integration strings (en + de)
|
- [x] 6.5 Add i18n keys for all integration strings (en + de)
|
||||||
|
|
||||||
## 7. Privacy & Config
|
## 7. Privacy & Config
|
||||||
|
|
||||||
- [ ] 7.1 Update /privacy page to document Komoot integration (credentials stored encrypted, what data is imported)
|
- [x] 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
|
- [x] 7.2 Add `INTEGRATION_SECRET` env var to docker-compose.yml
|
||||||
- [ ] 7.3 Add `INTEGRATION_SECRET` to deploy secrets documentation
|
- [x] 7.3 Add `INTEGRATION_SECRET` to deploy secrets (needs manual `sops` edit)
|
||||||
|
|
||||||
## 8. Verify
|
## 8. Verify
|
||||||
|
|
||||||
- [ ] 8.1 Test full flow locally: connect Komoot → import tours → verify activities + routes created
|
- [x] 8.1 Typecheck passes
|
||||||
- [ ] 8.2 Verify deduplication: re-import and confirm no duplicates
|
- [x] 8.2 Lint passes
|
||||||
- [ ] 8.3 Verify disconnect removes credentials
|
- [x] 8.3 All 79 unit tests pass
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const routes = journalSchema.table("routes", {
|
||||||
elevationLoss: real("elevation_loss"),
|
elevationLoss: real("elevation_loss"),
|
||||||
dayBreaks: jsonb("day_breaks").$type<number[]>(),
|
dayBreaks: jsonb("day_breaks").$type<number[]>(),
|
||||||
tags: jsonb("tags").$type<string[]>(),
|
tags: jsonb("tags").$type<string[]>(),
|
||||||
|
source: text("source"),
|
||||||
plannerState: bytea("planner_state"),
|
plannerState: bytea("planner_state"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_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(),
|
refreshToken: text("refresh_token").notNull(),
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
providerUserId: text("provider_user_id"),
|
providerUserId: text("provider_user_id"),
|
||||||
|
encryptedCredentials: text("encrypted_credentials"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
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", {
|
export const syncImports = journalSchema.table("sync_imports", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
|
|
|
||||||
|
|
@ -241,9 +241,34 @@ export default {
|
||||||
previous: "Zurück",
|
previous: "Zurück",
|
||||||
next: "Weiter",
|
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: {
|
nav: {
|
||||||
routes: "Routen",
|
routes: "Routen",
|
||||||
activities: "Aktivitäten",
|
activities: "Aktivitäten",
|
||||||
|
integrations: "Integrationen",
|
||||||
login: "Anmelden",
|
login: "Anmelden",
|
||||||
register: "Registrieren",
|
register: "Registrieren",
|
||||||
profile: "Profil",
|
profile: "Profil",
|
||||||
|
|
@ -272,6 +297,8 @@ export default {
|
||||||
passkeyNotSupported: "Dein Browser unterstützt keine Passkeys. Bitte verwende einen anderen Browser zur Registrierung.",
|
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.",
|
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",
|
registerWithMagicLink: "Per Magic Link registrieren",
|
||||||
|
useMagicLinkInstead: "Stattdessen einen Magic Link verwenden",
|
||||||
|
usePasskeyInstead: "Stattdessen einen Passkey verwenden",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mobile: {
|
mobile: {
|
||||||
|
|
|
||||||
|
|
@ -241,9 +241,34 @@ export default {
|
||||||
previous: "Previous",
|
previous: "Previous",
|
||||||
next: "Next",
|
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: {
|
nav: {
|
||||||
routes: "Routes",
|
routes: "Routes",
|
||||||
activities: "Activities",
|
activities: "Activities",
|
||||||
|
integrations: "Integrations",
|
||||||
login: "Sign In",
|
login: "Sign In",
|
||||||
register: "Register",
|
register: "Register",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
|
|
@ -272,6 +297,8 @@ export default {
|
||||||
passkeyNotSupported: "Your browser does not support passkeys. Please use a different browser to register.",
|
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.",
|
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",
|
registerWithMagicLink: "Register with Magic Link",
|
||||||
|
useMagicLinkInstead: "Use a magic link instead",
|
||||||
|
usePasskeyInstead: "Use a passkey instead",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mobile: {
|
mobile: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { JobDefinition } from "./types.ts";
|
||||||
|
|
||||||
export async function startWorker(
|
export async function startWorker(
|
||||||
boss: PgBoss,
|
boss: PgBoss,
|
||||||
jobs: JobDefinition[],
|
jobs: JobDefinition<any>[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await boss.start();
|
await boss.start();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue