Add background bulk import for Komoot

Replace the per-tour import UI with a fire-and-forget background job:

- Add `import_batches` DB table tracking status, found/imported/duplicate counts
- Add `runKomootBulkImport` server function that pages all Komoot tours,
  fetches GPX, creates activities, and deduplicates via sync_imports
- Add `komoot-bulk-import` pg-boss job registered at server startup
- Add POST /api/sync/komoot/import to enqueue the job
- Add GET /api/sync/komoot/import-status to return the latest batch
- Replace the Komoot import page with a progress UI that polls every 2s
  while a batch is running and shows found/imported/skipped counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-23 18:19:10 +02:00
parent 386867ef6a
commit 5ff7af8a81
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
10 changed files with 442 additions and 211 deletions

View file

@ -276,6 +276,26 @@ export const syncPushes = journalSchema.table(
// row in this change. `acceptedAt` is always set today (auto-accept for
// public local profiles); the column stays nullable so federation's Pending
// state lands cleanly.
export type ImportBatchStatus = "pending" | "running" | "completed" | "failed";
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(() => connectedServices.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
status: text("status").$type<ImportBatchStatus>().notNull().default("pending"),
totalFound: integer("total_found").notNull().default(0),
importedCount: integer("imported_count").notNull().default(0),
duplicateCount: integer("duplicate_count").notNull().default(0),
errorMessage: text("error_message"),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
});
export const follows = journalSchema.table("follows", {
id: text("id").primaryKey(),
followerId: text("follower_id")

View file

@ -445,6 +445,23 @@ export default {
connectSuccess: "Komoot-Konto verbunden.",
authError: "Ungültige Komoot-Zugangsdaten. Bitte E-Mail und Passwort prüfen.",
modeBadge: "Modus: {{mode}}",
import: {
noImportYet: "Es wurde noch kein Import durchgeführt. Klicke unten, um alle deine Komoot-Touren zu importieren.",
startImport: "Alle Touren importieren",
starting: "Wird gestartet…",
runAgain: "Erneut ausführen",
found: "Gefunden",
imported: "Importiert",
skipped: "Bereits importiert",
completedIn: "Abgeschlossen in {{duration}}",
viewActivities: "Aktivitäten ansehen",
status: {
pending: "In Warteschlange",
running: "Wird importiert…",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen",
},
},
},
sync: {
import: "Importieren",

View file

@ -445,6 +445,23 @@ export default {
connectSuccess: "Komoot account connected.",
authError: "Invalid Komoot credentials. Please check your email and password.",
modeBadge: "Mode: {{mode}}",
import: {
noImportYet: "No import has been run yet. Click the button below to import all your Komoot tours.",
startImport: "Import all tours",
starting: "Starting…",
runAgain: "Run again",
found: "Found",
imported: "Imported",
skipped: "Already imported",
completedIn: "Completed in {{duration}}",
viewActivities: "View your activities",
status: {
pending: "Queued",
running: "Importing…",
completed: "Completed",
failed: "Failed",
},
},
},
sync: {
import: "Import",