From 5e5fb3221a3d61670c0423bc1326ae765a2c5f2c Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 5 Mar 2026 17:33:14 +0000 Subject: [PATCH] feat(worker): add autosync scheduler, endpoint, locking and UI controls --- .env.example | 1 + README.md | 37 +++ app/api/cron/autosync/route.ts | 38 +++ .../plesk/instances/[id]/autosync/route.ts | 87 +++++++ app/dashboard/page.tsx | 7 +- components/dashboard/dashboard-controls.tsx | 85 +++++++ lib/env.ts | 1 + lib/plesk/auto-sync.ts | 232 ++++++++++++++++++ lib/types.ts | 18 ++ supabase/migrations/CL6_auto_sync_worker.sql | 62 +++++ 10 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 app/api/cron/autosync/route.ts create mode 100644 app/api/plesk/instances/[id]/autosync/route.ts create mode 100644 lib/plesk/auto-sync.ts create mode 100644 supabase/migrations/CL6_auto_sync_worker.sql diff --git a/.env.example b/.env.example index eabe3c4..6e36439 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ SUPABASE_SERVICE_ROLE_KEY= NEXT_PUBLIC_APP_URL=http://localhost:3000 JOB_SECRET= +CRON_SECRET= ENCRYPTION_KEY= PLESK_CRED_ENC_KEY= diff --git a/README.md b/README.md index c1978d5..a6eb5e0 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ values ('', '', 'owner'); - `POST /api/plesk/instances` — create + validate Plesk connection - `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance +- `POST /api/plesk/instances/:id/autosync` — update auto-sync enabled/frequency for one Plesk instance - `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`) +- `POST /api/cron/autosync` — scheduled auto-sync tick (requires `X-CRON-SECRET`) - `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend - `POST /api/stripe/checkout` — create Stripe Checkout session - `POST /api/stripe/portal` — create Stripe Customer Portal session @@ -74,6 +76,7 @@ values ('', '', 'owner'); - Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets. - Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption. - Set `JOB_SECRET` for internal cron-triggered job auth. +- Set `CRON_SECRET` for `/api/cron/autosync` auth. - `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification. - Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production. @@ -97,3 +100,37 @@ Notes: - Job uses DB lock (`job_locks`) to avoid overlapping runs. - Job processes at most 10 connected instances per run. - Instances synced in the last 3 minutes are skipped. + +## 6) Auto Sync Worker (CL6) + +New scheduler fields live on `plesk_instances`: + +- `auto_sync_enabled` +- `auto_sync_frequency_minutes` +- `last_auto_sync_at` +- `next_auto_sync_at` +- `auto_sync_lock_until` +- `auto_sync_lock_token` + +Endpoint for periodic worker ticks: + +```bash +curl -sS -X POST http://localhost:3000/api/cron/autosync \ + -H "X-CRON-SECRET: " +``` + +Recommended system cron (every 5 minutes): + +```cron +*/5 * * * * curl -sS -X POST https:///api/cron/autosync -H "X-CRON-SECRET: ${CRON_SECRET}" +``` + +Or schedule the same call from Jenkins. + +Worker safety behavior: + +- processes at most 5 instances per tick +- concurrency limited to 2 at a time +- per-instance lock lease 15 minutes +- per-instance timeout guard 14 minutes +- per-instance failures are isolated and logged to `actions_log` diff --git a/app/api/cron/autosync/route.ts b/app/api/cron/autosync/route.ts new file mode 100644 index 0000000..f8d17a0 --- /dev/null +++ b/app/api/cron/autosync/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +import { env } from "@/lib/env"; +import { runAutoSyncTick } from "@/lib/plesk/auto-sync"; +import { createSupabaseAdminClient } from "@/lib/supabase/admin"; + +export async function POST(req: Request) { + const startedAt = Date.now(); + + try { + const secret = req.headers.get("x-cron-secret"); + if (!env.cronSecret || !secret || secret !== env.cronSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const supabase = createSupabaseAdminClient() as any; + const summary = await runAutoSyncTick(supabase); + + return NextResponse.json({ + ...summary, + duration_ms: Date.now() - startedAt, + }); + } catch (error) { + console.error("[autosync cron] failed", error); + + return NextResponse.json( + { + error: "Auto-sync job failed", + instances_considered: 0, + locks_acquired: 0, + succeeded: 0, + failed: 0, + duration_ms: Date.now() - startedAt, + }, + { status: 500 }, + ); + } +} diff --git a/app/api/plesk/instances/[id]/autosync/route.ts b/app/api/plesk/instances/[id]/autosync/route.ts new file mode 100644 index 0000000..8321be9 --- /dev/null +++ b/app/api/plesk/instances/[id]/autosync/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency"; +import { assertRateLimit } from "@/lib/api/rate-limit"; +import { assertSameOrigin } from "@/lib/api/security"; +import { createSupabaseRouteClient } from "@/lib/supabase/route"; + +const autosyncSchema = z.object({ + auto_sync_enabled: z.boolean(), + auto_sync_frequency_minutes: z.number().int().min(5).max(10080).optional(), +}); + +export async function POST( + req: Request, + { params }: { params: { id: string } }, +) { + try { + assertSameOrigin(req); + + const context = await getRouteAgencyContextOrThrow(); + assertAgencyAdmin(context); + assertRateLimit(`plesk-autosync-settings:${context.userId}`, 20, 60_000); + + const input = autosyncSchema.parse(await req.json()); + const frequency = input.auto_sync_frequency_minutes ?? 60; + const nowIso = new Date().toISOString(); + const nextAtIso = new Date( + Date.now() + frequency * 60 * 1000, + ).toISOString(); + + const supabase = createSupabaseRouteClient() as any; + + const updates = { + auto_sync_enabled: input.auto_sync_enabled, + auto_sync_frequency_minutes: frequency, + next_auto_sync_at: input.auto_sync_enabled ? nextAtIso : null, + auto_sync_lock_until: null, + auto_sync_lock_token: null, + last_auto_sync_at: input.auto_sync_enabled ? undefined : null, + } as Record; + + const { data: instance, error } = await supabase + .from("plesk_instances") + .update(updates) + .eq("id", params.id) + .eq("agency_id", context.agencyId) + .select( + "id, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until", + ) + .single(); + + if (error || !instance) { + throw new Error(error?.message ?? "Plesk instance not found"); + } + + try { + await supabase.from("actions_log").insert({ + agency_id: context.agencyId, + actor_user_id: context.userId, + target_type: "plesk_instance", + target_id: params.id, + action: "autosync_settings_updated", + status: "success", + metadata: { + auto_sync_enabled: input.auto_sync_enabled, + auto_sync_frequency_minutes: frequency, + updated_at: nowIso, + }, + }); + } catch { + // best effort logging + } + + return NextResponse.json({ instance }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to update auto-sync settings", + }, + { status: 400 }, + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index c785cbf..f82bb2a 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -57,7 +57,7 @@ export default async function DashboardPage() { supabase .from("plesk_instances") .select( - "id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains", + "id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until", ) .eq("agency_id", context.agencyId) .order("created_at", { ascending: false }), @@ -104,6 +104,11 @@ export default async function DashboardPage() { last_sync_error: string | null; last_sync_subscriptions: number; last_sync_domains: number; + auto_sync_enabled: boolean; + auto_sync_frequency_minutes: number; + last_auto_sync_at: string | null; + next_auto_sync_at: string | null; + auto_sync_lock_until: string | null; }> | null; error: { message: string } | null; }, diff --git a/components/dashboard/dashboard-controls.tsx b/components/dashboard/dashboard-controls.tsx index 5a639d2..6e493ec 100644 --- a/components/dashboard/dashboard-controls.tsx +++ b/components/dashboard/dashboard-controls.tsx @@ -18,6 +18,11 @@ type Instance = { last_sync_error: string | null; last_sync_subscriptions: number; last_sync_domains: number; + auto_sync_enabled: boolean; + auto_sync_frequency_minutes: number; + last_auto_sync_at: string | null; + next_auto_sync_at: string | null; + auto_sync_lock_until: string | null; }; type Subscription = { @@ -116,6 +121,7 @@ export function DashboardControls({ domains, autoSyncByInstance, }: Props) { + const autoSyncFrequencyOptions = [15, 30, 60, 180, 360, 1440]; const [instanceName, setInstanceName] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [authType, setAuthType] = useState<"api_key" | "basic">("api_key"); @@ -239,6 +245,25 @@ export function DashboardControls({ } } + async function onUpdateAutoSync( + instanceId: string, + input: { auto_sync_enabled: boolean; auto_sync_frequency_minutes: number }, + ) { + setLoading(true); + setMessage(null); + try { + await runRequest(`/api/plesk/instances/${instanceId}/autosync`, input); + setMessage("Auto-sync settings updated."); + window.location.reload(); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Auto-sync update failed", + ); + } finally { + setLoading(false); + } + } + async function onSubscriptionAction() { if (!actionSubId) return; setLoading(true); @@ -470,6 +495,30 @@ export function DashboardControls({ ? formatDateTime(instance.last_connected_at) : "never"}

+

+ Auto-sync:{" "} + {instance.auto_sync_enabled ? "Enabled" : "Disabled"} +

+

+ Auto-sync frequency:{" "} + {instance.auto_sync_frequency_minutes ?? 60} min +

+

+ Last auto-sync:{" "} + {formatDateTime(instance.last_auto_sync_at)} +

+

+ Next auto-sync:{" "} + {formatDateTime(instance.next_auto_sync_at)} +

+

+ Lock status:{" "} + {instance.auto_sync_lock_until && + new Date(instance.auto_sync_lock_until).getTime() > + Date.now() + ? "Sync in progress" + : "Idle"} +

{autoSyncByInstance[instance.id] ? (

Last auto-sync:{" "} @@ -496,6 +545,42 @@ export function DashboardControls({ ) : null}

+
+ + ) => + onUpdateAutoSync(instance.id, { + auto_sync_enabled: e.target.checked, + auto_sync_frequency_minutes: + instance.auto_sync_frequency_minutes ?? 60, + }) + } + disabled={loading} + /> +
+