Compare commits

...

29 Commits

Author SHA1 Message Date
81b0617390 fix(actions): use plesk bin site for suspend/unsuspend domain state 2026-03-05 16:59:40 +00:00
757e8953be Merge pull request 'fix(plesk): use subscription --unsuspend for unsuspend action' (#19) from fix/plesk-unsuspend-command into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/19
2026-03-05 16:04:42 +00:00
c528392682 fix(plesk): use subscription --unsuspend for unsuspend action 2026-03-05 16:04:11 +00:00
a678713d1d Merge pull request 'fix(actions): unsuspend using canonical subscription identifier + refresh status' (#18) from fix/unsuspend-verify-and-refresh into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/18
2026-03-05 14:27:56 +00:00
033d816d42 fix(actions): unsuspend using canonical subscription identifier + refresh status 2026-03-05 14:26:58 +00:00
a22fc2c4d2 Merge pull request 'fix(actions): send and use subscription_id for domain suspend/unsuspend' (#17) from fix/actions-use-subscription-id into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/17
2026-03-05 14:20:34 +00:00
825bf8dc84 fix(actions): send and use subscription_id for domain suspend/unsuspend 2026-03-05 14:20:07 +00:00
2c52d89dc9 Merge pull request 'fix(linking): link subdomains to parent subscription via suffix match' (#16) from fix/subdomain-inherit-subscription into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/16
2026-03-05 14:14:52 +00:00
cc7e30092d fix(linking): link subdomains to parent subscription via suffix match 2026-03-05 14:14:20 +00:00
ce8b04285a Merge pull request 'fix(actions): refresh subscription status after suspend/unsuspend' (#15) from fix/action-refresh-status into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/15
2026-03-05 14:10:56 +00:00
ed31598a1d fix(actions): refresh subscription status after suspend/unsuspend 2026-03-05 14:10:27 +00:00
a49448c04b Merge pull request 'fix(sync): link domains to subscriptions and add backfill endpoint' (#14) from fix/link-domains-to-subscriptions into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/14
2026-03-05 14:09:56 +00:00
dceccecf0c fix(sync): link domains to subscriptions and add backfill endpoint 2026-03-05 14:03:03 +00:00
8e5d9865ac Merge pull request 'fix(domains): correctly match subscriptions to domains for status' (#13) from fix/cl4-domain-status-merge into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/13
2026-03-05 13:46:31 +00:00
ee9b90e3d1 fix(domains): correctly match subscriptions to domains for status 2026-03-05 13:46:03 +00:00
ea671336d3 Merge pull request 'fix(domains): prevent status enrichment from filtering domains' (#12) from hotfix/domains-list-server-join-empty into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/12
2026-03-05 13:36:34 +00:00
0d914f5973 fix(domains): prevent status enrichment from filtering domains 2026-03-05 13:36:08 +00:00
fbeb089b9e Merge pull request 'fix(ui): show subscription status on domains list' (#11) from feature/cl4-domain-status-from-subscription into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/11
2026-03-05 13:23:15 +00:00
269c9e47c9 Merge pull request 'fix(sync): robustly map domain rows to subscription status' (#10) from feature/cl4-fix-domain-status-ui into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/10
2026-03-05 13:23:07 +00:00
c9e3fe1dbc fix(ui): show subscription status on domains list 2026-03-05 13:22:33 +00:00
78d3f104de fix(sync): robustly map domain rows to subscription status 2026-03-05 13:14:26 +00:00
e4465d9549 docs: add system architecture documentation 2026-03-05 13:06:29 +00:00
698090853c docs: add ai project context for development agents 2026-03-05 13:06:29 +00:00
e9c65f8096 Merge pull request 'feat/cl4-subscription-status-cli' (#9) from feat/cl4-subscription-status-cli into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/9
2026-03-05 12:57:15 +00:00
84a1396265 feat(ui): show subscription status in dashboard 2026-03-05 12:42:56 +00:00
fec7c682f0 feat(sync): ingest subscription status during sync 2026-03-05 12:42:51 +00:00
5adc9f0923 feat(plesk): parse subscription status from CLI 2026-03-05 12:42:44 +00:00
d87808af23 feat(db): add subscription status fields 2026-03-05 12:42:34 +00:00
57005859c3 Merge pull request 'Dashboard: make domains clickable links opening in new tab' (#8) from feature/domain-links into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/8
2026-03-05 11:56:14 +00:00
10 changed files with 1174 additions and 62 deletions

View File

@@ -5,13 +5,14 @@ import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import {
suspendPleskSubscription,
unsuspendPleskSubscription,
parseSubscriptionStatusDetailsFromInfo,
pleskCliCall,
} from "@/lib/plesk/client";
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(
@@ -22,7 +23,11 @@ export async function POST(
let context: Awaited<ReturnType<typeof getRouteAgencyContextOrThrow>> | null =
null;
let domain: { id: string; subscription_id: string | null } | null = null;
let subscription: { id: string; plesk_subscription_id: string } | null = null;
let subscription: {
id: string;
plesk_subscription_id: string;
plesk_instance_id: string;
} | null = null;
let requestedAction: "suspend" | "unsuspend" | null = null;
try {
@@ -32,7 +37,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 +52,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();
@@ -71,6 +79,7 @@ export async function POST(
subscription = {
id: String(subscriptionRow.id),
plesk_subscription_id: String(subscriptionRow.plesk_subscription_id),
plesk_instance_id: String(subscriptionRow.plesk_instance_id),
};
const { data: instance, error: instanceError } = await supabase
@@ -90,17 +99,99 @@ export async function POST(
encryptedSecret: instance.encrypted_secret,
} as const;
if (action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
const domainIdentifier = subscription.plesk_subscription_id;
const siteArgs =
action === "suspend"
? ["--suspend", domainIdentifier]
: ["--on", domainIdentifier];
console.info("[domain_action] site CLI", {
action,
command: "site",
args: siteArgs,
domain_id: domain.id,
subscription_id: subscription.id,
});
const siteResult = await pleskCliCall(connection, "site", siteArgs);
console.info("[domain_action] site CLI result", {
code: siteResult.code,
stdout: siteResult.stdout,
stderr: siteResult.stderr,
});
const fallbackStatus = 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",
domainIdentifier,
]);
console.info("[domain_action] subscription info result", {
code: infoResult.code,
stdout: infoResult.stdout,
stderr: infoResult.stderr,
});
const parsed = parseSubscriptionStatusDetailsFromInfo(
infoResult.stdout ?? "",
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
refreshedStatus = parsed.status;
refreshedStatusRaw = parsed.statusRaw;
if (
action === "unsuspend" &&
refreshedStatus === "suspended" &&
(refreshedStatusRaw ?? "").toLowerCase().includes("subscriber")
) {
throw new Error(
"Domain remains suspended because its subscriber/customer account is suspended.",
);
}
} 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,
source: "domain_action",
domain_id: domain.id,
},
});
}
await supabase
.from("plesk_subscriptions")
.update({
status: refreshedStatus,
status_raw: refreshedStatusRaw,
last_status_sync_at: new Date().toISOString(),
})
.eq("id", subscription.id)
.eq("agency_id", context.agencyId);
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,
@@ -113,6 +204,8 @@ export async function POST(
domain_id: domain.id,
subscription_id: subscription.id,
plesk_subscription_id: subscription.plesk_subscription_id,
refreshed_status: refreshedStatus,
refresh_error: refreshErrorMessage,
},
});

View 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 },
);
}
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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";
}
@@ -235,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) {
@@ -576,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>
@@ -629,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)
}
@@ -638,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
View 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
View 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.

View File

@@ -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;
@@ -148,16 +150,27 @@ export async function listPleskSubscriptions(connection: PleskConnection) {
const subscriptions: PleskSubscription[] = [];
for (const subscriptionName of subscriptionNames.slice(0, 500)) {
let status: "active" | "suspended" | "unknown" = "unknown";
let status: "active" | "suspended" | "disabled" | "expired" | "unknown" =
"unknown";
let statusRaw: string | null = null;
let statusSyncError: string | null = null;
try {
const infoResult = await pleskCliCall(connection, "subscription", [
"--info",
subscriptionName,
]);
status = parseSubscriptionStatusFromInfo(infoResult.stdout ?? "");
} 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({
@@ -165,33 +178,57 @@ export async function listPleskSubscriptions(connection: PleskConnection) {
subscription_id: subscriptionName,
name: subscriptionName,
status,
status_raw: statusRaw,
status_sync_error: statusSyncError,
});
}
return subscriptions;
}
export function parseSubscriptionStatusFromInfo(
stdout: string,
): "active" | "suspended" | "unknown" {
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 "unknown";
const value = line
.replace(/^domain\s+status\s*:/i, "")
.trim()
.toLowerCase();
if (value.includes("ok")) return "active";
if (value.includes("suspend") || value.includes("disabled")) {
return "suspended";
if (!line) {
return { status: "unknown", statusRaw: null };
}
return "unknown";
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(
@@ -252,7 +289,7 @@ export async function unsuspendPleskSubscription(
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-on",
"--unsuspend",
subscriptionName,
]);
}

View File

@@ -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,
@@ -95,6 +150,8 @@ export async function syncPleskInstance(
plesk_subscription_id: String(sourceId),
name: item?.name ? String(item.name) : String(sourceId),
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,
};
}

View 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;