diff --git a/app/api/plesk/instances/[id]/backfill-domain-links/route.ts b/app/api/plesk/instances/[id]/backfill-domain-links/route.ts new file mode 100644 index 0000000..5e89ac4 --- /dev/null +++ b/app/api/plesk/instances/[id]/backfill-domain-links/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; + +import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency"; +import { assertRateLimit } from "@/lib/api/rate-limit"; +import { assertSameOrigin } from "@/lib/api/security"; +import { backfillDomainSubscriptionLinks } from "@/lib/plesk/sync"; +import { createSupabaseRouteClient } from "@/lib/supabase/route"; + +export async function POST( + req: Request, + { params }: { params: { id: string } }, +) { + try { + assertSameOrigin(req); + + const context = await getRouteAgencyContextOrThrow(); + assertAgencyAdmin(context); + assertRateLimit(`plesk-backfill-links:${context.userId}`, 5, 60_000); + + const supabase = createSupabaseRouteClient() as any; + + const { data: instance, error: instanceError } = await supabase + .from("plesk_instances") + .select("id") + .eq("id", params.id) + .eq("agency_id", context.agencyId) + .single(); + + if (instanceError || !instance) { + throw new Error("Plesk instance not found"); + } + + const result = await backfillDomainSubscriptionLinks(supabase, { + agencyId: context.agencyId, + instanceId: params.id, + actorUserId: context.userId, + }); + + return NextResponse.json({ ok: true, ...result }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Backfill domain links failed", + }, + { status: 400 }, + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index c05500c..360c423 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -118,14 +118,6 @@ export default async function DashboardPage() { { data: ActionLogSummary[] | null }, ]; - const domainSubscriptionIds = Array.from( - new Set( - (domains ?? []) - .map((domain) => domain.subscription_id) - .filter((id): id is string => Boolean(id)), - ), - ); - const domainInstanceIds = Array.from( new Set((domains ?? []).map((domain) => domain.plesk_instance_id)), ); diff --git a/lib/plesk/sync.ts b/lib/plesk/sync.ts index 97c3d22..7ac1355 100644 --- a/lib/plesk/sync.ts +++ b/lib/plesk/sync.ts @@ -31,6 +31,10 @@ type LocalSubscriptionLookupRow = { const MIN_INTERVAL_MS = 3 * 60 * 1000; +function normalizeDomainLike(value: string) { + return value.trim().toLowerCase().replace(/\.$/, ""); +} + export async function syncPleskInstance( supabase: SupabaseLike, instance: InstanceRow, @@ -213,13 +217,16 @@ export async function syncPleskInstance( >( subscriptionsIndexed .filter((row) => row.name) - .map((row) => [String(row.name).trim().toLowerCase(), row]), + .map((row) => [normalizeDomainLike(String(row.name)), row]), ); const domainsForUpsert = domainsRaw .map((item: any) => { const domainId = item?.id ?? item?.guid ?? item?.name; const domainName = item?.name ?? item?.domain_name ?? item?.domain; + const normalizedDomainName = domainName + ? normalizeDomainLike(String(domainName)) + : null; const subExternalId = item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id; const rawExternalSubscriptionId = subExternalId @@ -240,13 +247,17 @@ export async function syncPleskInstance( : undefined; const localSubscriptionByName = subscriptionNameHint ? subscriptionByName.get( - String(subscriptionNameHint).trim().toLowerCase(), + normalizeDomainLike(String(subscriptionNameHint)), ) : undefined; + const localSubscriptionByDomainName = normalizedDomainName + ? subscriptionByName.get(normalizedDomainName) + : undefined; const localSubscription = localSubscriptionByExternal ?? localSubscriptionByLocalId ?? - localSubscriptionByName; + localSubscriptionByName ?? + localSubscriptionByDomainName; const externalSubscriptionId = localSubscription ? localSubscription.pleskSubscriptionId @@ -281,6 +292,10 @@ export async function syncPleskInstance( }) .filter(Boolean) as Array>; + const unlinkedDomains = domainsForUpsert.filter( + (domain) => !domain.subscription_id, + ); + if (domainsForUpsert.length > 0) { const { error } = await supabase .from("plesk_domains") @@ -315,6 +330,10 @@ export async function syncPleskInstance( metadata: { subscriptions_seen: subscriptionsRaw.length, domains_seen: domainsRaw.length, + unlinked_domains: unlinkedDomains.length, + unlinked_domain_samples: unlinkedDomains + .slice(0, 25) + .map((domain) => String(domain.domain_name ?? "unknown")), }, }); @@ -387,3 +406,124 @@ export async function syncPleskInstance( throw error; } } + +export async function backfillDomainSubscriptionLinks( + supabase: SupabaseLike, + input: { + agencyId: string; + instanceId: string; + actorUserId?: string | null; + }, +) { + const { agencyId, instanceId, actorUserId } = input; + + const { data: subscriptions, error: subscriptionsError } = await supabase + .from("plesk_subscriptions") + .select("id, plesk_subscription_id, name, status") + .eq("agency_id", agencyId) + .eq("plesk_instance_id", instanceId); + + if (subscriptionsError) { + throw new Error(subscriptionsError.message); + } + + const { data: domains, error: domainsError } = await supabase + .from("plesk_domains") + .select("id, domain_name, external_subscription_id, subscription_id") + .eq("agency_id", agencyId) + .eq("plesk_instance_id", instanceId) + .is("subscription_id", null); + + if (domainsError) { + throw new Error(domainsError.message); + } + + const subscriptionByExternalId = new Map< + string, + { id: string; status: string } + >( + (subscriptions ?? []) + .filter((subscription: any) => subscription?.plesk_subscription_id) + .map((subscription: any) => [ + String(subscription.plesk_subscription_id), + { + id: String(subscription.id), + status: subscription?.status + ? String(subscription.status) + : "unknown", + }, + ]), + ); + + const subscriptionByName = new Map( + (subscriptions ?? []) + .filter((subscription: any) => subscription?.name) + .map((subscription: any) => [ + normalizeDomainLike(String(subscription.name)), + { + id: String(subscription.id), + status: subscription?.status + ? String(subscription.status) + : "unknown", + }, + ]), + ); + + let linkedCount = 0; + const failures: Array<{ domain_id: string; error: string }> = []; + + for (const domain of domains ?? []) { + const matchByExternal = domain?.external_subscription_id + ? subscriptionByExternalId.get(String(domain.external_subscription_id)) + : undefined; + const matchByDomainName = domain?.domain_name + ? subscriptionByName.get(normalizeDomainLike(String(domain.domain_name))) + : undefined; + const linkedSubscription = matchByExternal ?? matchByDomainName; + + if (!linkedSubscription) continue; + + const { error: updateError } = await supabase + .from("plesk_domains") + .update({ + subscription_id: linkedSubscription.id, + status: linkedSubscription.status, + }) + .eq("id", domain.id) + .eq("agency_id", agencyId); + + if (updateError) { + failures.push({ + domain_id: String(domain.id), + error: updateError.message, + }); + continue; + } + + linkedCount += 1; + } + + await supabase.from("actions_log").insert({ + agency_id: agencyId, + actor_user_id: actorUserId ?? null, + target_type: "plesk_instance", + target_id: instanceId, + action: "domain_subscription_backfill", + status: failures.length > 0 ? "failed" : "success", + error_message: + failures.length > 0 + ? `${failures.length} domain links failed during backfill` + : null, + metadata: { + attempted: (domains ?? []).length, + linked: linkedCount, + failures: failures.slice(0, 25), + }, + }); + + return { + attempted: (domains ?? []).length, + linked: linkedCount, + failed: failures.length, + }; +}