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}
+ />
+
+