Compare commits
30 Commits
feature/do
...
fix/action
| Author | SHA1 | Date | |
|---|---|---|---|
| 825bf8dc84 | |||
| 2c52d89dc9 | |||
| cc7e30092d | |||
| ce8b04285a | |||
| ed31598a1d | |||
| a49448c04b | |||
| dceccecf0c | |||
| 8e5d9865ac | |||
| ee9b90e3d1 | |||
| ea671336d3 | |||
| 0d914f5973 | |||
| fbeb089b9e | |||
| 269c9e47c9 | |||
| c9e3fe1dbc | |||
| 78d3f104de | |||
| e4465d9549 | |||
| 698090853c | |||
| e9c65f8096 | |||
| 84a1396265 | |||
| fec7c682f0 | |||
| 5adc9f0923 | |||
| d87808af23 | |||
| 57005859c3 | |||
| b221ad45fa | |||
| 9ec1259403 | |||
| e90b34e6ca | |||
| f2ad5c9d7d | |||
| 3bc2cf09c3 | |||
| e619c12c35 | |||
| a64de5aba3 |
@@ -12,6 +12,7 @@ import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(["suspend", "unsuspend"]),
|
||||
subscription_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
@@ -32,7 +33,8 @@ export async function POST(
|
||||
assertAgencyAdmin(context);
|
||||
assertRateLimit(`plesk-domain-action:${context.userId}`, 10, 60_000);
|
||||
|
||||
const { action } = actionSchema.parse(await req.json());
|
||||
const payload = actionSchema.parse(await req.json());
|
||||
const { action } = payload;
|
||||
requestedAction = action;
|
||||
|
||||
const { data: domainRow, error: domainError } = await supabase
|
||||
@@ -46,21 +48,23 @@ export async function POST(
|
||||
throw new Error("Domain not found");
|
||||
}
|
||||
|
||||
const resolvedSubscriptionId =
|
||||
payload.subscription_id ??
|
||||
(domainRow.subscription_id ? String(domainRow.subscription_id) : null);
|
||||
|
||||
domain = {
|
||||
id: String(domainRow.id),
|
||||
subscription_id: domainRow.subscription_id
|
||||
? String(domainRow.subscription_id)
|
||||
: null,
|
||||
subscription_id: resolvedSubscriptionId,
|
||||
};
|
||||
|
||||
if (!domain.subscription_id) {
|
||||
if (!resolvedSubscriptionId) {
|
||||
throw new Error("Domain is not linked to a subscription");
|
||||
}
|
||||
|
||||
const { data: subscriptionRow, error: subscriptionError } = await supabase
|
||||
.from("plesk_subscriptions")
|
||||
.select("id, plesk_subscription_id, plesk_instance_id")
|
||||
.eq("id", domain.subscription_id)
|
||||
.eq("id", resolvedSubscriptionId)
|
||||
.eq("agency_id", context.agencyId)
|
||||
.single();
|
||||
|
||||
|
||||
51
app/api/plesk/instances/[id]/backfill-domain-links/route.ts
Normal file
51
app/api/plesk/instances/[id]/backfill-domain-links/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||
import { assertSameOrigin } from "@/lib/api/security";
|
||||
import {
|
||||
parseSubscriptionStatusDetailsFromInfo,
|
||||
pleskCliCall,
|
||||
suspendPleskSubscription,
|
||||
unsuspendPleskSubscription,
|
||||
} from "@/lib/plesk/client";
|
||||
@@ -80,12 +82,59 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const newStatus = payload.action === "suspend" ? "suspended" : "active";
|
||||
const fallbackStatus =
|
||||
payload.action === "suspend" ? "suspended" : "active";
|
||||
let refreshedStatus = fallbackStatus;
|
||||
let refreshedStatusRaw: string | null = null;
|
||||
let refreshErrorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
const infoResult = await pleskCliCall(connection, "subscription", [
|
||||
"--info",
|
||||
subscription.plesk_subscription_id,
|
||||
]);
|
||||
const parsed = parseSubscriptionStatusDetailsFromInfo(
|
||||
infoResult.stdout ?? "",
|
||||
);
|
||||
refreshedStatus = parsed.status;
|
||||
refreshedStatusRaw = parsed.statusRaw;
|
||||
} catch (refreshError) {
|
||||
refreshErrorMessage =
|
||||
refreshError instanceof Error
|
||||
? refreshError.message.slice(0, 1000)
|
||||
: "subscription_status_refresh_failed";
|
||||
|
||||
await supabase.from("actions_log").insert({
|
||||
agency_id: context.agencyId,
|
||||
actor_user_id: context.userId,
|
||||
target_type: "plesk_subscription",
|
||||
target_id: subscription.id,
|
||||
action: "subscription_status_refresh_failed",
|
||||
status: "failed",
|
||||
error_message: refreshErrorMessage,
|
||||
metadata: {
|
||||
subscription_id: subscription.id,
|
||||
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||
plesk_instance_id: subscription.plesk_instance_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("plesk_subscriptions")
|
||||
.update({ status: newStatus })
|
||||
.update({
|
||||
status: refreshedStatus,
|
||||
status_raw: refreshedStatusRaw,
|
||||
last_status_sync_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", subscription.id);
|
||||
|
||||
await supabase
|
||||
.from("plesk_domains")
|
||||
.update({ status: refreshedStatus })
|
||||
.eq("agency_id", context.agencyId)
|
||||
.eq("subscription_id", subscription.id);
|
||||
|
||||
await supabase.from("actions_log").insert({
|
||||
agency_id: context.agencyId,
|
||||
actor_user_id: context.userId,
|
||||
@@ -95,14 +144,17 @@ export async function POST(
|
||||
status: "success",
|
||||
metadata: {
|
||||
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||
refreshed_status: refreshedStatus,
|
||||
refresh_error: refreshErrorMessage,
|
||||
db_status_update_error: updateError?.message ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: newStatus,
|
||||
status: refreshedStatus,
|
||||
dbStatusUpdated: !updateError,
|
||||
refreshError: refreshErrorMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
if (supabase && context && subscription) {
|
||||
|
||||
@@ -17,6 +17,26 @@ type ActionLogSummary = Pick<
|
||||
"target_id" | "status" | "action" | "created_at" | "error_message"
|
||||
>;
|
||||
|
||||
function normalizeDomainLike(value: string) {
|
||||
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function findBestSuffixStatusMatch(
|
||||
normalizedDomain: string,
|
||||
candidates: Array<{ normalizedName: string; status: string | null }>,
|
||||
) {
|
||||
let best: { normalizedName: string; status: string | null } | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!normalizedDomain.endsWith(`.${candidate.normalizedName}`)) continue;
|
||||
if (!best || candidate.normalizedName.length > best.normalizedName.length) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best?.status ?? null;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const context = await getServerAgencyContextOrRedirect();
|
||||
const supabase = createSupabaseServerClient() as any;
|
||||
@@ -50,7 +70,7 @@ export default async function DashboardPage() {
|
||||
supabase
|
||||
.from("plesk_domains")
|
||||
.select(
|
||||
"id, plesk_instance_id, subscription_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
|
||||
"id, plesk_instance_id, subscription_id, external_subscription_id, status, domain_name, hosting_type, source_created_at, aliases_count, updated_at",
|
||||
)
|
||||
.eq("agency_id", context.agencyId)
|
||||
.order("updated_at", { ascending: false })
|
||||
@@ -101,6 +121,7 @@ export default async function DashboardPage() {
|
||||
id: string;
|
||||
plesk_instance_id: string;
|
||||
subscription_id: string | null;
|
||||
external_subscription_id: string | null;
|
||||
domain_name: string;
|
||||
status: string | null;
|
||||
hosting_type: string | null;
|
||||
@@ -113,6 +134,69 @@ export default async function DashboardPage() {
|
||||
{ data: ActionLogSummary[] | null },
|
||||
];
|
||||
|
||||
const domainInstanceIds = Array.from(
|
||||
new Set((domains ?? []).map((domain) => domain.plesk_instance_id)),
|
||||
);
|
||||
|
||||
const { data: domainSubscriptions } =
|
||||
domainInstanceIds.length > 0
|
||||
? await supabase
|
||||
.from("plesk_subscriptions")
|
||||
.select("id, plesk_instance_id, plesk_subscription_id, name, status")
|
||||
.eq("agency_id", context.agencyId)
|
||||
.in("plesk_instance_id", domainInstanceIds)
|
||||
: { data: [] };
|
||||
|
||||
const subscriptionStatusById = new Map<string, string | null>(
|
||||
(domainSubscriptions ?? []).map((subscription: any) => [
|
||||
String(subscription.id),
|
||||
subscription?.status ? String(subscription.status) : null,
|
||||
]),
|
||||
);
|
||||
|
||||
const subscriptionStatusByExternalId = new Map<string, string | null>(
|
||||
(domainSubscriptions ?? [])
|
||||
.filter((subscription: any) =>
|
||||
Boolean(
|
||||
subscription?.plesk_instance_id &&
|
||||
subscription?.plesk_subscription_id,
|
||||
),
|
||||
)
|
||||
.map((subscription: any) => [
|
||||
`${String(subscription.plesk_instance_id)}:${String(subscription.plesk_subscription_id)}`,
|
||||
subscription?.status ? String(subscription.status) : null,
|
||||
]),
|
||||
);
|
||||
|
||||
const subscriptionStatusByInstanceAndName = new Map<string, string | null>(
|
||||
(domainSubscriptions ?? [])
|
||||
.filter((subscription: any) =>
|
||||
Boolean(subscription?.plesk_instance_id && subscription?.name),
|
||||
)
|
||||
.map((subscription: any) => [
|
||||
`${String(subscription.plesk_instance_id)}:${normalizeDomainLike(String(subscription.name))}`,
|
||||
subscription?.status ? String(subscription.status) : null,
|
||||
]),
|
||||
);
|
||||
|
||||
const subscriptionStatusNameCandidatesByInstance = new Map<
|
||||
string,
|
||||
Array<{ normalizedName: string; status: string | null }>
|
||||
>();
|
||||
|
||||
for (const subscription of domainSubscriptions ?? []) {
|
||||
if (!subscription?.plesk_instance_id || !subscription?.name) continue;
|
||||
|
||||
const instanceId = String(subscription.plesk_instance_id);
|
||||
const existing =
|
||||
subscriptionStatusNameCandidatesByInstance.get(instanceId) ?? [];
|
||||
existing.push({
|
||||
normalizedName: normalizeDomainLike(String(subscription.name)),
|
||||
status: subscription?.status ? String(subscription.status) : null,
|
||||
});
|
||||
subscriptionStatusNameCandidatesByInstance.set(instanceId, existing);
|
||||
}
|
||||
|
||||
const autoSyncByInstance = new Map<
|
||||
string,
|
||||
{ status: string; created_at: string; error_message: string | null }
|
||||
@@ -167,7 +251,35 @@ export default async function DashboardPage() {
|
||||
<DashboardControls
|
||||
instances={instances ?? []}
|
||||
subscriptions={subscriptions ?? []}
|
||||
domains={domains ?? []}
|
||||
domains={(domains ?? []).map((domain) => {
|
||||
const joinedStatusByLocalId = domain.subscription_id
|
||||
? subscriptionStatusById.get(domain.subscription_id)
|
||||
: null;
|
||||
const joinedStatusByExternalId = domain.external_subscription_id
|
||||
? subscriptionStatusByExternalId.get(
|
||||
`${domain.plesk_instance_id}:${domain.external_subscription_id}`,
|
||||
)
|
||||
: null;
|
||||
const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
|
||||
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
|
||||
);
|
||||
const joinedStatusBySuffix = findBestSuffixStatusMatch(
|
||||
normalizeDomainLike(domain.domain_name),
|
||||
subscriptionStatusNameCandidatesByInstance.get(
|
||||
domain.plesk_instance_id,
|
||||
) ?? [],
|
||||
);
|
||||
const joinedStatus =
|
||||
joinedStatusByLocalId ??
|
||||
joinedStatusByExternalId ??
|
||||
joinedStatusByName ??
|
||||
joinedStatusBySuffix;
|
||||
|
||||
return {
|
||||
...domain,
|
||||
status: joinedStatus ?? "unknown",
|
||||
};
|
||||
})}
|
||||
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -57,6 +57,10 @@ function getDomainStatusBadge(status: string | null) {
|
||||
return "bg-rose-100 text-rose-700";
|
||||
}
|
||||
|
||||
if (normalized.includes("expired")) {
|
||||
return "bg-amber-100 text-amber-700";
|
||||
}
|
||||
|
||||
if (normalized.includes("active") || normalized.includes("enabled")) {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
@@ -82,6 +86,12 @@ function isDomainActive(status: string | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function getDomainHref(domainName: string) {
|
||||
const normalized = domainName.trim();
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized;
|
||||
return `https://${normalized}`;
|
||||
}
|
||||
|
||||
export function DashboardControls({
|
||||
instances,
|
||||
subscriptions,
|
||||
@@ -229,13 +239,23 @@ export function DashboardControls({
|
||||
}
|
||||
|
||||
async function onDomainAction(
|
||||
domainId: string,
|
||||
domain: Domain,
|
||||
action: "suspend" | "unsuspend",
|
||||
) {
|
||||
if (!domain.subscription_id) {
|
||||
setMessage(
|
||||
"Domain is not linked to a subscription yet. Run Sync Now (or backfill links) and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await runRequest(`/api/plesk/domains/${domainId}/action`, { action });
|
||||
await runRequest(`/api/plesk/domains/${domain.id}/action`, {
|
||||
action,
|
||||
subscription_id: domain.subscription_id,
|
||||
});
|
||||
setMessage("Domain action completed.");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -570,7 +590,7 @@ export function DashboardControls({
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
||||
<th className="px-2 py-2">Domain</th>
|
||||
<th className="px-2 py-2">Status</th>
|
||||
<th className="px-2 py-2">Subscription Status</th>
|
||||
<th className="px-2 py-2">Hosting</th>
|
||||
<th className="px-2 py-2">Created</th>
|
||||
<th className="px-2 py-2">Aliases</th>
|
||||
@@ -589,7 +609,14 @@ export function DashboardControls({
|
||||
filteredDomains.map((domain) => (
|
||||
<tr key={domain.id} className="border-b border-slate-100">
|
||||
<td className="px-2 py-2 font-medium text-slate-900">
|
||||
<p>{domain.domain_name}</p>
|
||||
<a
|
||||
href={getDomainHref(domain.domain_name)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-slate-300 underline-offset-2 hover:text-brand-700"
|
||||
>
|
||||
{domain.domain_name}
|
||||
</a>
|
||||
{domain.subscription_id ? (
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Subscription: {domain.subscription_id}
|
||||
@@ -616,7 +643,7 @@ export function DashboardControls({
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onDomainAction(domain.id, "suspend")}
|
||||
onClick={() => onDomainAction(domain, "suspend")}
|
||||
disabled={
|
||||
loading || isDomainSuspended(domain.status)
|
||||
}
|
||||
@@ -625,9 +652,7 @@ export function DashboardControls({
|
||||
Suspend
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onDomainAction(domain.id, "unsuspend")
|
||||
}
|
||||
onClick={() => onDomainAction(domain, "unsuspend")}
|
||||
disabled={loading || isDomainActive(domain.status)}
|
||||
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
|
||||
230
docs/ai-project-context.md
Normal file
230
docs/ai-project-context.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Project: Plesk Agency Portal
|
||||
|
||||
This project is a multi-tenant SaaS platform that allows agencies to manage multiple Plesk servers from a single dashboard.
|
||||
|
||||
The application synchronizes subscriptions and domains from Plesk instances and allows administrators to manage hosting accounts.
|
||||
|
||||
---
|
||||
|
||||
# Technology Stack
|
||||
|
||||
Frontend
|
||||
|
||||
- Next.js (App Router)
|
||||
- React
|
||||
- TypeScript
|
||||
|
||||
Backend
|
||||
|
||||
- Next.js API routes
|
||||
- Node runtime
|
||||
|
||||
Database
|
||||
|
||||
- Supabase Postgres
|
||||
|
||||
Authentication
|
||||
|
||||
- Supabase Auth (magic link)
|
||||
|
||||
External Integrations
|
||||
|
||||
- Plesk REST API
|
||||
- Plesk CLI
|
||||
- Stripe (future billing integration)
|
||||
|
||||
Development Tools
|
||||
|
||||
- Git
|
||||
- Docker (for some services)
|
||||
- Jenkins (future CI/CD)
|
||||
|
||||
---
|
||||
|
||||
# Core Database Tables
|
||||
|
||||
agencies
|
||||
|
||||
agency_members
|
||||
|
||||
plesk_instances
|
||||
|
||||
plesk_subscriptions
|
||||
|
||||
plesk_domains
|
||||
|
||||
actions_log
|
||||
|
||||
billing_accounts
|
||||
|
||||
---
|
||||
|
||||
# Multi-Tenant Model
|
||||
|
||||
Each user belongs to an agency.
|
||||
|
||||
Agency relationships:
|
||||
|
||||
user
|
||||
→ agency_members
|
||||
→ agencies
|
||||
→ plesk_instances
|
||||
→ plesk_subscriptions
|
||||
→ plesk_domains
|
||||
|
||||
Row Level Security in Supabase ensures users can only access rows belonging to their agency.
|
||||
|
||||
Never bypass or remove RLS policies.
|
||||
|
||||
---
|
||||
|
||||
# Plesk Integration
|
||||
|
||||
The system integrates with Plesk using both:
|
||||
|
||||
Plesk REST API
|
||||
Plesk CLI
|
||||
|
||||
Common CLI commands used:
|
||||
|
||||
subscription --info
|
||||
subscription --suspend
|
||||
subscription --unsuspend
|
||||
|
||||
CLI output must always be parsed defensively because formatting can vary between Plesk versions.
|
||||
|
||||
---
|
||||
|
||||
# Sync System
|
||||
|
||||
The platform supports manual synchronization of Plesk instances.
|
||||
|
||||
During sync the system:
|
||||
|
||||
1. Connects to a Plesk instance
|
||||
2. Retrieves subscriptions and domains
|
||||
3. Updates records in Supabase
|
||||
4. Updates the dashboard
|
||||
|
||||
Future development will include background sync workers.
|
||||
|
||||
---
|
||||
|
||||
# Dashboard Features
|
||||
|
||||
Current features include:
|
||||
|
||||
Connecting Plesk instances
|
||||
Testing connections
|
||||
Manual sync
|
||||
Listing domains and subscriptions
|
||||
Suspending subscriptions
|
||||
Unsuspending subscriptions
|
||||
|
||||
---
|
||||
|
||||
# Logging
|
||||
|
||||
Operational actions are written to:
|
||||
|
||||
actions_log
|
||||
|
||||
Examples include:
|
||||
|
||||
instance_connected
|
||||
sync_started
|
||||
sync_completed
|
||||
subscription_suspended
|
||||
subscription_unsuspended
|
||||
|
||||
Logging should never block primary workflows.
|
||||
|
||||
---
|
||||
|
||||
# Development Rules
|
||||
|
||||
When implementing features:
|
||||
|
||||
Search the repository before modifying code.
|
||||
|
||||
Extend existing architecture rather than rewriting systems.
|
||||
|
||||
Avoid introducing breaking database schema changes.
|
||||
|
||||
Prefer small modular functions.
|
||||
|
||||
Keep API routes thin.
|
||||
|
||||
Place integration logic inside lib folders.
|
||||
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
Default branch:
|
||||
|
||||
master
|
||||
|
||||
All work must be done on feature branches.
|
||||
|
||||
Example:
|
||||
|
||||
git checkout master
|
||||
git pull
|
||||
git checkout -b feature/<feature-name>
|
||||
|
||||
Never commit directly to master.
|
||||
|
||||
---
|
||||
|
||||
# Error Handling
|
||||
|
||||
External integrations must always fail gracefully.
|
||||
|
||||
If a single subscription fails during sync, the rest of the sync must continue.
|
||||
|
||||
All failures should be logged.
|
||||
|
||||
---
|
||||
|
||||
# UI Safety
|
||||
|
||||
Dashboard components must:
|
||||
|
||||
Handle null values safely
|
||||
Avoid crashing if API responses change
|
||||
Use status badges instead of raw text for statuses
|
||||
|
||||
---
|
||||
|
||||
# Future Roadmap
|
||||
|
||||
Upcoming development milestones include:
|
||||
|
||||
CL4 – Subscription status ingestion via CLI
|
||||
|
||||
CL5 – Action logging and retry queue
|
||||
|
||||
CL6 – Automatic sync worker
|
||||
|
||||
CL7 – Stripe billing enforcement
|
||||
|
||||
CL8 – Instance health monitoring
|
||||
|
||||
CL9 – Observability and audit improvements
|
||||
|
||||
---
|
||||
|
||||
# AI Agent Instructions
|
||||
|
||||
Before modifying code:
|
||||
|
||||
Search the repository for related implementations.
|
||||
|
||||
Avoid rewriting working systems.
|
||||
|
||||
Implement minimal safe modifications.
|
||||
|
||||
Always verify that existing features continue working.
|
||||
|
||||
If unsure about a change, prefer logging rather than failing.
|
||||
179
docs/system-architecture.md
Normal file
179
docs/system-architecture.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# System Architecture
|
||||
|
||||
This document describes the architecture of the Plesk Agency Portal SaaS platform.
|
||||
|
||||
The platform allows agencies to connect multiple Plesk servers and manage hosting subscriptions from a single dashboard.
|
||||
|
||||
---
|
||||
|
||||
# High Level Components
|
||||
|
||||
The system consists of the following components:
|
||||
|
||||
Frontend Dashboard
|
||||
Backend API
|
||||
Supabase Database
|
||||
Plesk Servers
|
||||
Future Worker Services
|
||||
Future Billing Integration
|
||||
|
||||
At a high level, users interact with the Frontend Dashboard, which sends authenticated requests to the Backend API (Next.js API routes). The backend reads and writes persistent state in Supabase, and executes remote operations against connected Plesk servers via API/CLI integrations. Future worker services will run asynchronous sync and retry workflows, while future billing integration will enforce plan limits and subscription state.
|
||||
|
||||
---
|
||||
|
||||
# Frontend
|
||||
|
||||
The frontend is a Next.js application using the App Router.
|
||||
|
||||
Responsibilities include:
|
||||
|
||||
User authentication
|
||||
Dashboard interface
|
||||
Displaying domains and subscriptions
|
||||
Triggering sync operations
|
||||
Sending management actions (suspend / unsuspend)
|
||||
|
||||
The frontend communicates with backend API routes.
|
||||
|
||||
---
|
||||
|
||||
# Backend
|
||||
|
||||
The backend uses Next.js API routes.
|
||||
|
||||
Responsibilities include:
|
||||
|
||||
Handling authenticated requests
|
||||
Connecting to Supabase
|
||||
Executing Plesk API and CLI commands
|
||||
Processing sync operations
|
||||
Writing operational logs
|
||||
|
||||
Business logic should be implemented in reusable modules inside the lib directory.
|
||||
|
||||
---
|
||||
|
||||
# Database
|
||||
|
||||
Supabase Postgres is used for persistent storage.
|
||||
|
||||
Key tables include:
|
||||
|
||||
agencies
|
||||
agency_members
|
||||
plesk_instances
|
||||
plesk_subscriptions
|
||||
plesk_domains
|
||||
actions_log
|
||||
billing_accounts
|
||||
|
||||
Relationship overview:
|
||||
|
||||
- `agencies` is the tenant root.
|
||||
- `agency_members` associates users to agencies and roles.
|
||||
- `plesk_instances` stores connected remote Plesk endpoints per agency.
|
||||
- `plesk_subscriptions` stores subscription-level records linked to instances.
|
||||
- `plesk_domains` stores domain-level records linked to subscriptions/instances.
|
||||
- `actions_log` stores operational/audit events.
|
||||
- `billing_accounts` stores plan/subscription billing state per agency.
|
||||
|
||||
The multi-tenant model is enforced using Supabase Row Level Security policies so users can only read/write rows that belong to their agency.
|
||||
|
||||
---
|
||||
|
||||
# Plesk Integration
|
||||
|
||||
The platform communicates with remote Plesk servers.
|
||||
|
||||
Integration methods:
|
||||
|
||||
Plesk REST API
|
||||
Plesk CLI commands
|
||||
|
||||
Typical operations include:
|
||||
|
||||
Listing subscriptions
|
||||
Retrieving domain data
|
||||
Suspending subscriptions
|
||||
Unsuspending subscriptions
|
||||
|
||||
CLI parsing must be defensive due to formatting differences between Plesk versions.
|
||||
|
||||
---
|
||||
|
||||
# Sync System
|
||||
|
||||
The system supports synchronization of Plesk servers.
|
||||
|
||||
Manual sync is triggered from the dashboard.
|
||||
|
||||
During sync:
|
||||
|
||||
The backend connects to a Plesk instance
|
||||
Subscription and domain data are retrieved
|
||||
Records are updated in Supabase
|
||||
The dashboard displays updated data
|
||||
|
||||
Future development will add automated background sync workers.
|
||||
|
||||
---
|
||||
|
||||
# Logging System
|
||||
|
||||
Operational actions are recorded in the actions_log table.
|
||||
|
||||
Examples:
|
||||
|
||||
instance_connected
|
||||
sync_started
|
||||
sync_completed
|
||||
subscription_suspended
|
||||
subscription_unsuspended
|
||||
|
||||
Logging supports debugging and auditability.
|
||||
|
||||
---
|
||||
|
||||
# Future Worker Architecture
|
||||
|
||||
Future versions of the system will include background workers for:
|
||||
|
||||
Automatic instance synchronization
|
||||
Retry queues
|
||||
Monitoring tasks
|
||||
|
||||
Workers may run as:
|
||||
|
||||
Cron jobs
|
||||
Queue processors
|
||||
Dedicated worker services
|
||||
|
||||
---
|
||||
|
||||
# Billing Architecture (Future)
|
||||
|
||||
Stripe will be integrated for SaaS billing.
|
||||
|
||||
Billing responsibilities will include:
|
||||
|
||||
Agency subscription plans
|
||||
Usage limits
|
||||
Payment handling
|
||||
|
||||
Billing enforcement will eventually control:
|
||||
|
||||
Number of connected Plesk instances
|
||||
Number of managed domains
|
||||
|
||||
---
|
||||
|
||||
# Development Philosophy
|
||||
|
||||
The system prioritizes:
|
||||
|
||||
Stability
|
||||
Security
|
||||
Multi-tenant isolation
|
||||
Safe integrations with external systems
|
||||
|
||||
Developers should extend existing systems rather than rewriting them.
|
||||
@@ -16,6 +16,8 @@ export type PleskSubscription = {
|
||||
external_id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
status_raw?: string | null;
|
||||
status_sync_error?: string | null;
|
||||
owner_login?: string;
|
||||
owner?: { login?: string };
|
||||
plan_name?: string;
|
||||
@@ -138,39 +140,37 @@ export async function testPleskConnection(connection: PleskConnection) {
|
||||
}
|
||||
|
||||
export async function listPleskSubscriptions(connection: PleskConnection) {
|
||||
const listOutput = await pleskCliCall(connection, "subscription", ["--list"]);
|
||||
const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
|
||||
|
||||
const subscriptionNames = listOutput
|
||||
const subscriptionNames = (listResult.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const subscriptions: PleskSubscription[] = [];
|
||||
|
||||
for (const subscriptionName of subscriptionNames) {
|
||||
let status = "unknown";
|
||||
for (const subscriptionName of subscriptionNames.slice(0, 500)) {
|
||||
let status: "active" | "suspended" | "disabled" | "expired" | "unknown" =
|
||||
"unknown";
|
||||
let statusRaw: string | null = null;
|
||||
let statusSyncError: string | null = null;
|
||||
|
||||
try {
|
||||
const infoOutput = await pleskCliCall(connection, "subscription", [
|
||||
const infoResult = await pleskCliCall(connection, "subscription", [
|
||||
"--info",
|
||||
subscriptionName,
|
||||
]);
|
||||
const normalizedInfo = infoOutput.toLowerCase();
|
||||
|
||||
const hasSuspendedMarker =
|
||||
normalizedInfo.includes("suspended") ||
|
||||
normalizedInfo.includes("webspace-off") ||
|
||||
normalizedInfo.includes("disabled");
|
||||
|
||||
const hasActiveMarker =
|
||||
normalizedInfo.includes("active") ||
|
||||
normalizedInfo.includes("enabled") ||
|
||||
normalizedInfo.includes("ok");
|
||||
|
||||
if (hasSuspendedMarker) status = "suspended";
|
||||
else if (hasActiveMarker || !hasSuspendedMarker) status = "active";
|
||||
} catch {
|
||||
const parsed = parseSubscriptionStatusDetailsFromInfo(
|
||||
infoResult.stdout ?? "",
|
||||
);
|
||||
status = parsed.status;
|
||||
statusRaw = parsed.statusRaw;
|
||||
} catch (error) {
|
||||
status = "unknown";
|
||||
statusSyncError =
|
||||
error instanceof Error
|
||||
? error.message.slice(0, 1000)
|
||||
: "status_sync_failed";
|
||||
}
|
||||
|
||||
subscriptions.push({
|
||||
@@ -178,12 +178,59 @@ export async function listPleskSubscriptions(connection: PleskConnection) {
|
||||
subscription_id: subscriptionName,
|
||||
name: subscriptionName,
|
||||
status,
|
||||
status_raw: statusRaw,
|
||||
status_sync_error: statusSyncError,
|
||||
});
|
||||
}
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
export function parseSubscriptionStatusDetailsFromInfo(stdout: string): {
|
||||
status: "active" | "suspended" | "disabled" | "expired" | "unknown";
|
||||
statusRaw: string | null;
|
||||
} {
|
||||
const line = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => /^domain\s+status\s*:/i.test(entry));
|
||||
|
||||
if (!line) {
|
||||
return { status: "unknown", statusRaw: null };
|
||||
}
|
||||
|
||||
const value = line.replace(/^domain\s+status\s*:/i, "").trim();
|
||||
const normalized = value.toLowerCase();
|
||||
|
||||
if (normalized.includes("expired")) {
|
||||
return { status: "expired", statusRaw: value };
|
||||
}
|
||||
|
||||
if (normalized.includes("disabled")) {
|
||||
return { status: "disabled", statusRaw: value };
|
||||
}
|
||||
|
||||
if (normalized.includes("suspend")) {
|
||||
return { status: "suspended", statusRaw: value };
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("ok") ||
|
||||
normalized.includes("active") ||
|
||||
normalized.includes("enabled")
|
||||
) {
|
||||
return { status: "active", statusRaw: value };
|
||||
}
|
||||
|
||||
return { status: "unknown", statusRaw: value };
|
||||
}
|
||||
|
||||
export function parseSubscriptionStatusFromInfo(
|
||||
stdout: string,
|
||||
): "active" | "suspended" | "disabled" | "expired" | "unknown" {
|
||||
return parseSubscriptionStatusDetailsFromInfo(stdout).status;
|
||||
}
|
||||
|
||||
export async function pleskCliCall(
|
||||
connection: PleskConnection,
|
||||
command: string,
|
||||
@@ -208,7 +255,7 @@ export async function pleskCliCall(
|
||||
);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
export async function listPleskDomains(connection: PleskConnection) {
|
||||
|
||||
@@ -22,8 +22,34 @@ type InstanceRow = Pick<
|
||||
| "last_connected_at"
|
||||
>;
|
||||
|
||||
type LocalSubscriptionLookupRow = {
|
||||
id: string;
|
||||
plesk_subscription_id: string;
|
||||
name: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 3 * 60 * 1000;
|
||||
|
||||
function normalizeDomainLike(value: string) {
|
||||
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function findBestSuffixSubscriptionMatch<
|
||||
T extends { normalizedName: string; id: string; status: string | null },
|
||||
>(normalizedDomain: string, candidates: T[]) {
|
||||
let best: T | undefined;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!normalizedDomain.endsWith(`.${candidate.normalizedName}`)) continue;
|
||||
if (!best || candidate.normalizedName.length > best.normalizedName.length) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function syncPleskInstance(
|
||||
supabase: SupabaseLike,
|
||||
instance: InstanceRow,
|
||||
@@ -32,6 +58,31 @@ export async function syncPleskInstance(
|
||||
): Promise<SyncResult> {
|
||||
const startedAtIso = new Date().toISOString();
|
||||
|
||||
const logStatusSyncEvent = async (
|
||||
eventAction:
|
||||
| "subscription_status_sync_started"
|
||||
| "subscription_status_sync_completed"
|
||||
| "subscription_status_sync_failed",
|
||||
metadata?: Record<string, unknown>,
|
||||
status: "success" | "failed" = "success",
|
||||
errorMessage?: string | null,
|
||||
) => {
|
||||
try {
|
||||
await supabase.from("actions_log").insert({
|
||||
agency_id: instance.agency_id,
|
||||
actor_user_id: actorUserId ?? null,
|
||||
target_type: "plesk_instance",
|
||||
target_id: instance.id,
|
||||
action: eventAction,
|
||||
status,
|
||||
error_message: errorMessage ?? null,
|
||||
metadata: metadata ?? {},
|
||||
});
|
||||
} catch {
|
||||
// Logging should never block sync flow.
|
||||
}
|
||||
};
|
||||
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
@@ -67,6 +118,10 @@ export async function syncPleskInstance(
|
||||
}
|
||||
|
||||
try {
|
||||
await logStatusSyncEvent("subscription_status_sync_started", {
|
||||
started_at: startedAtIso,
|
||||
});
|
||||
|
||||
const [subscriptionsRaw, domainsRaw] = await Promise.all([
|
||||
listPleskSubscriptions({
|
||||
baseUrl: instance.base_url,
|
||||
@@ -94,7 +149,9 @@ export async function syncPleskInstance(
|
||||
plesk_instance_id: instance.id,
|
||||
plesk_subscription_id: String(sourceId),
|
||||
name: item?.name ? String(item.name) : String(sourceId),
|
||||
status: item?.status ? String(item.status) : null,
|
||||
status: item?.status ? String(item.status) : "unknown",
|
||||
status_raw: item?.status_raw ? String(item.status_raw) : null,
|
||||
last_status_sync_at: startedAtIso,
|
||||
owner_login: item?.owner_login
|
||||
? String(item.owner_login)
|
||||
: item?.owner?.login
|
||||
@@ -109,6 +166,10 @@ export async function syncPleskInstance(
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
|
||||
const subscriptionStatusSyncFailures = subscriptionsRaw.filter(
|
||||
(item: any) => Boolean(item?.status_sync_error),
|
||||
);
|
||||
|
||||
if (subscriptionsForUpsert.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from("plesk_subscriptions")
|
||||
@@ -121,7 +182,7 @@ export async function syncPleskInstance(
|
||||
const { data: localSubscriptions, error: localSubscriptionsError } =
|
||||
await supabase
|
||||
.from("plesk_subscriptions")
|
||||
.select("id, plesk_subscription_id, status")
|
||||
.select("id, plesk_subscription_id, name, status")
|
||||
.eq("agency_id", instance.agency_id)
|
||||
.eq("plesk_instance_id", instance.id);
|
||||
|
||||
@@ -129,33 +190,116 @@ export async function syncPleskInstance(
|
||||
throw new Error(localSubscriptionsError.message);
|
||||
}
|
||||
|
||||
const subscriptionsIndexed = ((localSubscriptions ?? []) as Array<any>)
|
||||
.filter((row): row is LocalSubscriptionLookupRow =>
|
||||
Boolean(row?.plesk_subscription_id && row?.id),
|
||||
)
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
pleskSubscriptionId: String(row.plesk_subscription_id),
|
||||
status: row?.status ? String(row.status) : null,
|
||||
name: row?.name ? String(row.name) : null,
|
||||
}));
|
||||
|
||||
const subscriptionByExternalId = new Map<
|
||||
string,
|
||||
{ id: string; status: string | null }
|
||||
>(
|
||||
(localSubscriptions ?? [])
|
||||
.filter((row: any) => row?.plesk_subscription_id && row?.id)
|
||||
.map((row: any) => [
|
||||
String(row.plesk_subscription_id),
|
||||
{
|
||||
id: String(row.id),
|
||||
status: row?.status ? String(row.status) : null,
|
||||
},
|
||||
]),
|
||||
id: string;
|
||||
pleskSubscriptionId: string;
|
||||
status: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
>(subscriptionsIndexed.map((row) => [row.pleskSubscriptionId, row]));
|
||||
|
||||
const subscriptionByLocalId = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
pleskSubscriptionId: string;
|
||||
status: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
>(subscriptionsIndexed.map((row) => [row.id, row]));
|
||||
|
||||
const subscriptionByName = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
pleskSubscriptionId: string;
|
||||
status: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
>(
|
||||
subscriptionsIndexed
|
||||
.filter((row) => row.name)
|
||||
.map((row) => [normalizeDomainLike(String(row.name)), row]),
|
||||
);
|
||||
|
||||
const subscriptionNameCandidates: Array<{
|
||||
id: string;
|
||||
pleskSubscriptionId: string;
|
||||
status: string | null;
|
||||
normalizedName: string;
|
||||
}> = subscriptionsIndexed
|
||||
.filter((row) => row.name)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
pleskSubscriptionId: row.pleskSubscriptionId,
|
||||
status: row.status,
|
||||
normalizedName: normalizeDomainLike(String(row.name)),
|
||||
}));
|
||||
|
||||
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 externalSubscriptionId = subExternalId
|
||||
const rawExternalSubscriptionId = subExternalId
|
||||
? String(subExternalId)
|
||||
: null;
|
||||
const localSubscription = externalSubscriptionId
|
||||
? subscriptionByExternalId.get(externalSubscriptionId)
|
||||
const subscriptionNameHint =
|
||||
item?.subscription?.name ??
|
||||
item?.subscription_name ??
|
||||
item?.webspace_name ??
|
||||
item?.webspace ??
|
||||
null;
|
||||
|
||||
const localSubscriptionByExternal = rawExternalSubscriptionId
|
||||
? subscriptionByExternalId.get(rawExternalSubscriptionId)
|
||||
: undefined;
|
||||
const localSubscriptionByLocalId = rawExternalSubscriptionId
|
||||
? subscriptionByLocalId.get(rawExternalSubscriptionId)
|
||||
: undefined;
|
||||
const localSubscriptionByName = subscriptionNameHint
|
||||
? subscriptionByName.get(
|
||||
normalizeDomainLike(String(subscriptionNameHint)),
|
||||
)
|
||||
: undefined;
|
||||
const localSubscriptionByDomainName = normalizedDomainName
|
||||
? subscriptionByName.get(normalizedDomainName)
|
||||
: undefined;
|
||||
const localSubscriptionBySuffix = normalizedDomainName
|
||||
? findBestSuffixSubscriptionMatch(
|
||||
normalizedDomainName,
|
||||
subscriptionNameCandidates,
|
||||
)
|
||||
: undefined;
|
||||
const localSubscription =
|
||||
localSubscriptionByExternal ??
|
||||
localSubscriptionByLocalId ??
|
||||
localSubscriptionByName ??
|
||||
localSubscriptionByDomainName ??
|
||||
localSubscriptionBySuffix;
|
||||
|
||||
const externalSubscriptionId = localSubscription
|
||||
? localSubscription.pleskSubscriptionId
|
||||
: subscriptionNameHint
|
||||
? String(subscriptionNameHint)
|
||||
: rawExternalSubscriptionId;
|
||||
const rawCreated = item?.created_at ?? item?.created;
|
||||
const sourceCreatedAt = rawCreated
|
||||
? new Date(String(rawCreated)).toISOString()
|
||||
@@ -184,6 +328,10 @@ export async function syncPleskInstance(
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
|
||||
const unlinkedDomains = domainsForUpsert.filter(
|
||||
(domain) => !domain.subscription_id,
|
||||
);
|
||||
|
||||
if (domainsForUpsert.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from("plesk_domains")
|
||||
@@ -218,9 +366,47 @@ 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")),
|
||||
},
|
||||
});
|
||||
|
||||
await logStatusSyncEvent("subscription_status_sync_completed", {
|
||||
synced_subscriptions: subscriptionsRaw.length,
|
||||
failed_subscriptions: subscriptionStatusSyncFailures.length,
|
||||
failed_subscription_ids: subscriptionStatusSyncFailures
|
||||
.slice(0, 50)
|
||||
.map((item: any) =>
|
||||
String(
|
||||
item?.plesk_subscription_id ?? item?.subscription_id ?? "unknown",
|
||||
),
|
||||
),
|
||||
completed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (subscriptionStatusSyncFailures.length > 0) {
|
||||
await logStatusSyncEvent(
|
||||
"subscription_status_sync_failed",
|
||||
{
|
||||
failed_subscriptions: subscriptionStatusSyncFailures.length,
|
||||
sample_errors: subscriptionStatusSyncFailures
|
||||
.slice(0, 20)
|
||||
.map((item: any) => ({
|
||||
subscription: String(
|
||||
item?.plesk_subscription_id ??
|
||||
item?.subscription_id ??
|
||||
"unknown",
|
||||
),
|
||||
error: String(item?.status_sync_error ?? "status_sync_failed"),
|
||||
})),
|
||||
},
|
||||
"failed",
|
||||
`${subscriptionStatusSyncFailures.length} subscription status sync failures`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
agencyId: instance.agency_id,
|
||||
@@ -232,6 +418,15 @@ export async function syncPleskInstance(
|
||||
const message =
|
||||
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
|
||||
|
||||
await logStatusSyncEvent(
|
||||
"subscription_status_sync_failed",
|
||||
{
|
||||
failed_at: new Date().toISOString(),
|
||||
},
|
||||
"failed",
|
||||
message,
|
||||
);
|
||||
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
@@ -247,3 +442,146 @@ 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<string, { id: string; status: string }>(
|
||||
(subscriptions ?? [])
|
||||
.filter((subscription: any) => subscription?.name)
|
||||
.map((subscription: any) => [
|
||||
normalizeDomainLike(String(subscription.name)),
|
||||
{
|
||||
id: String(subscription.id),
|
||||
status: subscription?.status
|
||||
? String(subscription.status)
|
||||
: "unknown",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const subscriptionNameCandidates: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
}> = (subscriptions ?? [])
|
||||
.filter((subscription: any) => subscription?.name)
|
||||
.map((subscription: any) => ({
|
||||
id: String(subscription.id),
|
||||
status: subscription?.status ? String(subscription.status) : "unknown",
|
||||
normalizedName: normalizeDomainLike(String(subscription.name)),
|
||||
}));
|
||||
|
||||
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 normalizedDomainName = domain?.domain_name
|
||||
? normalizeDomainLike(String(domain.domain_name))
|
||||
: null;
|
||||
const matchBySuffix = normalizedDomainName
|
||||
? findBestSuffixSubscriptionMatch(
|
||||
normalizedDomainName,
|
||||
subscriptionNameCandidates,
|
||||
)
|
||||
: undefined;
|
||||
const linkedSubscription =
|
||||
matchByExternal ?? matchByDomainName ?? matchBySuffix;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
-- One-time backfill: align existing domain status with linked subscription status
|
||||
update public.plesk_domains d
|
||||
set status = s.status
|
||||
set status = coalesce(s.status, 'unknown')
|
||||
from public.plesk_subscriptions s
|
||||
where d.subscription_id = s.id
|
||||
and d.agency_id = s.agency_id
|
||||
and (d.status is null or d.status = 'unknown');
|
||||
and d.agency_id = s.agency_id;
|
||||
|
||||
update public.plesk_subscriptions
|
||||
set status = 'unknown'
|
||||
|
||||
8
supabase/migrations/CL4_subscription_status_fields.sql
Normal file
8
supabase/migrations/CL4_subscription_status_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
alter table public.plesk_subscriptions
|
||||
add column if not exists status text default 'unknown',
|
||||
add column if not exists status_raw text,
|
||||
add column if not exists last_status_sync_at timestamptz;
|
||||
|
||||
update public.plesk_subscriptions
|
||||
set status = 'unknown'
|
||||
where status is null;
|
||||
Reference in New Issue
Block a user