Compare commits

...

87 Commits

Author SHA1 Message Date
7346447136 updated jenkins file 2026-03-08 10:22:37 +00:00
e4c3c9a343 update guradrails and docker file 2026-03-08 10:12:11 +00:00
7538231f9d Merge pull request 'add cline docs' (#58) from feature/add-ai-docs-for-cline into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/58
2026-03-08 09:59:22 +00:00
8c0ba875c4 add cline docs 2026-03-08 09:58:56 +00:00
d26be9893a Merge pull request 'fix(ci): prevent ssh from consuming deploy-stage heredoc stdin' (#57) from fix/cl9-ssh-stdin-heredoc-consumption into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/57
2026-03-07 11:08:50 +00:00
268b10554a fix(ci): prevent ssh from consuming deploy-stage heredoc stdin 2026-03-07 11:08:28 +00:00
8f178e6b07 Merge pull request 'fix(ci): quote deploy-stage heredoc to preserve bash variable expansion' (#56) from fix/cl9-quoted-heredoc-in-deploy-stage into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/56
2026-03-07 10:56:09 +00:00
4cf768abec fix(ci): quote deploy-stage heredoc to preserve bash variable expansion 2026-03-07 10:55:11 +00:00
56a30fac4b Merge pull request 'fix(ci): use verified Jenkins SSH key path for deploy stage' (#55) from fix/cl9-use-known-good-jenkins-key-path into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/55
2026-03-07 10:38:51 +00:00
164e97618e fix(ci): use verified Jenkins SSH key path for deploy stage 2026-03-07 10:36:49 +00:00
2bb78e9864 Merge pull request 'fix(ci): add SSH credential diagnostics to deploy stage' (#54) from fix/cl9-debug-jenkins-ssh-key into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/54
2026-03-07 09:30:16 +00:00
173071cd5c fix(ci): add SSH credential diagnostics to deploy stage 2026-03-07 09:29:38 +00:00
fc73bf422f Merge pull request 'fix(ci): make deploy SSH commands non-interactive and fail fast' (#53) from fix/cl9-noninteractive-ssh-deploy into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/53
2026-03-07 09:03:58 +00:00
4c8ff2d07d fix(ci): make deploy SSH commands non-interactive and fail fast 2026-03-07 09:03:20 +00:00
5b5682acb1 Merge pull request 'fix(ci): run deploy stage under bash for pipefail support' (#52) from fix/cl9-deploy-stage-bash-wrapper into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/52
2026-03-07 08:52:04 +00:00
3701db4470 fix(ci): run deploy stage under bash for pipefail support 2026-03-07 08:51:42 +00:00
c67926dcb5 Merge pull request 'fix(deploy): add explicit image transfer verification to Jenkins deploy stage' (#51) from fix/cl9-verbose-image-transfer-debug into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/51
2026-03-07 08:46:59 +00:00
151a13f4b3 fix(deploy): add explicit image transfer verification to Jenkins deploy stage 2026-03-07 08:46:33 +00:00
79bf929b2d Merge pull request 'fix(deploy): make image transfer explicit and verifiable in Jenkins deploy stage' (#50) from fix/cl9-observable-image-transfer into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/50
2026-03-07 08:32:30 +00:00
7d5512230f fix(deploy): make image transfer explicit and verifiable in Jenkins deploy stage 2026-03-07 08:31:54 +00:00
2915f10315 Merge pull request 'fix(deploy): reliably transfer exact build image and simplify production deploy scripts' (#49) from fix/cl9-reliable-image-deploy-and-script-sync into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/49
2026-03-07 08:14:51 +00:00
23ad8af20e fix(deploy): reliably transfer exact build image and simplify production deploy scripts 2026-03-07 08:14:10 +00:00
ea57734fa8 Merge pull request 'fix(deploy): deploy exact Jenkins image tag and force container recreation' (#48) from fix/cl9-deploy-exact-image-tag into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/48
2026-03-07 08:00:12 +00:00
0efe1a5129 fix(deploy): deploy exact Jenkins image tag and force container recreation 2026-03-07 07:59:39 +00:00
18912934f8 Merge pull request 'fix(deploy): bust Docker build cache for Next.js client build' (#47) from fix/cl9-force-fresh-docker-client-build into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/47
2026-03-07 07:51:48 +00:00
4a8e5ae853 fix(deploy): bust Docker build cache for Next.js client build 2026-03-07 07:51:20 +00:00
a2d69f8210 update 2026-03-07 07:26:41 +00:00
74b1ab02b6 Merge pull request 'fix(deploy): add smoke check retries and pass public Supabase build env' (#45) from fix/cl9-smoke-check-retries-and-build-env into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/45
2026-03-07 07:20:41 +00:00
838cb38862 fix(deploy): add smoke check retries and pass public Supabase build env 2026-03-07 06:37:14 +00:00
5c6be8a056 Merge pull request 'fix(deploy): use image-only production compose and correct script sync' (#44) from fix/cl9-prod-compose-and-deploy-sync into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/44
2026-03-06 18:38:02 +00:00
2edf19fac3 fix(deploy): use image-only production compose and correct script sync 2026-03-06 18:37:14 +00:00
c696869909 Merge pull request 'fix(ci): correct rsync deploy path and define smoke check base URL' (#43) from fix/cl9-jenkins-rsync-and-smoke-check-paths into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/43
2026-03-06 18:22:38 +00:00
0e2dd1bfc7 fix(ci): correct rsync deploy path and define smoke check base URL 2026-03-06 18:21:13 +00:00
99f020f560 Merge pull request 'update deploy stage and smoke check for scripts' (#42) from fix/cl9-move-deployment-scripts into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/42
2026-03-06 17:56:59 +00:00
4d241545d2 update deploy stage and smoke check for scripts 2026-03-06 17:56:46 +00:00
2e8849332b Merge pull request 'fix(ci): add default deploy environment variables for bash-safe pipeline' (#41) from fix/cl9-jenkins-env-defaults into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/41
2026-03-06 16:10:05 +00:00
d30a7d37b5 fix(ci): add default deploy environment variables for bash-safe pipeline 2026-03-06 16:08:50 +00:00
6a09053ac3 Merge pull request 'fix(ci): run pipeline shell steps using bash to support pipefail' (#40) from fix/cl9-jenkins-bash-shell into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/40
2026-03-06 16:00:48 +00:00
ab27e57e66 fix(ci): run pipeline shell steps using bash to support pipefail 2026-03-06 15:53:52 +00:00
cd4ba09404 Merge pull request 'fix(ci): add default deploy credential and host variables to Jenkins pipeline' (#39) from fix/cl9-jenkins-credential-default into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/39
2026-03-06 15:45:01 +00:00
13b778a4e4 fix(ci): add default deploy credential and host variables to Jenkins pipeline 2026-03-06 15:42:18 +00:00
69c051052d Merge pull request 'swapped runner to node:20-bookworm-slim' (#38) from fix/update-runner into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/38
2026-03-06 14:13:39 +00:00
d1982f1618 swapped runner to node:20-bookworm-slim 2026-03-06 14:13:26 +00:00
d2b57c03b3 Merge pull request 'changed image to node:20-bookworm-slim' (#37) from fix/update-docker-file-for-image into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/37
2026-03-06 14:11:32 +00:00
5990ad9423 changed image to node:20-bookworm-slim 2026-03-06 14:11:13 +00:00
8391ef1004 Merge pull request 'commented out preflight' (#36) from fix/edit-jenkinfile into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/36
2026-03-06 14:03:03 +00:00
348e26de14 commented out preflight 2026-03-06 14:02:52 +00:00
54d920bc09 Merge pull request 'build fix check for docker' (#35) from fix/jenkins-check-for-docker into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/35
2026-03-06 13:58:53 +00:00
91744bf71f build fix check for docker 2026-03-06 13:58:41 +00:00
c4626e32f3 Merge pull request 'fix(ci): require docker-capable Jenkins agent with preflight checks' (#34) from feature/cl9-jenkins-docker-agent into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/34
2026-03-06 13:44:20 +00:00
4a334b8812 fix(ci): require docker-capable Jenkins agent with preflight checks 2026-03-06 13:41:14 +00:00
d525cf2589 Merge pull request 'fix types and linting' (#33) from fix/build-errors-for-linting-and-dynamic-server-with-types- into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/33
2026-03-06 13:25:45 +00:00
e16b9fb8f9 fix types and linting 2026-03-06 13:25:32 +00:00
d37e682138 Merge pull request 'comment out lint for deploy' (#32) from fix/comment-lint-out into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/32
2026-03-06 11:20:46 +00:00
c0d3eb84bd comment out lint for deploy 2026-03-06 11:20:35 +00:00
c42709194c Merge pull request 'feat(deploy): harden production proxy and cron configuration' (#31) from feature/cl9-production-hardening into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/31
2026-03-06 10:31:17 +00:00
63590a975e feat(deploy): harden production proxy and cron configuration 2026-03-06 10:24:32 +00:00
e0f7636ea6 Merge pull request 'feat(deploy): add Jenkins production deployment pipeline' (#30) from feature/cl9-jenkins-cicd-pipeline into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/30
2026-03-06 10:19:39 +00:00
403c6dd0a0 feat(deploy): add Jenkins production deployment pipeline 2026-03-06 10:18:11 +00:00
a3030703ce Merge pull request 'feature/cl9-production-runtime-foundation' (#29) from feature/cl9-production-runtime-foundation into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/29
2026-03-06 09:56:54 +00:00
820b676bd8 chore(deploy): move runtime to chainguard node images 2026-03-06 09:53:15 +00:00
cf1cd39c54 chore(deploy): switch to patched bookworm slim runtime base 2026-03-06 09:52:04 +00:00
4a60c7ec39 chore(deploy): harden docker base image patch level 2026-03-06 09:50:44 +00:00
b8d2693c67 feat(deploy): add production runtime foundation 2026-03-06 09:48:36 +00:00
f4ff058a8f Merge pull request 'feat(ui): add collapsible plesk instances panel with summary view' (#28) from feature/ui-collapse-instances-panel into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/28
2026-03-06 06:50:55 +00:00
7378b09302 feat(ui): add collapsible plesk instances panel with summary view 2026-03-06 06:48:44 +00:00
ad9b6ef7d2 Merge pull request 'feat(ui): collapse connect instance panel when instances exist' (#27) from feature/ui-collapse-connect-panel into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/27
2026-03-05 18:31:15 +00:00
fa97ae0400 feat(ui): collapse connect instance panel when instances exist 2026-03-05 18:29:33 +00:00
d0a0a81a31 Merge pull request 'feat(ui): add dashboard health and autosync overview widget' (#26) from feature/cl8-5-dashboard-overview into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/26
2026-03-05 18:14:55 +00:00
627a34728e feat(ui): add dashboard health and autosync overview widget 2026-03-05 18:14:25 +00:00
826e7da895 Merge pull request 'feature/cl8-instance-health' (#25) from feature/cl8-instance-health into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/25
2026-03-05 18:04:40 +00:00
8d86378341 feat(ui): show instance health badges and health panel 2026-03-05 18:03:38 +00:00
fb385b59c4 feat(api): add cron and manual health check endpoints 2026-03-05 18:03:26 +00:00
9411f292a8 feat(worker): add health check runner 2026-03-05 18:02:57 +00:00
953b62ef6e feat(db): add instance health monitoring fields 2026-03-05 18:02:32 +00:00
464dccedea docs: add cline super prompt for safe AI development 2026-03-05 17:48:38 +00:00
699454f183 Merge pull request 'feat(ui): add activity log page backed by actions_log' (#24) from feature/activity-log-page into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/24
2026-03-05 17:47:59 +00:00
03dfa6a5ae feat(ui): add activity log page backed by actions_log 2026-03-05 17:42:03 +00:00
4f2e0c8192 Merge pull request 'feat(worker): add autosync scheduler, endpoint, locking and UI controls' (#23) from feature/cl6-auto-sync-worker into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/23
2026-03-05 17:37:15 +00:00
5e5fb3221a feat(worker): add autosync scheduler, endpoint, locking and UI controls 2026-03-05 17:33:14 +00:00
34aa17a74a Merge pull request 'feat(logging): add best-effort requested/succeeded/failed action logs' (#22) from feature/cl5-action-logging into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/22
2026-03-05 17:19:05 +00:00
c943d2cbf6 feat(logging): add best-effort requested/succeeded/failed action logs 2026-03-05 17:13:18 +00:00
c11fcd01a4 Merge pull request 'feat(ui): polish domains list subscription/status display' (#21) from feature/ui-domains-polish into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/21
2026-03-05 17:07:48 +00:00
710227a49a feat(ui): polish domains list subscription/status display 2026-03-05 17:06:29 +00:00
b72176b276 Merge pull request 'fix(actions): use plesk bin site for suspend/unsuspend domain state' (#20) from fix/domain-actions-use-site-cli into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/20
2026-03-05 17:03:55 +00:00
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
42 changed files with 4236 additions and 358 deletions

36
.clinerules Normal file
View File

@@ -0,0 +1,36 @@
# Project AI Rules
This repository contains the Plesk Agency Portal SaaS project.
Before making changes, read the following documentation files:
docs/ai-bootstrap.md
docs/ai-project-context.md
docs/system-architecture.md
docs/GUARDRAILS.md
docs/next-steps.md
docs/handovers/latest.md
docs/current-focus.md
Project guidelines:
- Treat master branch as production-ready.
- Preserve the working Jenkins CI/CD deployment pipeline.
- Avoid breaking Supabase Row Level Security policies.
- Prefer minimal changes over large rewrites.
- Follow existing Next.js App Router structure.
Deployment architecture:
Jenkins builds Docker image.
Image transferred using:
docker save → scp → docker load
Application host path:
/opt/plesk-agency-portal
Public domain:
https://portal.rdbcloud.co.uk
Supabase domain
https://supabase.rdbcloud.co.uk

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,13 +1,22 @@
NEXT_PUBLIC_SUPABASE_URL= NODE_ENV=production
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
ENCRYPTION_KEY= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
PLESK_CRED_ENC_KEY= PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID= STRIPE_PRICE_ID=

View File

@@ -1,3 +1,6 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"] "extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
} }

45
AI-START-HERE.md Normal file
View File

@@ -0,0 +1,45 @@
# AI Start Here
Before making changes, read these files in order:
1. docs/ai-project-context.md
2. docs/system-architecture.md
3. docs/next-steps.md
4. docs/handovers/latest.md (or the newest dated handover file)
## Project
Plesk Agency Portal
## Rules
- Make minimal safe changes
- Do not weaken Supabase RLS
- Preserve working deployment pipeline
- Prefer extending existing patterns over rewrites
- Treat `master` as production-ready
## Current Focus
- Fix Supabase HTTPS/public URL
- Validate auth flow
- Keep CI/CD stable
## Deployment Summary
- Jenkins builds Docker image
- Image transferred via `docker save -> scp -> docker load`
- App runs on `/opt/plesk-agency-portal`
- Public app URL: `https://portal.rdbcloud.co.uk`
- supabase URL: `https://supabase.rdbcloud.co.uk`
## Notes
Use the docs folder as source of truth for current state and handover history.
## When updating the project
- update docs/ai-project-context.md for current state
- add a new file in docs/handovers/
- update docs/next-steps.md
- keep this file short

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG SUPABASE_URL
ARG NEXT_PUBLIC_APP_URL
ARG BUILD_CACHE_BUSTER
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
ENV SUPABASE_URL=${SUPABASE_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV BUILD_CACHE_BUSTER=${BUILD_CACHE_BUSTER}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ARG NEXT_PUBLIC_SUPABASE_URL
ARG SUPABASE_URL
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV SUPABASE_URL=${SUPABASE_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

172
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,172 @@
pipeline {
// Build + deploy stages require Docker CLI/daemon access on the Jenkins node.
// Ensure this label maps to an agent with Docker installed and usable.
//update
agent any
environment {
REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
DEPLOY_SSH_CREDENTIAL_ID = "${env.DEPLOY_SSH_CREDENTIAL_ID ?: 'plesk-agency-deploy-key'}"
APP_HOST = "${env.APP_HOST ?: '192.168.68.89'}"
APP_USER = "${env.APP_USER ?: 'deploy'}"
DEPLOY_PATH = "${env.DEPLOY_PATH ?: '/opt/plesk-agency-portal'}"
APP_BASE_URL = "${env.APP_BASE_URL ?: 'https://portal.rdbcloud.co.uk'}"
NEXT_PUBLIC_APP_URL = "${env.NEXT_PUBLIC_APP_URL ?: 'https://portal.rdbcloud.co.uk'}"
}
stages {
// stage('Preflight') {
// steps {
// sh '''
// set -euo pipefail
// if ! command -v docker >/dev/null 2>&1; then
// echo "Docker CLI is not available on this Jenkins agent."
// echo "Run this pipeline on a Docker-capable node or install Docker on the current agent."
// exit 1
// fi
// if ! docker version >/dev/null 2>&1; then
// echo "Docker is installed but not usable by Jenkins (daemon/socket/permissions issue)."
// exit 1
// fi
// '''
// }
// }
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install dependencies') {
steps {
sh 'npm ci'
}
}
// stage('Lint') {
// steps {
// script {
// def hasLint = sh(script: "node -e \"const fs=require('fs');const p=require('./package.json');process.exit(p?.scripts?.lint?0:1)\"", returnStatus: true)
// if (hasLint == 0) {
// sh 'npm run lint'
// } else {
// echo 'No lint script configured; skipping lint stage.'
// }
// }
// }
// }
stage('Build application') {
steps {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'SUPABASE_PUBLIC_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh '''
NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
NEXT_PUBLIC_APP_URL="$NEXT_PUBLIC_APP_URL" \
npm run build
'''
}
}
}
stage('Build Docker image') {
steps {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'SUPABASE_PUBLIC_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh 'docker build --build-arg NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" --build-arg SUPABASE_URL="$SUPABASE_PUBLIC_URL" --build-arg NEXT_PUBLIC_APP_URL="$NEXT_PUBLIC_APP_URL" --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="$NEXT_PUBLIC_SUPABASE_ANON_KEY" --build-arg BUILD_CACHE_BUSTER="${BUILD_NUMBER}" -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
}
}
}
stage('Deploy') {
steps {
sh '''
bash <<'EOF'
set -euxo pipefail
SSH_KEY="/var/lib/jenkins/.ssh/plesk_agency_deploy"
SSH_USER="deploy"
SSH_COMMON_OPTS="-i ${SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
SSH_OPTS="-n ${SSH_COMMON_OPTS}"
RSYNC_SSH="ssh ${SSH_COMMON_OPTS}"
echo "SSH user from configured deploy key: ${SSH_USER}"
echo "SSH key file path: ${SSH_KEY}"
ls -l "${SSH_KEY}"
ssh-keygen -y -f "${SSH_KEY}"
echo "Testing remote SSH connectivity"
ssh -n ${SSH_OPTS} "${SSH_USER}@${APP_HOST}" "whoami && hostname && pwd"
echo "Deploying image tag: ${IMAGE_BUILD}"
docker image inspect "${IMAGE_BUILD}" --format='{{.Id}} {{.RepoTags}}'
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "mkdir -p $DEPLOY_PATH/scripts"
rsync -az \
-e "${RSYNC_SSH}" \
docker-compose.prod.yml \
"$SSH_USER@$APP_HOST:$DEPLOY_PATH/"
rsync -az \
-e "${RSYNC_SSH}" \
scripts/ \
"$SSH_USER@$APP_HOST:$DEPLOY_PATH/scripts/"
IMAGE_TAR="plesk-agency-portal-build.tar"
echo "Creating image archive: ${IMAGE_TAR}"
docker save -o "${IMAGE_TAR}" "${IMAGE_BUILD}"
ls -lh "${IMAGE_TAR}"
sha256sum "${IMAGE_TAR}"
echo "Copying image archive to remote host via scp"
scp ${SSH_OPTS} "${IMAGE_TAR}" "$SSH_USER@$APP_HOST:$DEPLOY_PATH/"
echo "Verifying image archive exists on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "ls -lh $DEPLOY_PATH/plesk-agency-portal-build.tar"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "sha256sum $DEPLOY_PATH/plesk-agency-portal-build.tar"
echo "Loading image archive on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker load -i $DEPLOY_PATH/plesk-agency-portal-build.tar"
echo "Verifying exact image tag is available on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker image inspect ${IMAGE_BUILD} --format='{{.Id}} {{.RepoTags}}'"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" \
"cd $DEPLOY_PATH && chmod +x ./scripts/deploy-prod.sh ./scripts/smoke-check.sh ./scripts/rollback-prod.sh"
echo "Running remote deploy script with exact image tag"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" \
"cd $DEPLOY_PATH && APP_IMAGE='${IMAGE_BUILD}' ./scripts/deploy-prod.sh"
echo "Printing final running container image metadata"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker inspect plesk-agency-portal --format='{{.Image}} {{.Created}}'"
EOF
'''
}
}
stage('Smoke check') {
steps {
sh '''
bash -eo pipefail << EOF
set -euo pipefail
chmod +x scripts/smoke-check.sh
scripts/smoke-check.sh "${APP_BASE_URL}"
EOF
'''
}
}
}
}

View File

@@ -63,7 +63,9 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
- `POST /api/plesk/instances` — create + validate Plesk connection - `POST /api/plesk/instances` — create + validate Plesk connection
- `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance - `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance
- `POST /api/plesk/instances/:id/autosync` — update auto-sync enabled/frequency for one Plesk instance
- `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`) - `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`)
- `POST /api/cron/autosync` — scheduled auto-sync tick (requires `X-CRON-SECRET`)
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend - `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
- `POST /api/stripe/checkout` — create Stripe Checkout session - `POST /api/stripe/checkout` — create Stripe Checkout session
- `POST /api/stripe/portal` — create Stripe Customer Portal session - `POST /api/stripe/portal` — create Stripe Customer Portal session
@@ -74,6 +76,7 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
- Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets. - Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets.
- Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption. - Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption.
- Set `JOB_SECRET` for internal cron-triggered job auth. - Set `JOB_SECRET` for internal cron-triggered job auth.
- Set `CRON_SECRET` for `/api/cron/autosync` auth.
- `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification. - `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification.
- Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production. - Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production.
@@ -97,3 +100,37 @@ Notes:
- Job uses DB lock (`job_locks`) to avoid overlapping runs. - Job uses DB lock (`job_locks`) to avoid overlapping runs.
- Job processes at most 10 connected instances per run. - Job processes at most 10 connected instances per run.
- Instances synced in the last 3 minutes are skipped. - Instances synced in the last 3 minutes are skipped.
## 6) Auto Sync Worker (CL6)
New scheduler fields live on `plesk_instances`:
- `auto_sync_enabled`
- `auto_sync_frequency_minutes`
- `last_auto_sync_at`
- `next_auto_sync_at`
- `auto_sync_lock_until`
- `auto_sync_lock_token`
Endpoint for periodic worker ticks:
```bash
curl -sS -X POST http://localhost:3000/api/cron/autosync \
-H "X-CRON-SECRET: <CRON_SECRET>"
```
Recommended system cron (every 5 minutes):
```cron
*/5 * * * * curl -sS -X POST https://<your-host>/api/cron/autosync -H "X-CRON-SECRET: ${CRON_SECRET}"
```
Or schedule the same call from Jenkins.
Worker safety behavior:
- processes at most 5 instances per tick
- concurrency limited to 2 at a time
- per-instance lock lease 15 minutes
- per-instance timeout guard 14 minutes
- per-instance failures are isolated and logged to `actions_log`

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { runAutoSyncTick } from "@/lib/plesk/auto-sync";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
export async function POST(req: Request) {
const startedAt = Date.now();
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const supabase = createSupabaseAdminClient() as any;
const summary = await runAutoSyncTick(supabase);
return NextResponse.json({
...summary,
duration_ms: Date.now() - startedAt,
});
} catch (error) {
console.error("[autosync cron] failed", error);
return NextResponse.json(
{
error: "Auto-sync job failed",
instances_considered: 0,
locks_acquired: 0,
succeeded: 0,
failed: 0,
duration_ms: Date.now() - startedAt,
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { runHealthCheckTick } from "@/lib/plesk/health";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
export async function POST(req: Request) {
const startedAt = Date.now();
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const supabase = createSupabaseAdminClient() as any;
const summary = await runHealthCheckTick(supabase);
return NextResponse.json({
...summary,
duration_ms: Date.now() - startedAt,
});
} catch (error) {
console.error("[health cron] failed", error);
return NextResponse.json(
{
error: "Health check job failed",
checked: 0,
ok: 0,
degraded: 0,
failed: 0,
duration_ms: Date.now() - startedAt,
},
{ status: 500 },
);
}
}

13
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

View File

@@ -7,8 +7,6 @@ import { assertSameOrigin } from "@/lib/api/security";
import { import {
parseSubscriptionStatusDetailsFromInfo, parseSubscriptionStatusDetailsFromInfo,
pleskCliCall, pleskCliCall,
suspendPleskSubscription,
unsuspendPleskSubscription,
} from "@/lib/plesk/client"; } from "@/lib/plesk/client";
import { createSupabaseRouteClient } from "@/lib/supabase/route"; import { createSupabaseRouteClient } from "@/lib/supabase/route";
@@ -17,6 +15,44 @@ const actionSchema = z.object({
subscription_id: z.string().uuid().optional(), subscription_id: z.string().uuid().optional(),
}); });
const LOG_EXCERPT_LIMIT = 500;
const DOMAIN_ACTION_EVENTS = {
suspend: {
requested: "domain_suspend_requested",
succeeded: "domain_suspend_succeeded",
failed: "domain_suspend_failed",
},
unsuspend: {
requested: "domain_unsuspend_requested",
succeeded: "domain_unsuspend_succeeded",
failed: "domain_unsuspend_failed",
},
} as const;
function excerpt(value: string | null | undefined) {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > LOG_EXCERPT_LIMIT
? `${trimmed.slice(0, LOG_EXCERPT_LIMIT)}`
: trimmed;
}
async function insertActionLogBestEffort(
supabase: any,
payload: Record<string, unknown>,
) {
try {
const { error } = await supabase.from("actions_log").insert(payload);
if (error) {
console.error("[actions_log] insert failed", error.message);
}
} catch (error) {
console.error("[actions_log] insert threw", error);
}
}
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } }, { params }: { params: { id: string } },
@@ -31,6 +67,7 @@ export async function POST(
plesk_instance_id: string; plesk_instance_id: string;
} | null = null; } | null = null;
let requestedAction: "suspend" | "unsuspend" | null = null; let requestedAction: "suspend" | "unsuspend" | null = null;
const startedAt = Date.now();
try { try {
assertSameOrigin(req); assertSameOrigin(req);
@@ -101,17 +138,30 @@ export async function POST(
encryptedSecret: instance.encrypted_secret, encryptedSecret: instance.encrypted_secret,
} as const; } as const;
if (action === "suspend") { const domainIdentifier = subscription.plesk_subscription_id;
await suspendPleskSubscription( const siteArgs =
connection, action === "suspend"
subscription.plesk_subscription_id, ? ["--suspend", domainIdentifier]
); : ["--on", domainIdentifier];
} else {
await unsuspendPleskSubscription( await insertActionLogBestEffort(supabase, {
connection, agency_id: context.agencyId,
subscription.plesk_subscription_id, actor_user_id: context.userId,
); target_type: "plesk_domain",
} target_id: domain.id,
action: DOMAIN_ACTION_EVENTS[action].requested,
status: "pending",
metadata: {
domain_id: domain.id,
subscription_id: subscription.id,
plesk_instance_id: subscription.plesk_instance_id,
plesk_subscription_id: subscription.plesk_subscription_id,
cli_command: "site",
cli_args: siteArgs,
},
});
const siteResult = await pleskCliCall(connection, "site", siteArgs);
const fallbackStatus = action === "suspend" ? "suspended" : "active"; const fallbackStatus = action === "suspend" ? "suspended" : "active";
let refreshedStatus = fallbackStatus; let refreshedStatus = fallbackStatus;
@@ -121,20 +171,31 @@ export async function POST(
try { try {
const infoResult = await pleskCliCall(connection, "subscription", [ const infoResult = await pleskCliCall(connection, "subscription", [
"--info", "--info",
subscription.plesk_subscription_id, domainIdentifier,
]); ]);
const parsed = parseSubscriptionStatusDetailsFromInfo( const parsed = parseSubscriptionStatusDetailsFromInfo(
infoResult.stdout ?? "", infoResult.stdout ?? "",
); );
refreshedStatus = parsed.status; refreshedStatus = parsed.status;
refreshedStatusRaw = parsed.statusRaw; 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) { } catch (refreshError) {
refreshErrorMessage = refreshErrorMessage =
refreshError instanceof Error refreshError instanceof Error
? refreshError.message.slice(0, 1000) ? refreshError.message.slice(0, 1000)
: "subscription_status_refresh_failed"; : "subscription_status_refresh_failed";
await supabase.from("actions_log").insert({ await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId, agency_id: context.agencyId,
actor_user_id: context.userId, actor_user_id: context.userId,
target_type: "plesk_subscription", target_type: "plesk_subscription",
@@ -168,17 +229,24 @@ export async function POST(
.eq("agency_id", context.agencyId) .eq("agency_id", context.agencyId)
.eq("subscription_id", subscription.id); .eq("subscription_id", subscription.id);
await supabase.from("actions_log").insert({ await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId, agency_id: context.agencyId,
actor_user_id: context.userId, actor_user_id: context.userId,
target_type: "plesk_domain", target_type: "plesk_domain",
target_id: domain.id, target_id: domain.id,
action: `domain_${action}`, action: DOMAIN_ACTION_EVENTS[action].succeeded,
status: "success", status: "success",
metadata: { metadata: {
duration_ms: Date.now() - startedAt,
domain_id: domain.id, domain_id: domain.id,
subscription_id: subscription.id, subscription_id: subscription.id,
plesk_instance_id: subscription.plesk_instance_id,
plesk_subscription_id: subscription.plesk_subscription_id, plesk_subscription_id: subscription.plesk_subscription_id,
cli_command: "site",
cli_args: siteArgs,
cli_exit_code: siteResult.code,
cli_stdout_excerpt: excerpt(siteResult.stdout),
cli_stderr_excerpt: excerpt(siteResult.stderr),
refreshed_status: refreshedStatus, refreshed_status: refreshedStatus,
refresh_error: refreshErrorMessage, refresh_error: refreshErrorMessage,
}, },
@@ -189,20 +257,41 @@ export async function POST(
if (context && domain) { if (context && domain) {
const message = const message =
error instanceof Error ? error.message : "Domain action failed"; error instanceof Error ? error.message : "Domain action failed";
await supabase.from("actions_log").insert({ if (requestedAction) {
agency_id: context.agencyId, await insertActionLogBestEffort(supabase, {
actor_user_id: context.userId, agency_id: context.agencyId,
target_type: "plesk_domain", actor_user_id: context.userId,
target_id: domain.id, target_type: "plesk_domain",
action: requestedAction ? `domain_${requestedAction}` : "domain_action", target_id: domain.id,
status: "failed", action: DOMAIN_ACTION_EVENTS[requestedAction].failed,
error_message: message, status: "failed",
metadata: { error_message: message,
domain_id: domain.id, metadata: {
subscription_id: subscription?.id ?? domain.subscription_id, duration_ms: Date.now() - startedAt,
plesk_subscription_id: subscription?.plesk_subscription_id ?? null, domain_id: domain.id,
}, subscription_id: subscription?.id ?? domain.subscription_id,
}); plesk_instance_id: subscription?.plesk_instance_id ?? null,
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
},
});
} else {
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: "domain_action_failed",
status: "failed",
error_message: message,
metadata: {
duration_ms: Date.now() - startedAt,
domain_id: domain.id,
subscription_id: subscription?.id ?? domain.subscription_id,
plesk_instance_id: subscription?.plesk_instance_id ?? null,
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
},
});
}
} }
return NextResponse.json( return NextResponse.json(

View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
const autosyncSchema = z.object({
auto_sync_enabled: z.boolean(),
auto_sync_frequency_minutes: z.number().int().min(5).max(10080).optional(),
});
export async function POST(
req: Request,
{ params }: { params: { id: string } },
) {
try {
assertSameOrigin(req);
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-autosync-settings:${context.userId}`, 20, 60_000);
const input = autosyncSchema.parse(await req.json());
const frequency = input.auto_sync_frequency_minutes ?? 60;
const nowIso = new Date().toISOString();
const nextAtIso = new Date(
Date.now() + frequency * 60 * 1000,
).toISOString();
const supabase = createSupabaseRouteClient() as any;
const updates = {
auto_sync_enabled: input.auto_sync_enabled,
auto_sync_frequency_minutes: frequency,
next_auto_sync_at: input.auto_sync_enabled ? nextAtIso : null,
auto_sync_lock_until: null,
auto_sync_lock_token: null,
last_auto_sync_at: input.auto_sync_enabled ? undefined : null,
} as Record<string, unknown>;
const { data: instance, error } = await supabase
.from("plesk_instances")
.update(updates)
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.select(
"id, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until",
)
.single();
if (error || !instance) {
throw new Error(error?.message ?? "Plesk instance not found");
}
try {
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_instance",
target_id: params.id,
action: "autosync_settings_updated",
status: "success",
metadata: {
auto_sync_enabled: input.auto_sync_enabled,
auto_sync_frequency_minutes: frequency,
updated_at: nowIso,
},
});
} catch {
// best effort logging
}
return NextResponse.json({ instance });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to update auto-sync settings",
},
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,73 @@
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 { runInstanceHealthCheck } from "@/lib/plesk/health";
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-health-check:${context.userId}`, 20, 60_000);
const supabase = createSupabaseRouteClient() as any;
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, health_enabled, health_check_frequency_minutes, health_next_check_at, health_lock_until",
)
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
if (instanceError || !instance) {
throw new Error("Plesk instance not found");
}
const result = await runInstanceHealthCheck(supabase, instance, {
actorUserId: context.userId,
force: true,
source: "manual",
});
if (!result.checked) {
return NextResponse.json(
{ error: "Health check is already running for this instance" },
{ status: 409 },
);
}
const { data: updated } = await supabase
.from("plesk_instances")
.select(
"id, health_enabled, health_status, health_last_checked_at, health_last_ok_at, health_last_error, health_last_latency_ms, health_check_frequency_minutes, health_next_check_at",
)
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
return NextResponse.json({
ok: true,
result,
instance: updated,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Health check execution failed",
},
{ status: 400 },
);
}
}

View File

@@ -16,6 +16,44 @@ const actionSchema = z.object({
action: z.enum(["suspend", "unsuspend"]), action: z.enum(["suspend", "unsuspend"]),
}); });
const LOG_EXCERPT_LIMIT = 500;
const SUBSCRIPTION_ACTION_EVENTS = {
suspend: {
requested: "subscription_suspend_requested",
succeeded: "subscription_suspend_succeeded",
failed: "subscription_suspend_failed",
},
unsuspend: {
requested: "subscription_unsuspend_requested",
succeeded: "subscription_unsuspend_succeeded",
failed: "subscription_unsuspend_failed",
},
} as const;
function excerpt(value: string | null | undefined) {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > LOG_EXCERPT_LIMIT
? `${trimmed.slice(0, LOG_EXCERPT_LIMIT)}`
: trimmed;
}
async function insertActionLogBestEffort(
supabase: any,
payload: Record<string, unknown>,
) {
try {
const { error } = await supabase.from("actions_log").insert(payload);
if (error) {
console.error("[actions_log] insert failed", error.message);
}
} catch (error) {
console.error("[actions_log] insert threw", error);
}
}
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } }, { params }: { params: { id: string } },
@@ -29,6 +67,7 @@ export async function POST(
plesk_subscription_id: string; plesk_subscription_id: string;
plesk_instance_id: string; plesk_instance_id: string;
} | null = null; } | null = null;
const startedAt = Date.now();
try { try {
assertSameOrigin(req); assertSameOrigin(req);
@@ -70,17 +109,37 @@ export async function POST(
encryptedSecret: instance.encrypted_secret, encryptedSecret: instance.encrypted_secret,
} as const; } as const;
if (payload.action === "suspend") { const cliArgs =
await suspendPleskSubscription( payload.action === "suspend"
connection, ? ["--webspace-off", subscription.plesk_subscription_id]
subscription.plesk_subscription_id, : ["--unsuspend", subscription.plesk_subscription_id];
);
} else { await insertActionLogBestEffort(supabase, {
await unsuspendPleskSubscription( agency_id: context.agencyId,
connection, actor_user_id: context.userId,
subscription.plesk_subscription_id, target_type: "plesk_subscription",
); target_id: subscription.id,
} action: SUBSCRIPTION_ACTION_EVENTS[payload.action].requested,
status: "pending",
metadata: {
subscription_id: subscription.id,
plesk_instance_id: subscription.plesk_instance_id,
plesk_subscription_id: subscription.plesk_subscription_id,
cli_command: "subscription",
cli_args: cliArgs,
},
});
const actionCliResult =
payload.action === "suspend"
? await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
)
: await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
const fallbackStatus = const fallbackStatus =
payload.action === "suspend" ? "suspended" : "active"; payload.action === "suspend" ? "suspended" : "active";
@@ -104,7 +163,7 @@ export async function POST(
? refreshError.message.slice(0, 1000) ? refreshError.message.slice(0, 1000)
: "subscription_status_refresh_failed"; : "subscription_status_refresh_failed";
await supabase.from("actions_log").insert({ await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId, agency_id: context.agencyId,
actor_user_id: context.userId, actor_user_id: context.userId,
target_type: "plesk_subscription", target_type: "plesk_subscription",
@@ -135,17 +194,23 @@ export async function POST(
.eq("agency_id", context.agencyId) .eq("agency_id", context.agencyId)
.eq("subscription_id", subscription.id); .eq("subscription_id", subscription.id);
await supabase.from("actions_log").insert({ await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId, agency_id: context.agencyId,
actor_user_id: context.userId, actor_user_id: context.userId,
target_type: "plesk_subscription", target_type: "plesk_subscription",
target_id: subscription.id, target_id: subscription.id,
action: payload.action, action: SUBSCRIPTION_ACTION_EVENTS[payload.action].succeeded,
status: "success", status: "success",
metadata: { metadata: {
duration_ms: Date.now() - startedAt,
cli_command: "subscription",
cli_args: cliArgs,
plesk_subscription_id: subscription.plesk_subscription_id, plesk_subscription_id: subscription.plesk_subscription_id,
refreshed_status: refreshedStatus, refreshed_status: refreshedStatus,
refresh_error: refreshErrorMessage, refresh_error: refreshErrorMessage,
cli_exit_code: actionCliResult.code,
cli_stdout_excerpt: excerpt(actionCliResult.stdout),
cli_stderr_excerpt: excerpt(actionCliResult.stderr),
db_status_update_error: updateError?.message ?? null, db_status_update_error: updateError?.message ?? null,
}, },
}); });
@@ -160,18 +225,37 @@ export async function POST(
if (supabase && context && subscription) { if (supabase && context && subscription) {
const message = const message =
error instanceof Error ? error.message : "Subscription action failed"; error instanceof Error ? error.message : "Subscription action failed";
await supabase.from("actions_log").insert({ if (requestedAction) {
agency_id: context.agencyId, await insertActionLogBestEffort(supabase, {
actor_user_id: context.userId, agency_id: context.agencyId,
target_type: "plesk_subscription", actor_user_id: context.userId,
target_id: subscription.id, target_type: "plesk_subscription",
action: requestedAction ?? "subscription_action", target_id: subscription.id,
status: "failed", action: SUBSCRIPTION_ACTION_EVENTS[requestedAction].failed,
error_message: message, status: "failed",
metadata: { error_message: message,
plesk_subscription_id: subscription.plesk_subscription_id, metadata: {
}, duration_ms: Date.now() - startedAt,
}); plesk_subscription_id: subscription.plesk_subscription_id,
plesk_instance_id: subscription.plesk_instance_id,
},
});
} else {
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_subscription",
target_id: subscription.id,
action: "subscription_action_failed",
status: "failed",
error_message: message,
metadata: {
duration_ms: Date.now() - startedAt,
plesk_subscription_id: subscription.plesk_subscription_id,
plesk_instance_id: subscription.plesk_instance_id,
},
});
}
} }
return NextResponse.json( return NextResponse.json(

View File

@@ -15,7 +15,7 @@ export async function POST() {
} }
const stripe = getStripeClient(); const stripe = getStripeClient();
const supabase = createSupabaseRouteClient(); const supabase = createSupabaseRouteClient() as any;
const { data: billing, error: billingError } = await supabase const { data: billing, error: billingError } = await supabase
.from("billing_accounts") .from("billing_accounts")
@@ -42,7 +42,10 @@ export async function POST() {
}; };
if (billing?.id) { if (billing?.id) {
await supabase.from("billing_accounts").update(upsertPayload).eq("id", billing.id); await supabase
.from("billing_accounts")
.update(upsertPayload)
.eq("id", billing.id);
} else { } else {
await supabase.from("billing_accounts").insert(upsertPayload); await supabase.from("billing_accounts").insert(upsertPayload);
} }
@@ -62,7 +65,12 @@ export async function POST() {
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create checkout session" }, {
error:
error instanceof Error
? error.message
: "Failed to create checkout session",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -10,7 +10,7 @@ export async function POST() {
const context = await getRouteAgencyContextOrThrow(); const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context); assertAgencyAdmin(context);
const supabase = createSupabaseRouteClient(); const supabase = createSupabaseRouteClient() as any;
const stripe = getStripeClient(); const stripe = getStripeClient();
const { data: billing, error } = await supabase const { data: billing, error } = await supabase
@@ -32,7 +32,12 @@ export async function POST() {
return NextResponse.json({ url: session.url }); return NextResponse.json({ url: session.url });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to open billing portal" }, {
error:
error instanceof Error
? error.message
: "Failed to open billing portal",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -19,7 +19,7 @@ async function upsertBillingByCustomer(
current_period_end?: string | null; current_period_end?: string | null;
}, },
) { ) {
const supabase = createSupabaseAdminClient(); const supabase = createSupabaseAdminClient() as any;
const { data: existing } = await supabase const { data: existing } = await supabase
.from("billing_accounts") .from("billing_accounts")
@@ -28,22 +28,33 @@ async function upsertBillingByCustomer(
.maybeSingle(); .maybeSingle();
if (existing?.id) { if (existing?.id) {
await supabase.from("billing_accounts").update(updates).eq("id", existing.id); await supabase
.from("billing_accounts")
.update(updates)
.eq("id", existing.id);
} }
} }
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) { async function handleCheckoutSessionCompleted(
const agencyId = typeof session.metadata?.agency_id === "string" ? session.metadata.agency_id : null; session: Stripe.Checkout.Session,
const customerId = typeof session.customer === "string" ? session.customer : null; ) {
const agencyId =
typeof session.metadata?.agency_id === "string"
? session.metadata.agency_id
: null;
const customerId =
typeof session.customer === "string" ? session.customer : null;
if (!agencyId || !customerId) return; if (!agencyId || !customerId) return;
const supabase = createSupabaseAdminClient(); const supabase = createSupabaseAdminClient() as any;
const payload = { const payload = {
agency_id: agencyId, agency_id: agencyId,
stripe_customer_id: customerId, stripe_customer_id: customerId,
stripe_subscription_id: stripe_subscription_id:
typeof session.subscription === "string" ? (session.subscription as string) : null, typeof session.subscription === "string"
? (session.subscription as string)
: null,
subscription_status: "active", subscription_status: "active",
}; };
@@ -54,7 +65,10 @@ async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session)
.maybeSingle(); .maybeSingle();
if (existing?.id) { if (existing?.id) {
await supabase.from("billing_accounts").update(payload).eq("id", existing.id); await supabase
.from("billing_accounts")
.update(payload)
.eq("id", existing.id);
} else { } else {
await supabase.from("billing_accounts").insert(payload); await supabase.from("billing_accounts").insert(payload);
} }
@@ -62,7 +76,14 @@ async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session)
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId = const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id; typeof subscription.customer === "string"
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
if (!customerId) return; if (!customerId) return;
@@ -70,20 +91,27 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
plan_id: subscription.items.data[0]?.price.id ?? null, plan_id: subscription.items.data[0]?.price.id ?? null,
subscription_status: subscription.status, subscription_status: subscription.status,
current_period_end: toIsoOrNull(subscription.current_period_end), current_period_end: toIsoOrNull(currentPeriodEnd),
}); });
} }
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id; typeof subscription.customer === "string"
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
if (!customerId) return; if (!customerId) return;
await upsertBillingByCustomer(customerId, { await upsertBillingByCustomer(customerId, {
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
subscription_status: "canceled", subscription_status: "canceled",
current_period_end: toIsoOrNull(subscription.current_period_end), current_period_end: toIsoOrNull(currentPeriodEnd),
}); });
} }
@@ -95,22 +123,35 @@ export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature"); const signature = req.headers.get("stripe-signature");
if (!signature) { if (!signature) {
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 }); return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 },
);
} }
const stripe = getStripeClient(); const stripe = getStripeClient();
const body = await req.text(); const body = await req.text();
const event = stripe.webhooks.constructEvent(body, signature, env.stripeWebhookSecret); const event = stripe.webhooks.constructEvent(
body,
signature,
env.stripeWebhookSecret,
);
switch (event.type) { switch (event.type) {
case "checkout.session.completed": case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session); await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session,
);
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription,
);
break; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription,
);
break; break;
default: default:
break; break;
@@ -119,7 +160,10 @@ export async function POST(req: Request) {
return NextResponse.json({ received: true }); return NextResponse.json({ received: true });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : "Webhook handling failed" }, {
error:
error instanceof Error ? error.message : "Webhook handling failed",
},
{ status: 400 }, { status: 400 },
); );
} }

View File

@@ -2,12 +2,18 @@ import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { env } from "@/lib/env";
import type { Database } from "@/lib/types"; import type { Database } from "@/lib/types";
export async function GET(request: Request) { export async function GET(request: Request) {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code"); const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next") ?? "/dashboard"; const nextParam = requestUrl.searchParams.get("next");
const next =
nextParam && nextParam.startsWith("/") && !nextParam.startsWith("//")
? nextParam
: "/dashboard";
const redirectOrigin = env.appOrigin || requestUrl.origin;
if (code) { if (code) {
const supabase = createRouteHandlerClient<Database>({ cookies }); const supabase = createRouteHandlerClient<Database>({ cookies });
@@ -15,18 +21,16 @@ export async function GET(request: Request) {
if (error) { if (error) {
console.error(error); console.error(error);
return NextResponse.redirect( return NextResponse.redirect(
new URL(`/login?authError=1`, requestUrl.origin), new URL(`/login?authError=1`, redirectOrigin),
); );
} }
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user) { if (!user) {
return NextResponse.redirect( return NextResponse.redirect(new URL(`/login?noUser=1`, redirectOrigin));
new URL(`/login?noUser=1`, requestUrl.origin),
);
} }
} }
return NextResponse.redirect(new URL(next, requestUrl.origin)); return NextResponse.redirect(new URL(next, redirectOrigin));
} }

View File

@@ -0,0 +1,301 @@
import Link from "next/link";
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { formatDateTime } from "@/lib/dates";
export const dynamic = "force-dynamic";
const PAGE_SIZE = 50;
type SearchParams = {
page?: string;
instance?: string;
action?: string;
status?: string;
};
function getSingleParam(value: string | string[] | undefined) {
if (!value) return "";
return Array.isArray(value) ? (value[0] ?? "") : value;
}
function buildQueryString(next: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(next)) {
if (value == null) continue;
const normalized = String(value);
if (!normalized) continue;
params.set(key, normalized);
}
return params.toString();
}
export default async function ActivityPage({
searchParams,
}: {
searchParams?: SearchParams;
}) {
const context = await getServerAgencyContextOrRedirect();
const supabase = createSupabaseServerClient() as any;
const selectedInstance = getSingleParam(searchParams?.instance);
const selectedAction = getSingleParam(searchParams?.action);
const selectedStatus = getSingleParam(searchParams?.status);
const page = Math.max(
Number(getSingleParam(searchParams?.page) || "1") || 1,
1,
);
const { data: instances } = await supabase
.from("plesk_instances")
.select("id, name, base_url")
.eq("agency_id", context.agencyId)
.order("name", { ascending: true });
let baseQuery = supabase
.from("actions_log")
.select(
"id, created_at, target_type, target_id, action, status, error_message, metadata",
{ count: "exact" },
)
.eq("agency_id", context.agencyId)
.order("created_at", { ascending: false });
if (selectedAction) {
baseQuery = baseQuery.eq("action", selectedAction);
}
if (selectedStatus) {
baseQuery = baseQuery.eq("status", selectedStatus);
}
if (selectedInstance) {
baseQuery = baseQuery.or(
`target_id.eq.${selectedInstance},metadata->>plesk_instance_id.eq.${selectedInstance}`,
);
}
const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { data: logs, count } = await baseQuery.range(from, to);
const { data: recentForActions } = await supabase
.from("actions_log")
.select("action")
.eq("agency_id", context.agencyId)
.order("created_at", { ascending: false })
.limit(200);
const actionOptions: string[] = Array.from(
new Set<string>(
(recentForActions ?? []).map((entry: any) => String(entry.action)),
),
).sort((a, b) => a.localeCompare(b));
const instanceNameById = new Map<string, string>(
(instances ?? []).map((instance: any) => [
String(instance.id),
String(instance.name ?? instance.base_url ?? instance.id),
]),
);
const total = count ?? 0;
const hasPrev = page > 1;
const hasNext = to + 1 < total;
return (
<main className="mx-auto w-full max-w-6xl space-y-4 px-6 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Activity Log</h1>
<p className="text-sm text-slate-600">
Recent actions for your agency
</p>
</div>
<Link href="/dashboard" className="text-sm text-brand-700 underline">
Back to dashboard
</Link>
</div>
<form className="grid gap-2 rounded-xl border border-slate-200 bg-white p-4 md:grid-cols-4">
<div>
<label className="text-xs font-medium text-slate-600">Instance</label>
<select
name="instance"
defaultValue={selectedInstance}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
>
<option value="">All instances</option>
{(instances ?? []).map((instance: any) => (
<option key={String(instance.id)} value={String(instance.id)}>
{String(instance.name ?? instance.base_url ?? instance.id)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-slate-600">Action</label>
<select
name="action"
defaultValue={selectedAction}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
>
<option value="">All actions</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{action}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium text-slate-600">Status</label>
<select
name="status"
defaultValue={selectedStatus}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
</div>
<div className="flex items-end gap-2">
<button className="rounded border px-3 py-1.5 text-sm">Apply</button>
<Link
href="/dashboard/activity"
className="rounded border px-3 py-1.5 text-sm"
>
Reset
</Link>
</div>
</form>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
<th className="px-3 py-2">Time</th>
<th className="px-3 py-2">Instance</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Target</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Message</th>
<th className="px-3 py-2">Details</th>
</tr>
</thead>
<tbody>
{(logs ?? []).length === 0 ? (
<tr>
<td className="px-3 py-4 text-slate-500" colSpan={7}>
No activity found.
</td>
</tr>
) : (
(logs ?? []).map((row: any) => {
const metadata =
row?.metadata && typeof row.metadata === "object"
? (row.metadata as Record<string, unknown>)
: null;
const instanceId =
(metadata?.plesk_instance_id as string | undefined) ??
(row.target_type === "plesk_instance"
? (row.target_id as string | null)
: null);
return (
<tr
key={row.id}
className="border-b border-slate-100 align-top"
>
<td className="px-3 py-2">
{formatDateTime(row.created_at)}
</td>
<td className="px-3 py-2">
{instanceId
? (instanceNameById.get(instanceId) ?? instanceId)
: "-"}
</td>
<td className="px-3 py-2">{row.action ?? "-"}</td>
<td className="px-3 py-2">
{row.target_type ?? "-"}
{row.target_id ? ` (${row.target_id})` : ""}
</td>
<td className="px-3 py-2">{row.status ?? "-"}</td>
<td className="px-3 py-2">{row.error_message ?? "-"}</td>
<td className="px-3 py-2">
{metadata ? (
<details>
<summary className="cursor-pointer text-xs text-brand-700">
View
</summary>
<pre className="mt-1 max-w-[460px] overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-700">
{JSON.stringify(metadata, null, 2)}
</pre>
</details>
) : (
"-"
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<p className="text-slate-500">
Showing {Math.min(total, from + 1)} - {Math.min(total, to + 1)} of{" "}
{total}
</p>
<div className="flex gap-2">
{hasPrev ? (
<Link
href={`/dashboard/activity?${buildQueryString({
page: page - 1,
instance: selectedInstance,
action: selectedAction,
status: selectedStatus,
})}`}
className="rounded border px-3 py-1.5"
>
Previous
</Link>
) : (
<span className="rounded border px-3 py-1.5 text-slate-400">
Previous
</span>
)}
{hasNext ? (
<Link
href={`/dashboard/activity?${buildQueryString({
page: page + 1,
instance: selectedInstance,
action: selectedAction,
status: selectedStatus,
})}`}
className="rounded border px-3 py-1.5"
>
Next
</Link>
) : (
<span className="rounded border px-3 py-1.5 text-slate-400">
Next
</span>
)}
</div>
</div>
</main>
);
}

View File

@@ -1,8 +1,12 @@
import { SignOutButton } from "@/components/auth/sign-out-button"; import { SignOutButton } from "@/components/auth/sign-out-button";
import { DashboardControls } from "@/components/dashboard/dashboard-controls"; import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency"; import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { formatDateTime } from "@/lib/dates";
import type { Database } from "@/lib/types"; import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link";
export const dynamic = "force-dynamic";
type AgencySummary = Pick< type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"], Database["public"]["Tables"]["agencies"]["Row"],
@@ -16,6 +20,78 @@ type ActionLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"], Database["public"]["Tables"]["actions_log"]["Row"],
"target_id" | "status" | "action" | "created_at" | "error_message" "target_id" | "status" | "action" | "created_at" | "error_message"
>; >;
type AutosyncLatestLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
"created_at"
>;
type HealthLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
| "id"
| "target_id"
| "status"
| "action"
| "created_at"
| "error_message"
| "metadata"
>;
const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
});
function getLatestTimestamp(values: Array<string | null | undefined>) {
let latestIso: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const value of values) {
if (!value) continue;
const parsed = new Date(value).getTime();
if (Number.isNaN(parsed)) continue;
if (parsed > latestMs) {
latestMs = parsed;
latestIso = value;
}
}
return latestIso;
}
function formatRelativeTime(value?: string | null) {
if (!value) return null;
const targetMs = new Date(value).getTime();
if (Number.isNaN(targetMs)) return null;
const deltaSeconds = Math.round((targetMs - Date.now()) / 1000);
const absSeconds = Math.abs(deltaSeconds);
if (absSeconds < 60) {
return RELATIVE_TIME_FORMATTER.format(deltaSeconds, "second");
}
const deltaMinutes = Math.round(deltaSeconds / 60);
if (Math.abs(deltaMinutes) < 60) {
return RELATIVE_TIME_FORMATTER.format(deltaMinutes, "minute");
}
const deltaHours = Math.round(deltaMinutes / 60);
if (Math.abs(deltaHours) < 24) {
return RELATIVE_TIME_FORMATTER.format(deltaHours, "hour");
}
const deltaDays = Math.round(deltaHours / 24);
if (Math.abs(deltaDays) < 30) {
return RELATIVE_TIME_FORMATTER.format(deltaDays, "day");
}
const deltaMonths = Math.round(deltaDays / 30);
if (Math.abs(deltaMonths) < 12) {
return RELATIVE_TIME_FORMATTER.format(deltaMonths, "month");
}
const deltaYears = Math.round(deltaMonths / 12);
return RELATIVE_TIME_FORMATTER.format(deltaYears, "year");
}
function normalizeDomainLike(value: string) { function normalizeDomainLike(value: string) {
return value.trim().toLowerCase().replace(/\.$/, ""); return value.trim().toLowerCase().replace(/\.$/, "");
@@ -48,6 +124,8 @@ export default async function DashboardPage() {
{ data: domains }, { data: domains },
{ data: billing }, { data: billing },
{ data: autoSyncLogs }, { data: autoSyncLogs },
{ data: latestAutoSyncLog },
{ data: healthLogs },
] = (await Promise.all([ ] = (await Promise.all([
supabase supabase
.from("agencies") .from("agencies")
@@ -57,7 +135,7 @@ export default async function DashboardPage() {
supabase supabase
.from("plesk_instances") .from("plesk_instances")
.select( .select(
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains", "id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until, health_enabled, health_status, health_last_checked_at, health_last_ok_at, health_last_error, health_last_latency_ms, health_check_frequency_minutes, health_next_check_at, health_lock_until",
) )
.eq("agency_id", context.agencyId) .eq("agency_id", context.agencyId)
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
@@ -88,6 +166,31 @@ export default async function DashboardPage() {
.eq("action", "plesk_sync_auto") .eq("action", "plesk_sync_auto")
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(200), .limit(200),
supabase
.from("actions_log")
.select("created_at")
.eq("agency_id", context.agencyId)
.eq("target_type", "plesk_instance")
.eq("action", "autosync_completed")
.eq("status", "success")
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from("actions_log")
.select(
"id, target_id, status, action, created_at, error_message, metadata",
)
.eq("agency_id", context.agencyId)
.eq("target_type", "plesk_instance")
.in("action", [
"health_check_started",
"health_check_ok",
"health_check_degraded",
"health_check_failed",
])
.order("created_at", { ascending: false })
.limit(400),
])) as [ ])) as [
{ data: AgencySummary | null }, { data: AgencySummary | null },
{ {
@@ -104,6 +207,20 @@ export default async function DashboardPage() {
last_sync_error: string | null; last_sync_error: string | null;
last_sync_subscriptions: number; last_sync_subscriptions: number;
last_sync_domains: number; last_sync_domains: number;
auto_sync_enabled: boolean;
auto_sync_frequency_minutes: number;
last_auto_sync_at: string | null;
next_auto_sync_at: string | null;
auto_sync_lock_until: string | null;
health_enabled: boolean;
health_status: "ok" | "degraded" | "down" | "unknown";
health_last_checked_at: string | null;
health_last_ok_at: string | null;
health_last_error: string | null;
health_last_latency_ms: number | null;
health_check_frequency_minutes: number;
health_next_check_at: string | null;
health_lock_until: string | null;
}> | null; }> | null;
error: { message: string } | null; error: { message: string } | null;
}, },
@@ -132,6 +249,8 @@ export default async function DashboardPage() {
}, },
{ data: BillingSummary | null }, { data: BillingSummary | null },
{ data: ActionLogSummary[] | null }, { data: ActionLogSummary[] | null },
{ data: AutosyncLatestLogSummary | null },
{ data: HealthLogSummary[] | null },
]; ];
const domainInstanceIds = Array.from( const domainInstanceIds = Array.from(
@@ -154,6 +273,19 @@ export default async function DashboardPage() {
]), ]),
); );
const subscriptionDisplayNameById = new Map<string, string>(
(domainSubscriptions ?? [])
.filter((subscription: any) => subscription?.id)
.map((subscription: any) => [
String(subscription.id),
String(
subscription?.name ||
subscription?.plesk_subscription_id ||
subscription?.id,
),
]),
);
const subscriptionStatusByExternalId = new Map<string, string | null>( const subscriptionStatusByExternalId = new Map<string, string | null>(
(domainSubscriptions ?? []) (domainSubscriptions ?? [])
.filter((subscription: any) => .filter((subscription: any) =>
@@ -211,6 +343,67 @@ export default async function DashboardPage() {
}); });
} }
const healthEventsByInstance = new Map<
string,
Array<{
id: string;
action: string;
status: string;
created_at: string;
error_message: string | null;
metadata: unknown;
}>
>();
for (const log of healthLogs ?? []) {
if (!log.target_id) continue;
const existing = healthEventsByInstance.get(log.target_id) ?? [];
if (existing.length >= 10) continue;
existing.push({
id: log.id,
action: log.action,
status: log.status,
created_at: log.created_at,
error_message: log.error_message,
metadata: log.metadata,
});
healthEventsByInstance.set(log.target_id, existing);
}
const healthCounts = { ok: 0, degraded: 0, down: 0, unknown: 0 };
for (const instance of instances ?? []) {
const status = (instance.health_status ?? "unknown") as
| "ok"
| "degraded"
| "down"
| "unknown";
if (status === "ok") healthCounts.ok += 1;
else if (status === "degraded") healthCounts.degraded += 1;
else if (status === "down") healthCounts.down += 1;
else healthCounts.unknown += 1;
}
const latestHealthCheckAt = getLatestTimestamp(
(instances ?? []).map((instance) => instance.health_last_checked_at),
);
const autoSyncEnabledCount = (instances ?? []).filter(
(instance) => instance.auto_sync_enabled,
).length;
const latestAutoSyncFromInstances = getLatestTimestamp(
(instances ?? []).map((instance) => instance.last_auto_sync_at),
);
const latestAutoSyncAt =
latestAutoSyncFromInstances ?? latestAutoSyncLog?.created_at ?? null;
const downInstanceNames = (instances ?? [])
.filter((instance) => instance.health_status === "down")
.slice(0, 3)
.map((instance) => instance.name || instance.base_url);
return ( return (
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8"> <main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
@@ -220,9 +413,100 @@ export default async function DashboardPage() {
</h1> </h1>
<p className="text-sm text-slate-600">Role: {context.role}</p> <p className="text-sm text-slate-600">Role: {context.role}</p>
</div> </div>
<SignOutButton /> <div className="flex items-center gap-3">
<Link
href="/dashboard/activity"
className="text-sm text-brand-700 underline"
>
Activity
</Link>
<SignOutButton />
</div>
</header> </header>
<section className="rounded-xl border border-slate-200 bg-white p-4">
<div className="space-y-3 text-sm text-slate-700">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Instance Health
</p>
<div className="mt-1 flex flex-wrap gap-2">
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
Ok: {healthCounts.ok}
</span>
<span className="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
Degraded: {healthCounts.degraded}
</span>
<span className="rounded bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
Down: {healthCounts.down}
</span>
<span className="rounded bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
Unknown: {healthCounts.unknown}
</span>
</div>
</div>
{healthCounts.down > 0 ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{healthCounts.down} instance{healthCounts.down === 1 ? "" : "s"}{" "}
down
{downInstanceNames.length > 0
? `: ${downInstanceNames.join(", ")}${healthCounts.down > downInstanceNames.length ? ", …" : ""}`
: ""}
</div>
) : null}
<div className="flex flex-wrap gap-3">
<p>
<span className="font-medium text-slate-900">
Last Health Check:
</span>{" "}
<span
title={
latestHealthCheckAt
? formatDateTime(latestHealthCheckAt)
: undefined
}
>
{formatRelativeTime(latestHealthCheckAt) ?? "Never"}
</span>
</p>
<p>
<span className="font-medium text-slate-900">Auto Sync:</span>{" "}
Enabled on {autoSyncEnabledCount} instance
{autoSyncEnabledCount === 1 ? "" : "s"}
</p>
<p>
<span className="font-medium text-slate-900">Last run:</span>{" "}
<span
title={
latestAutoSyncAt
? formatDateTime(latestAutoSyncAt)
: undefined
}
>
{formatRelativeTime(latestAutoSyncAt) ?? "Not yet run"}
</span>
</p>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Link
href="/dashboard#instances-panel"
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Instances
</Link>
<Link
href="/dashboard/activity"
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Activity
</Link>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3"> <section className="grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4"> <div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Plesk Instances</p> <p className="text-sm text-slate-500">Plesk Instances</p>
@@ -248,40 +532,49 @@ export default async function DashboardPage() {
Instances query failed: {instancesError.message} Instances query failed: {instancesError.message}
</div> </div>
) : null} ) : null}
<DashboardControls <section id="instances-panel">
instances={instances ?? []} <DashboardControls
subscriptions={subscriptions ?? []} instances={instances ?? []}
domains={(domains ?? []).map((domain) => { subscriptions={subscriptions ?? []}
const joinedStatusByLocalId = domain.subscription_id domains={(domains ?? []).map((domain) => {
? subscriptionStatusById.get(domain.subscription_id) const joinedStatusByLocalId = domain.subscription_id
: null; ? subscriptionStatusById.get(domain.subscription_id)
const joinedStatusByExternalId = domain.external_subscription_id : null;
? subscriptionStatusByExternalId.get( const joinedStatusByExternalId = domain.external_subscription_id
`${domain.plesk_instance_id}:${domain.external_subscription_id}`, ? subscriptionStatusByExternalId.get(
) `${domain.plesk_instance_id}:${domain.external_subscription_id}`,
: null; )
const joinedStatusByName = subscriptionStatusByInstanceAndName.get( : null;
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`, const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
); `${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
const joinedStatusBySuffix = findBestSuffixStatusMatch( );
normalizeDomainLike(domain.domain_name), const joinedStatusBySuffix = findBestSuffixStatusMatch(
subscriptionStatusNameCandidatesByInstance.get( normalizeDomainLike(domain.domain_name),
domain.plesk_instance_id, subscriptionStatusNameCandidatesByInstance.get(
) ?? [], domain.plesk_instance_id,
); ) ?? [],
const joinedStatus = );
joinedStatusByLocalId ?? const joinedStatus =
joinedStatusByExternalId ?? joinedStatusByLocalId ??
joinedStatusByName ?? joinedStatusByExternalId ??
joinedStatusBySuffix; joinedStatusByName ??
joinedStatusBySuffix;
return { return {
...domain, ...domain,
status: joinedStatus ?? "unknown", status: joinedStatus ?? "unknown",
}; subscription_name: domain.subscription_id
})} ? (subscriptionDisplayNameById.get(domain.subscription_id) ??
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())} null)
/> : null,
};
})}
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
healthEventsByInstance={Object.fromEntries(
healthEventsByInstance.entries(),
)}
/>
</section>
</main> </main>
); );
} }

File diff suppressed because it is too large Load Diff

21
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
web:
container_name: plesk-agency-portal
image: ${APP_IMAGE:-plesk-agency-portal:latest}
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

245
docs/GUARDRAILS.md Normal file
View File

@@ -0,0 +1,245 @@
# GUARDRAILS
These systems are **currently working** and must **not be broken by AI refactors**.
AI tools must **prefer minimal changes** and avoid architecture changes unless explicitly instructed.
---
# Core Principle
If a task can be solved with a **small change**, the AI must **not perform a large rewrite**.
---
# Git Workflow Rules
The AI must **NEVER commit directly to `master` or `main`.**
All changes must be made via a branch.
## Branch naming conventions
Feature work:
feature/<short-description>
Bug fixes:
fix/<short-description>
Examples:
feature/supabase-public-endpoint
fix/smoke-check-url
fix/magic-link-auth
## Required workflow
1. Create a branch from `master`
2. Make the required changes
3. Commit changes to that branch
4. Show the diff
5. Do **not merge automatically**
6. Raise a pull request
7. User performs merge after review
---
# Infrastructure Protection
The AI must **not modify infrastructure** unless explicitly instructed.
This includes:
- DNS records
- reverse proxy configuration
- SSL certificates
- server networking
- firewall rules
- Proxmox configuration
- VM/LXC creation
- Nginx Proxy Manager configuration
- Supabase server configuration
These systems are **managed manually**.
---
# CI/CD
The Jenkins pipeline currently **builds and deploys the application**.
Deployment process:
docker save → scp → docker load
The AI must **not change the deployment architecture** without instruction.
Allowed CI changes:
- fixing pipeline bugs
- improving reliability
- adding environment variables
- improving logs
Not allowed:
- replacing Jenkins
- replacing Docker deployment
- changing deployment host
- introducing Kubernetes
- major CI architecture changes
---
# Container
Container name:
plesk-agency-portal
Deployment path:
/opt/plesk-agency-portal
The AI must **not rename containers or paths** without explicit approval.
---
# Reverse Proxy
Reverse proxy is managed by:
Nginx Proxy Manager
Public application URL:
https://portal.rdbcloud.co.uk
Supabase public endpoint:
https://supabase.rdbcloud.co.uk
The AI must **not change reverse proxy configuration**.
---
# Authentication
Authentication system:
Supabase Magic Link
Required environment variables:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
The AI must **not replace the authentication system**.
---
# Environment Variables
Secrets must **never be committed to the repository**.
Secrets must come from:
- Jenkins credentials
- environment variables
- deployment configuration
Never commit:
API keys
private tokens
database passwords
service keys
---
# Docker
Docker is used for **build and deployment**.
The AI may:
- update Dockerfile
- fix build issues
- improve caching
The AI must **not introduce complex container orchestration**.
Not allowed:
kubernetes
docker swarm
nomad
terraform
Unless explicitly requested.
---
# Database
Database system:
Supabase Postgres
The AI must **not perform destructive database changes**.
Not allowed automatically:
DROP TABLE
DROP COLUMN
DELETE large datasets
schema rewrites
Schema changes must be **reviewed first**.
---
# Safe Refactoring Rules
Allowed:
- small code improvements
- bug fixes
- performance improvements
- logging improvements
- type fixes
- lint fixes
Avoid:
- large rewrites
- framework changes
- directory restructuring
- renaming large parts of the codebase
Unless specifically requested.
---
# When AI Is Unsure
If the AI is unsure:
1. Explain the risk
2. Ask for clarification
3. Do **not proceed with risky changes**
---
# Priority Order
When making decisions, the AI must prioritise:
1. **Do not break production**
2. **Respect guardrails**
3. **Make minimal safe changes**
4. **Preserve deployment pipeline**
5. **Follow branch workflow**

43
docs/PROJECT-STATE.md Normal file
View File

@@ -0,0 +1,43 @@
# PROJECT-STATE
## Stable Systems
The following are stable and working:
CI/CD pipeline
Docker deployment
Reverse proxy routing
Application container startup
Smoke test endpoint
## Deployment
Jenkins → Build → Docker image → docker save → SCP → docker load → container recreate
App path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
## Public Endpoint
https://portal.rdbcloud.co.uk
## Current Work
Fix Supabase HTTPS endpoint and authentication flow.
## Known Risk
Supabase currently exposed via HTTP internally causing browser mixed content blocking.
## Update Rules
When system changes:
1. Update PROJECT-STATE.md
2. Update ai-project-context.md
3. Add a new handover file

58
docs/RUNBOOK.md Normal file
View File

@@ -0,0 +1,58 @@
# RUNBOOK
## Production App
Host:
192.168.68.89
Deploy path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
Public URL:
https://portal.rdbcloud.co.uk
---
## Health Check
Public:
curl https://portal.rdbcloud.co.uk/api/health
Local:
curl http://127.0.0.1:3000/api/health
---
## Check Running Container
docker ps
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
docker images | grep plesk-agency-portal
---
## View Logs
cd /opt/plesk-agency-portal
docker compose -f docker-compose.prod.yml logs --tail=200
Follow logs:
docker logs -f plesk-agency-portal
---
## Rollback
cd /opt/plesk-agency-portal
APP_IMAGE=plesk-agency-portal:build-37 ./scripts/rollback-prod.sh
Verify:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
curl http://127.0.0.1:3000/api/health

191
docs/ai-bootstrap.md Normal file
View File

@@ -0,0 +1,191 @@
# AI Bootstrap Context -- Plesk Agency Portal
This document is intended to be uploaded into a new AI conversation so
the assistant can quickly understand the project state.
------------------------------------------------------------------------
# Project Overview
The **Plesk Agency Portal** is a multi-tenant SaaS platform that allows
agencies to manage multiple Plesk servers, hosting subscriptions, and
domains from a single interface.
Goals:
- Connect multiple Plesk servers
- Sync subscriptions and domains
- Manage hosting environments
- Provide SaaS billing integration
- Enforce tenant isolation using Supabase Row Level Security (RLS)
------------------------------------------------------------------------
# Technology Stack
Frontend Next.js (App Router)
Backend Next.js API routes
Authentication Supabase Auth (Magic Link)
Database Supabase PostgreSQL
CI/CD Jenkins
Containerisation Docker
Reverse Proxy Nginx Proxy Manager
Infrastructure Self-hosted Linux servers
------------------------------------------------------------------------
# Current Deployment Architecture
CI/CD Pipeline:
1. Code pushed to repository
2. Jenkins pipeline runs
3. Next.js application builds
4. Docker image builds
5. Image exported via `docker save`
6. Image transferred via SSH/SCP
7. Image loaded on application host
8. Container recreated
9. Smoke test verifies service
Images are transferred using:
docker save → scp → docker load
This avoids requiring a Docker registry during early development.
------------------------------------------------------------------------
# Infrastructure
Jenkins Host Responsible for:
- building Docker images
- running CI/CD pipeline
- deploying containers
Application Host
Deployment path:
/opt/plesk-agency-portal
Docker container:
plesk-agency-portal
Application port:
3000
Reverse Proxy:
Nginx Proxy Manager
Public domain:
https://portal.rdbcloud.co.uk
------------------------------------------------------------------------
# Database & Auth
Supabase is self-hosted on an internal server.
Current API endpoint:
http://192.168.68.52:54321
Because the portal runs via HTTPS, this causes a browser mixed-content
error.
------------------------------------------------------------------------
# Current Blocking Issue
Supabase authentication fails because the frontend attempts to call:
http://192.168.68.52:54321
while the portal itself is served via HTTPS.
Browsers block the request.
------------------------------------------------------------------------
# Planned Fix
Expose Supabase through HTTPS using Nginx Proxy Manager.
Target domain:
https://supabase.rdbcloud.co.uk
Proxy configuration:
Forward host: 192.168.68.52\
Forward port: 54321
After that update environment variables:
NEXT_PUBLIC_SUPABASE_URL SUPABASE_URL
Then redeploy the portal.
------------------------------------------------------------------------
# Deployment Verification
Example deployed image:
plesk-agency-portal:build-62
Verification command:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
Health endpoint:
/api/health
------------------------------------------------------------------------
# Repository Documentation
Important docs:
docs/ai-project-context.md\
docs/system-architecture.md\
docs/next-steps.md\
docs/handovers/\*
------------------------------------------------------------------------
# Typical AI Assistance Areas
- CI/CD debugging
- Docker deployment
- Next.js development
- Supabase integration
- SaaS multi-tenant architecture
- Stripe billing integration
- Plesk API integration
------------------------------------------------------------------------
# Current Development Stage
Infrastructure and deployment pipeline are working.
Next priorities:
1. Fix Supabase HTTPS endpoint
2. Verify authentication flow
3. Continue SaaS portal feature development

202
docs/cline-super-prompt.md Normal file
View File

@@ -0,0 +1,202 @@
# Cline Development Super Prompt
You are acting as a senior software engineer working on this repository.
Before making any modifications you must understand the project structure and current implementation.
Always read:
`docs/ai-project-context.md`
before implementing tasks.
---
## Working Method
When given a task follow this process.
Step 1
Search the repository for relevant code.
Step 2
Identify the correct extension point for the feature.
Step 3
Design the minimal safe implementation.
Step 4
Modify only the required files.
Step 5
Verify existing functionality remains intact.
Never rewrite large parts of the system unless explicitly instructed.
---
## Repository Exploration Rules
Before editing files:
Search for related implementations.
Look for:
- plesk integration
- sync logic
- API routes
- database queries
- dashboard components
Use existing patterns instead of creating new ones.
---
## Database Rules
The project uses Supabase Postgres.
Never introduce breaking schema changes.
All schema updates must be done using safe migrations.
Never remove columns that may contain production data.
Always assume the database already contains data.
---
## Multi-Tenant Security
This system is multi-tenant.
Users belong to agencies.
All queries must respect agency boundaries.
Never bypass Supabase Row Level Security.
Never expose data across agencies.
---
## Plesk Integration Safety
The platform interacts with Plesk servers using both REST API and CLI commands.
CLI output can vary between Plesk versions.
Always parse CLI output defensively.
Never assume fixed output formatting.
External integration failures must not crash the application.
---
## Sync Pipeline Rules
The sync system is critical to the platform.
Never break existing sync behaviour.
If one subscription fails during sync:
Continue processing the rest.
Log the failure.
Do not abort the entire sync job.
---
## Logging Requirements
Operational events should be written to:
`actions_log`
Examples include:
- instance_connected
- sync_started
- sync_completed
- subscription_suspended
- subscription_unsuspended
Logging errors must never break primary workflows.
---
## UI Safety
Frontend components must:
Handle missing or null data safely.
Avoid crashing when API responses change.
Display statuses using badges instead of raw text.
---
## Git Workflow
The repository uses:
`master` as the default branch.
All development must occur in feature branches.
Example workflow:
```bash
git checkout master
git pull
git checkout -b feature/<feature-name>
```
Commit frequently with clear commit messages.
Never commit directly to master.
---
## Implementation Guidelines
Prefer small modular changes.
Avoid large refactors unless requested.
Keep API routes lightweight.
Place integration logic inside lib folders.
Reuse existing utilities where possible.
---
## Error Handling
External services can fail.
Always implement safe error handling.
Prefer returning partial results over failing the entire operation.
Log unexpected behaviour.
---
## Expected Output
When implementing tasks you should:
Modify only the required files.
Maintain existing architecture.
Add clear commit messages.
Ensure the application remains stable.

7
docs/current-focus.md Normal file
View File

@@ -0,0 +1,7 @@
# Current Focus
Immediate tasks:
1. Fix Supabase HTTPS endpoint
2. Verify authentication flow
3. Continue SaaS portal feature development

View File

@@ -0,0 +1,157 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.
## Reverse proxy assumptions (Nginx Proxy Manager)
This app is expected to run behind a reverse proxy in production.
- TLS is terminated at the proxy.
- Proxy forwards standard host/protocol headers (`Host`, `X-Forwarded-Proto`, `X-Forwarded-Host`).
- Public origin is configured explicitly with `NEXT_PUBLIC_APP_URL`.
To keep callback behavior stable behind proxy layers, auth redirect responses use the configured application origin from environment configuration rather than relying only on request host detection.
## Callback URL expectations
- `NEXT_PUBLIC_APP_URL` must be set to the public HTTPS URL used by users.
- Supabase auth redirect/callback configuration must allow:
- `<NEXT_PUBLIC_APP_URL>/auth/callback`
The callback route also validates the `next` parameter as an internal path to prevent unsafe external redirects.
## Cron protection expectations
Operational cron endpoints are protected with a shared secret.
- Required header: `x-cron-secret`
- Server secret source: `CRON_SHARED_SECRET`
- Backward compatibility fallback: `CRON_SECRET`
For new production setups, set `CRON_SHARED_SECRET` and use that value for all cron callers.
## Jenkins pipeline flow
The repository includes a declarative `Jenkinsfile` with the following stages:
1. **Checkout**
2. **Install dependencies** (`npm ci`)
3. **Lint** (runs only when `package.json` contains a `lint` script)
4. **Build application** (`npm run build`)
5. **Build Docker image** (tags `latest` and `build-${BUILD_NUMBER}`)
6. **Deploy** over SSH to the app host
7. **Smoke check** against `/api/health`
## Required Jenkins configuration
Configure these values in Jenkins job/folder/global environment or credentials-backed environment variables:
- `DEPLOY_SSH_HOST` - remote app host
- `DEPLOY_SSH_USER` - SSH user on remote host
- `DEPLOY_SSH_CREDENTIAL_ID` - Jenkins SSH Agent credential ID
- `DEPLOY_PATH` - remote deployment path (for example `/opt/plesk-agency-portal`)
- `APP_BASE_URL` - externally reachable app URL used by smoke check
The pipeline assumes Jenkins credentials are managed outside this repository.
Jenkins runtime requirements:
- The pipeline must run on a Jenkins agent with the `docker` label.
- That agent must have Docker CLI installed and permission to access Docker daemon/socket.
- `rsync` and `ssh` must also be available on the Jenkins agent.
If Docker is missing or inaccessible, the pipeline now fails early in a dedicated preflight stage with an explicit message.
## Deploy flow
Deployment is intentionally simple and bash-based:
1. Jenkins builds Docker image tags locally.
2. Jenkins syncs deployment files (`docker-compose.prod.yml`, deploy/smoke/rollback scripts) to the remote deploy path.
3. Jenkins streams Docker images to the remote host (`docker save | ssh docker load`).
4. Jenkins runs `scripts/deploy-prod.sh` remotely.
5. Script applies the selected image tag via:
```bash
APP_IMAGE=<tag> docker compose -f docker-compose.prod.yml up -d --no-build
```
6. Script prints `docker compose ps` status.
## Rollback flow
Rollback uses `scripts/rollback-prod.sh` with a previously built image tag:
```bash
DEPLOY_PATH=/opt/plesk-agency-portal \
APP_BASE_URL=https://your-app.example.com \
scripts/rollback-prod.sh plesk-agency-portal:build-123
```
Rollback redeploys that image with compose, then immediately reruns smoke checks.
## Smoke check behavior
`scripts/smoke-check.sh`:
- accepts a base URL argument
- requests `${BASE_URL}/api/health`
- fails on non-200 responses
- fails on invalid JSON or missing expected fields (`ok: true`, `service` string)
This gives a quick post-deploy validation gate for both deploy and rollback operations.

View File

@@ -0,0 +1,100 @@
# Handover 2026-03-07 CI/CD Deploy Working
## Status
The Plesk Agency Portal deployment pipeline is now working endtoend.
### Jenkins Pipeline
Pipeline successfully performs:
1. Build Next.js application
2. Build Docker image
3. Save image archive
4. Transfer archive to app host
5. Load image on remote server
6. Recreate container
7. Run smoke test
Example deployed image:
plesk-agency-portal:build-62
Verification command:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
---
## Infrastructure
### Jenkins Host
Responsibilities:
- Build Docker images
- Execute CI/CD pipeline
- Transfer deployment artifacts
### Application Host
Deployment path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
Application port:
3000
---
## Reverse Proxy
Managed using:
Nginx Proxy Manager
Public domain:
https://portal.rdbcloud.co.uk
---
## Known Issue
Supabase authentication blocked due to mixed content.
Frontend attempts:
http://192.168.68.52:54321
while the site runs via HTTPS.
---
## Next Task
Expose Supabase API through HTTPS:
https://supabase.rdbcloud.co.uk
Then update environment variables:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_URL
and redeploy the application.
---
## Deployment Strategy
Docker registry intentionally avoided.
Images transferred using:
docker save → scp → docker load

0
docs/handovers/latest.md Normal file
View File

View File

@@ -3,6 +3,18 @@ const requiredEnvs = [
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const; ] as const;
const configuredAppUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
function getAppOrigin() {
if (!configuredAppUrl) return "http://localhost:3000";
try {
return new URL(configuredAppUrl).origin;
} catch {
return "http://localhost:3000";
}
}
for (const key of requiredEnvs) { for (const key of requiredEnvs) {
if (!process.env[key]) { if (!process.env[key]) {
// Keep as runtime warning for smoother local setup. // Keep as runtime warning for smoother local setup.
@@ -12,14 +24,19 @@ for (const key of requiredEnvs) {
} }
export const env = { export const env = {
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", appUrl: configuredAppUrl ?? "http://localhost:3000",
appOrigin: getAppOrigin(),
jobSecret: process.env.JOB_SECRET ?? "", jobSecret: process.env.JOB_SECRET ?? "",
cronSecret: process.env.CRON_SECRET ?? "",
cronSharedSecret:
process.env.CRON_SHARED_SECRET ?? process.env.CRON_SECRET ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "", pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "", stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
stripePriceId: process.env.STRIPE_PRICE_ID ?? "", stripePriceId: process.env.STRIPE_PRICE_ID ?? "",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", supabaseUrl:
process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
}; };

232
lib/plesk/auto-sync.ts Normal file
View File

@@ -0,0 +1,232 @@
import { randomUUID } from "crypto";
import { syncPleskInstance } from "@/lib/plesk/sync";
type SupabaseLike = any;
type AutoSyncInstance = {
id: string;
agency_id: string;
base_url: string;
auth_type: "api_key" | "basic";
encrypted_secret: string;
last_connected_at: string | null;
auto_sync_enabled: boolean;
auto_sync_frequency_minutes: number | null;
next_auto_sync_at: string | null;
auto_sync_lock_until: string | null;
};
type AutoSyncSummary = {
instances_considered: number;
locks_acquired: number;
succeeded: number;
failed: number;
duration_ms: number;
};
const MAX_INSTANCES_PER_TICK = 5;
const MAX_CONCURRENCY = 2;
const LOCK_LEASE_SECONDS = 15 * 60;
const INSTANCE_TIMEOUT_MS = 14 * 60 * 1000;
function isDue(instance: AutoSyncInstance, now: number) {
if (!instance.next_auto_sync_at) return true;
const dueAt = new Date(instance.next_auto_sync_at).getTime();
return Number.isFinite(dueAt) && dueAt <= now;
}
function isUnlocked(instance: AutoSyncInstance, now: number) {
if (!instance.auto_sync_lock_until) return true;
const lockUntil = new Date(instance.auto_sync_lock_until).getTime();
return !Number.isFinite(lockUntil) || lockUntil <= now;
}
async function insertActionLogBestEffort(
supabase: SupabaseLike,
payload: Record<string, unknown>,
) {
try {
const { error } = await supabase.from("actions_log").insert(payload);
if (error) {
console.error("[autosync] actions_log insert failed", error.message);
}
} catch (error) {
console.error("[autosync] actions_log insert threw", error);
}
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error("Auto-sync timed out"));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export async function runAutoSyncTick(
supabase: SupabaseLike,
): Promise<AutoSyncSummary> {
const startedAt = Date.now();
const now = Date.now();
const { data: rows, error } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at, auto_sync_enabled, auto_sync_frequency_minutes, next_auto_sync_at, auto_sync_lock_until",
)
.eq("status", "connected")
.eq("auto_sync_enabled", true)
.order("next_auto_sync_at", { ascending: true, nullsFirst: true })
.limit(50);
if (error) {
throw new Error(error.message);
}
const eligible = ((rows ?? []) as AutoSyncInstance[])
.filter((instance) => isDue(instance, now) && isUnlocked(instance, now))
.slice(0, MAX_INSTANCES_PER_TICK);
let locksAcquired = 0;
let succeeded = 0;
let failed = 0;
const queue = [...eligible];
async function processInstance(instance: AutoSyncInstance) {
const lockToken = randomUUID();
const { data: lockAcquired, error: lockError } = await supabase.rpc(
"acquire_instance_auto_sync_lock",
{
p_instance_id: instance.id,
p_lock_token: lockToken,
p_ttl_seconds: LOCK_LEASE_SECONDS,
},
);
if (lockError || !lockAcquired) return;
locksAcquired += 1;
const perInstanceStartedAt = Date.now();
const frequencyMinutes = Math.max(
instance.auto_sync_frequency_minutes ?? 60,
5,
);
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: null,
target_type: "plesk_instance",
target_id: instance.id,
action: "autosync_started",
status: "pending",
metadata: {
instance_id: instance.id,
lock_token: lockToken,
frequency_minutes: frequencyMinutes,
},
});
try {
await withTimeout(
syncPleskInstance(supabase, instance, null, "autosync"),
INSTANCE_TIMEOUT_MS,
);
succeeded += 1;
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: null,
target_type: "plesk_instance",
target_id: instance.id,
action: "autosync_completed",
status: "success",
metadata: {
instance_id: instance.id,
lock_token: lockToken,
duration_ms: Date.now() - perInstanceStartedAt,
},
});
} catch (syncError) {
failed += 1;
const errorMessage =
syncError instanceof Error
? syncError.message.slice(0, 500)
: "autosync_failed";
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: null,
target_type: "plesk_instance",
target_id: instance.id,
action: "autosync_failed",
status: "failed",
error_message: errorMessage,
metadata: {
instance_id: instance.id,
lock_token: lockToken,
duration_ms: Date.now() - perInstanceStartedAt,
error_summary: errorMessage,
},
});
} finally {
const nowIso = new Date().toISOString();
const nextAutoSyncAtIso = new Date(
Date.now() + frequencyMinutes * 60 * 1000,
).toISOString();
const { error: scheduleError } = await supabase
.from("plesk_instances")
.update({
last_auto_sync_at: nowIso,
next_auto_sync_at: nextAutoSyncAtIso,
auto_sync_lock_until: null,
auto_sync_lock_token: null,
})
.eq("id", instance.id)
.eq("auto_sync_lock_token", lockToken);
if (scheduleError) {
console.error(
"[autosync] schedule update failed",
scheduleError.message,
);
}
}
}
const workers = Array.from(
{ length: Math.min(MAX_CONCURRENCY, queue.length) },
async () => {
while (queue.length > 0) {
const instance = queue.shift();
if (!instance) break;
await processInstance(instance);
}
},
);
await Promise.all(workers);
return {
instances_considered: eligible.length,
locks_acquired: locksAcquired,
succeeded,
failed,
duration_ms: Date.now() - startedAt,
};
}

345
lib/plesk/health.ts Normal file
View File

@@ -0,0 +1,345 @@
import { randomUUID } from "crypto";
import { testPleskConnection } from "@/lib/plesk/client";
type SupabaseLike = any;
type HealthStatus = "ok" | "degraded" | "down" | "unknown";
type HealthInstance = {
id: string;
agency_id: string;
base_url: string;
auth_type: "api_key" | "basic";
encrypted_secret: string;
health_enabled: boolean;
health_check_frequency_minutes: number | null;
health_next_check_at: string | null;
health_lock_until: string | null;
};
type HealthCheckResult = {
status: Exclude<HealthStatus, "unknown">;
latency_ms: number;
error_message: string | null;
};
type RunSingleHealthCheckResult = {
checked: boolean;
status?: Exclude<HealthStatus, "unknown">;
latency_ms?: number;
error_message?: string | null;
};
export type HealthTickSummary = {
checked: number;
ok: number;
degraded: number;
failed: number;
duration_ms: number;
};
const MAX_INSTANCES_PER_TICK = 10;
const MAX_CONCURRENCY = 3;
const LOCK_LEASE_SECONDS = 5 * 60;
const HEALTH_TIMEOUT_MS = 15_000;
const DEGRADED_LATENCY_THRESHOLD_MS = 2_500;
function isDue(instance: HealthInstance, now: number) {
if (!instance.health_next_check_at) return true;
const dueAt = new Date(instance.health_next_check_at).getTime();
return Number.isFinite(dueAt) && dueAt <= now;
}
function isUnlocked(instance: HealthInstance, now: number) {
if (!instance.health_lock_until) return true;
const lockUntil = new Date(instance.health_lock_until).getTime();
return !Number.isFinite(lockUntil) || lockUntil <= now;
}
async function insertActionLogBestEffort(
supabase: SupabaseLike,
payload: Record<string, unknown>,
) {
try {
const { error } = await supabase.from("actions_log").insert(payload);
if (error) {
console.error("[health] actions_log insert failed", error.message);
}
} catch (error) {
console.error("[health] actions_log insert threw", error);
}
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error("Health check timed out"));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function checkPleskInstanceHealth(
instance: Pick<HealthInstance, "base_url" | "auth_type" | "encrypted_secret">,
): Promise<HealthCheckResult> {
const startedAt = Date.now();
try {
await withTimeout(
testPleskConnection({
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
}),
HEALTH_TIMEOUT_MS,
);
const latencyMs = Date.now() - startedAt;
const status: "ok" | "degraded" =
latencyMs > DEGRADED_LATENCY_THRESHOLD_MS ? "degraded" : "ok";
return {
status,
latency_ms: latencyMs,
error_message: null,
};
} catch (error) {
return {
status: "down",
latency_ms: Date.now() - startedAt,
error_message:
error instanceof Error
? error.message.slice(0, 1000)
: "health_check_failed",
};
}
}
export async function runInstanceHealthCheck(
supabase: SupabaseLike,
instance: HealthInstance,
input?: {
actorUserId?: string | null;
force?: boolean;
source?: "cron" | "manual";
},
): Promise<RunSingleHealthCheckResult> {
const lockToken = randomUUID();
const actorUserId = input?.actorUserId ?? null;
const source = input?.source ?? "cron";
const { data: lockAcquired, error: lockError } = await supabase.rpc(
"acquire_instance_health_lock",
{
p_instance_id: instance.id,
p_lock_token: lockToken,
p_ttl_seconds: LOCK_LEASE_SECONDS,
p_force: Boolean(input?.force),
},
);
if (lockError || !lockAcquired) {
return { checked: false };
}
const startedAt = Date.now();
const nowIso = new Date().toISOString();
const frequencyMinutes = Math.max(
instance.health_check_frequency_minutes ?? 30,
5,
);
const nextCheckAtIso = new Date(
Date.now() + frequencyMinutes * 60 * 1000,
).toISOString();
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: actorUserId,
target_type: "plesk_instance",
target_id: instance.id,
action: "health_check_started",
status: "pending",
metadata: {
instance_id: instance.id,
lock_token: lockToken,
source,
},
});
try {
const result = await checkPleskInstanceHealth(instance);
const updates: Record<string, unknown> = {
health_status: result.status,
health_last_checked_at: nowIso,
health_last_latency_ms: result.latency_ms,
health_last_error: result.error_message,
health_next_check_at: nextCheckAtIso,
health_lock_until: null,
health_lock_token: null,
};
if (result.status === "ok") {
updates.health_last_ok_at = nowIso;
}
const { error: updateError } = await supabase
.from("plesk_instances")
.update(updates)
.eq("id", instance.id)
.eq("health_lock_token", lockToken);
if (updateError) {
throw new Error(updateError.message);
}
const action =
result.status === "ok"
? "health_check_ok"
: result.status === "degraded"
? "health_check_degraded"
: "health_check_failed";
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: actorUserId,
target_type: "plesk_instance",
target_id: instance.id,
action,
status: result.status === "down" ? "failed" : "success",
error_message: result.error_message,
metadata: {
instance_id: instance.id,
lock_token: lockToken,
source,
latency_ms: result.latency_ms,
duration_ms: Date.now() - startedAt,
},
});
return {
checked: true,
status: result.status,
latency_ms: result.latency_ms,
error_message: result.error_message,
};
} catch (error) {
const errorMessage =
error instanceof Error
? error.message.slice(0, 1000)
: "health_check_failed";
await supabase
.from("plesk_instances")
.update({
health_status: "down",
health_last_checked_at: nowIso,
health_last_error: errorMessage,
health_next_check_at: nextCheckAtIso,
})
.eq("id", instance.id);
await insertActionLogBestEffort(supabase, {
agency_id: instance.agency_id,
actor_user_id: actorUserId,
target_type: "plesk_instance",
target_id: instance.id,
action: "health_check_failed",
status: "failed",
error_message: errorMessage,
metadata: {
instance_id: instance.id,
lock_token: lockToken,
source,
duration_ms: Date.now() - startedAt,
},
});
return {
checked: true,
status: "down",
latency_ms: Date.now() - startedAt,
error_message: errorMessage,
};
} finally {
await supabase.rpc("release_instance_health_lock", {
p_instance_id: instance.id,
p_lock_token: lockToken,
});
}
}
export async function runHealthCheckTick(
supabase: SupabaseLike,
): Promise<HealthTickSummary> {
const startedAt = Date.now();
const now = Date.now();
const { data: rows, error } = await supabase
.from("plesk_instances")
.select(
"id, agency_id, base_url, auth_type, encrypted_secret, health_enabled, health_check_frequency_minutes, health_next_check_at, health_lock_until",
)
.eq("health_enabled", true)
.order("health_next_check_at", { ascending: true, nullsFirst: true })
.limit(50);
if (error) {
throw new Error(error.message);
}
const eligible = ((rows ?? []) as HealthInstance[])
.filter((instance) => isDue(instance, now) && isUnlocked(instance, now))
.slice(0, MAX_INSTANCES_PER_TICK);
let checked = 0;
let ok = 0;
let degraded = 0;
let failed = 0;
const queue = [...eligible];
const workers = Array.from(
{ length: Math.min(MAX_CONCURRENCY, queue.length) },
async () => {
while (queue.length > 0) {
const instance = queue.shift();
if (!instance) break;
const result = await runInstanceHealthCheck(supabase, instance, {
actorUserId: null,
force: false,
source: "cron",
});
if (!result.checked || !result.status) continue;
checked += 1;
if (result.status === "ok") ok += 1;
else if (result.status === "degraded") degraded += 1;
else failed += 1;
}
},
);
await Promise.all(workers);
return {
checked,
ok,
degraded,
failed,
duration_ms: Date.now() - startedAt,
};
}

View File

@@ -11,7 +11,7 @@ export function getStripeClient() {
if (!stripeClient) { if (!stripeClient) {
stripeClient = new Stripe(env.stripeSecretKey, { stripeClient = new Stripe(env.stripeSecretKey, {
apiVersion: "2025-02-24.acacia", apiVersion: "2025-08-27.basil",
}); });
} }

View File

@@ -83,6 +83,22 @@ export type Database = {
last_sync_error: string | null; last_sync_error: string | null;
last_sync_subscriptions: number; last_sync_subscriptions: number;
last_sync_domains: number; last_sync_domains: number;
auto_sync_enabled: boolean;
auto_sync_frequency_minutes: number;
last_auto_sync_at: string | null;
next_auto_sync_at: string | null;
auto_sync_lock_until: string | null;
auto_sync_lock_token: string | null;
health_enabled: boolean;
health_status: "ok" | "degraded" | "down" | "unknown";
health_last_checked_at: string | null;
health_last_ok_at: string | null;
health_last_error: string | null;
health_last_latency_ms: number | null;
health_check_frequency_minutes: number;
health_next_check_at: string | null;
health_lock_until: string | null;
health_lock_token: string | null;
created_at: string; created_at: string;
}; };
Insert: { Insert: {
@@ -101,6 +117,22 @@ export type Database = {
last_sync_error?: string | null; last_sync_error?: string | null;
last_sync_subscriptions?: number; last_sync_subscriptions?: number;
last_sync_domains?: number; last_sync_domains?: number;
auto_sync_enabled?: boolean;
auto_sync_frequency_minutes?: number;
last_auto_sync_at?: string | null;
next_auto_sync_at?: string | null;
auto_sync_lock_until?: string | null;
auto_sync_lock_token?: string | null;
health_enabled?: boolean;
health_status?: "ok" | "degraded" | "down" | "unknown";
health_last_checked_at?: string | null;
health_last_ok_at?: string | null;
health_last_error?: string | null;
health_last_latency_ms?: number | null;
health_check_frequency_minutes?: number;
health_next_check_at?: string | null;
health_lock_until?: string | null;
health_lock_token?: string | null;
created_at?: string; created_at?: string;
}; };
Update: { Update: {
@@ -119,6 +151,22 @@ export type Database = {
last_sync_error?: string | null; last_sync_error?: string | null;
last_sync_subscriptions?: number; last_sync_subscriptions?: number;
last_sync_domains?: number; last_sync_domains?: number;
auto_sync_enabled?: boolean;
auto_sync_frequency_minutes?: number;
last_auto_sync_at?: string | null;
next_auto_sync_at?: string | null;
auto_sync_lock_until?: string | null;
auto_sync_lock_token?: string | null;
health_enabled?: boolean;
health_status?: "ok" | "degraded" | "down" | "unknown";
health_last_checked_at?: string | null;
health_last_ok_at?: string | null;
health_last_error?: string | null;
health_last_latency_ms?: number | null;
health_check_frequency_minutes?: number;
health_next_check_at?: string | null;
health_lock_until?: string | null;
health_lock_token?: string | null;
created_at?: string; created_at?: string;
}; };
}; };

24
scripts/deploy-prod.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
mkdir -p "${DEPLOY_PATH}"
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
echo "Missing .env in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build --force-recreate
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml ps

40
scripts/rollback-prod.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
PREVIOUS_IMAGE_TAG="${1:-}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_BASE_URL="${APP_BASE_URL:-}"
if [ -z "${PREVIOUS_IMAGE_TAG}" ]; then
echo "Usage: $0 <previous-image-tag>" >&2
exit 1
fi
if [ -z "${APP_BASE_URL}" ]; then
echo "APP_BASE_URL is required" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
echo "Missing .env in ${DEPLOY_PATH}" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/scripts/smoke-check.sh" ]; then
echo "Missing scripts/smoke-check.sh in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build --force-recreate
"${DEPLOY_PATH}/scripts/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps

36
scripts/smoke-check.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-}"
if [ -z "${BASE_URL}" ]; then
echo "Usage: $0 <base-url>" >&2
exit 1
fi
URL="${BASE_URL%/}/api/health"
TMP_FILE="$(mktemp)"
MAX_ATTEMPTS=20
SLEEP_SECONDS=3
trap 'rm -f "${TMP_FILE}"' EXIT
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}" || echo "000")
if [ "${HTTP_CODE}" = "200" ]; then
if node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(d?.ok!==true||typeof d?.service!=='string'){process.exit(1)}" "${TMP_FILE}"; then
echo "Smoke check passed: ${URL} (attempt ${attempt}/${MAX_ATTEMPTS})"
exit 0
fi
fi
if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then
sleep "${SLEEP_SECONDS}"
fi
done
echo "Smoke check failed after ${MAX_ATTEMPTS} attempts: ${URL}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1

View File

@@ -0,0 +1,78 @@
-- CL8: instance health monitoring fields + lock helpers
alter table public.plesk_instances
add column if not exists health_enabled boolean not null default true,
add column if not exists health_status text not null default 'unknown',
add column if not exists health_last_checked_at timestamptz,
add column if not exists health_last_ok_at timestamptz,
add column if not exists health_last_error text,
add column if not exists health_last_latency_ms integer,
add column if not exists health_check_frequency_minutes integer not null default 30,
add column if not exists health_next_check_at timestamptz,
add column if not exists health_lock_until timestamptz,
add column if not exists health_lock_token text;
alter table public.plesk_instances
drop constraint if exists plesk_instances_health_status_check;
alter table public.plesk_instances
add constraint plesk_instances_health_status_check
check (health_status in ('ok', 'degraded', 'down', 'unknown'));
alter table public.plesk_instances
drop constraint if exists plesk_instances_health_check_frequency_minutes_check;
alter table public.plesk_instances
add constraint plesk_instances_health_check_frequency_minutes_check
check (health_check_frequency_minutes between 5 and 10080);
create index if not exists idx_plesk_instances_health_due
on public.plesk_instances (health_enabled, health_next_check_at);
create or replace function public.acquire_instance_health_lock(
p_instance_id uuid,
p_lock_token text,
p_ttl_seconds integer default 300,
p_force boolean default false
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_acquired boolean := false;
begin
update public.plesk_instances
set
health_lock_until = now() + make_interval(secs => p_ttl_seconds),
health_lock_token = p_lock_token
where id = p_instance_id
and health_enabled = true
and (
p_force = true
or health_next_check_at is null
or health_next_check_at <= now()
)
and (health_lock_until is null or health_lock_until < now());
get diagnostics v_acquired = row_count;
return v_acquired;
end;
$$;
create or replace function public.release_instance_health_lock(
p_instance_id uuid,
p_lock_token text
)
returns void
language sql
security definer
set search_path = public
as $$
update public.plesk_instances
set health_lock_until = null,
health_lock_token = null
where id = p_instance_id
and health_lock_token = p_lock_token;
$$;

View File

@@ -0,0 +1,62 @@
-- CL6: auto-sync worker scheduling fields + per-instance lock helpers
alter table public.plesk_instances
add column if not exists auto_sync_enabled boolean not null default false,
add column if not exists auto_sync_frequency_minutes integer not null default 60,
add column if not exists last_auto_sync_at timestamptz,
add column if not exists next_auto_sync_at timestamptz,
add column if not exists auto_sync_lock_until timestamptz,
add column if not exists auto_sync_lock_token text;
alter table public.plesk_instances
drop constraint if exists plesk_instances_auto_sync_frequency_minutes_check;
alter table public.plesk_instances
add constraint plesk_instances_auto_sync_frequency_minutes_check
check (auto_sync_frequency_minutes between 5 and 10080);
create index if not exists idx_plesk_instances_auto_sync_due
on public.plesk_instances (auto_sync_enabled, next_auto_sync_at);
create or replace function public.acquire_instance_auto_sync_lock(
p_instance_id uuid,
p_lock_token text,
p_ttl_seconds integer default 900
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_acquired boolean := false;
begin
update public.plesk_instances
set
auto_sync_lock_until = now() + make_interval(secs => p_ttl_seconds),
auto_sync_lock_token = p_lock_token
where id = p_instance_id
and auto_sync_enabled = true
and (next_auto_sync_at is null or next_auto_sync_at <= now())
and (auto_sync_lock_until is null or auto_sync_lock_until < now());
get diagnostics v_acquired = row_count;
return v_acquired;
end;
$$;
create or replace function public.release_instance_auto_sync_lock(
p_instance_id uuid,
p_lock_token text
)
returns void
language sql
security definer
set search_path = public
as $$
update public.plesk_instances
set auto_sync_lock_until = null,
auto_sync_lock_token = null
where id = p_instance_id
and auto_sync_lock_token = p_lock_token;
$$;