import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, updateConnection, } from "@sttracker/core/connections"; import { ValidationError } from "@sttracker/core/errors"; import { enqueue } from "@sttracker/core/jobs"; import type { PluginRegistry } from "@sttracker/core/registry"; import { z } from "zod"; import { HttpError, json, noContent, route } from "../http.ts"; import { formFile } from "./form.ts"; import { qInt } from "./schemas.ts"; const ALREADY_RUNNING = "A sync for this source is already queued or running."; function pluginOf(registry: PluginRegistry, pluginId: string) { const plugin = registry.get(pluginId); if (!plugin) throw new ValidationError("pluginId", "not_installed"); return plugin; } async function enqueueSync(db: Parameters[0], connectionId: string, payload: Record): Promise { const jobId = await enqueue(db, "sync", { connectionId, ...payload }, { lockKey: `sync:${connectionId}` }); if (jobId === null) throw new HttpError(409, "conflict", ALREADY_RUNNING); return json({ jobId }, 202); } const schedule = z.number().int().min(1).nullable(); const config = z.record(z.string(), z.unknown()); const creds = z.record(z.string(), z.string()); export const sourceRoutes = [ route({ method: "GET", path: "plugins", summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).", auth: "any:read", handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })), }), route({ method: "GET", path: "sources", summary: "Your source connections (never includes secrets).", auth: "any:read", handler: async ({ deps, user }) => listConnections(deps.db, user.id), }), route({ method: "POST", path: "sources", summary: "Connect a source. Config fields typed secret are stored encrypted.", auth: "session", body: z .object({ pluginId: z.string().min(1), name: z.string().min(1).max(200), config: config.optional(), credentials: creds.optional(), scheduleMinutes: schedule.optional() }) .strict(), handler: async ({ deps, user, body }) => { const plugin = pluginOf(deps.registry, body.pluginId); const id = await createConnection(deps.db, deps.secret, user.id, { ...body, config: body.config ?? {} }, plugin.manifest); return json(await getConnection(deps.db, user.id, id), 201); }, }), route({ method: "PATCH", path: "sources/:id", summary: "Edit a source connection. Omitted secrets are kept.", auth: "session", body: z .object({ name: z.string().min(1).max(200), config, credentials: creds, scheduleMinutes: schedule, status: z.enum(["active", "paused"]) }) .partial() .strict(), handler: async ({ deps, user, params, body }) => { const conn = await getConnection(deps.db, user.id, params.id!); await updateConnection(deps.db, deps.secret, user.id, conn.id, body, pluginOf(deps.registry, conn.pluginId).manifest); return getConnection(deps.db, user.id, conn.id); }, }), route({ method: "DELETE", path: "sources/:id", summary: "Disconnect a source: pauses it and wipes its credentials. Imported data stays.", auth: "session", handler: async ({ deps, user, params }) => { await getConnection(deps.db, user.id, params.id!); await deleteConnection(deps.db, user.id, params.id!); return noContent(); }, }), route({ method: "POST", path: "sources/:id/run", summary: "Queue a sync now. 409 if one is already queued or running.", auth: "any:write", body: z.object({ mode: z.enum(["full", "changes"]).optional() }).strict().optional(), handler: async ({ deps, user, params, body }) => { const conn = await getConnection(deps.db, user.id, params.id!); return enqueueSync(deps.db, conn.id, body?.mode ? { mode: body.mode } : {}); }, }), route({ method: "POST", path: "sources/:id/import", summary: "Upload an export file (multipart: file) and queue a file-mode sync.", auth: "any:write", multipart: true, handler: async ({ deps, user, params, form }) => { const conn = await getConnection(deps.db, user.id, params.id!); if (!pluginOf(deps.registry, conn.pluginId).importFile) throw new ValidationError("pluginId", "cannot_import_files"); const file = formFile(form); const base64 = Buffer.from(await file.arrayBuffer()).toString("base64"); return enqueueSync(deps.db, conn.id, { file: { name: file.name, base64 } }); }, }), route({ method: "POST", path: "sources/:id/device/start", summary: "Start a device-code sign-in with the source.", auth: "session", handler: async ({ deps, user, params }) => deviceStart(deps.db, deps.secret, deps.registry, user.id, params.id!), }), route({ method: "POST", path: "sources/:id/device/poll", summary: "Poll a device-code sign-in: pending or connected.", auth: "session", body: z.object({ deviceCode: z.string().min(1).max(2000) }).strict(), handler: async ({ deps, user, params, body }) => ({ status: await devicePoll(deps.db, deps.secret, deps.registry, user.id, params.id!, body.deviceCode), }), }), route({ method: "GET", path: "sources/:id/runs", summary: "Recent sync runs of a source, newest first.", auth: "any:read", query: z.object({ limit: qInt.optional() }), handler: async ({ deps, user, params, query }) => listRuns(deps.db, user.id, params.id!, query.limit), }), ];