Compare commits

...

51 Commits

Author SHA1 Message Date
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
23 changed files with 779 additions and 91 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

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

View File

@@ -1,23 +1,30 @@
FROM cgr.dev/chainguard/node:20-dev AS deps FROM node:20-bookworm-slim AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
FROM cgr.dev/chainguard/node:20-dev AS builder FROM node:20-bookworm-slim AS builder
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
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 BUILD_CACHE_BUSTER=${BUILD_CACHE_BUSTER}
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM cgr.dev/chainguard/node:20-dev AS prod-deps FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM cgr.dev/chainguard/node:20 AS runner FROM node:20-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production

145
Jenkinsfile vendored
View File

@@ -1,13 +1,41 @@
pipeline { 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 agent any
environment { environment {
REGISTRY_IMAGE = "plesk-agency-portal" REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest" IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}" 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 ?: 'http://192.168.68.89:3000'}"
} }
stages { 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') { stage('Checkout') {
steps { steps {
checkout scm checkout scm
@@ -35,50 +63,101 @@ pipeline {
stage('Build application') { stage('Build application') {
steps { steps {
sh 'npm run build' withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'NEXT_PUBLIC_SUPABASE_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh 'npm run build'
}
} }
} }
stage('Build Docker image') { stage('Build Docker image') {
steps { steps {
sh 'docker build -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .' withCredentials([
} string(credentialsId: 'supabase-public-url', variable: 'NEXT_PUBLIC_SUPABASE_URL'),
} string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
stage('Deploy') { sh 'docker build --build-arg NEXT_PUBLIC_SUPABASE_URL="$NEXT_PUBLIC_SUPABASE_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} .'
steps {
script {
if (!env.DEPLOY_SSH_CREDENTIAL_ID) {
error 'DEPLOY_SSH_CREDENTIAL_ID is required'
}
}
sshagent(credentials: ["${env.DEPLOY_SSH_CREDENTIAL_ID}"]) {
sh '''
set -euo pipefail
: "${DEPLOY_SSH_HOST:?Missing DEPLOY_SSH_HOST}"
: "${DEPLOY_SSH_USER:?Missing DEPLOY_SSH_USER}"
: "${DEPLOY_PATH:?Missing DEPLOY_PATH}"
rsync -az \
docker-compose.prod.yml \
scripts/deploy-prod.sh \
scripts/smoke-check.sh \
scripts/rollback-prod.sh \
${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:${DEPLOY_PATH}/
docker save ${IMAGE_LATEST} ${IMAGE_BUILD} \
| ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} 'docker load'
ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} \
"chmod +x ${DEPLOY_PATH}/deploy-prod.sh ${DEPLOY_PATH}/smoke-check.sh ${DEPLOY_PATH}/rollback-prod.sh && APP_IMAGE='${IMAGE_BUILD}' DEPLOY_PATH='${DEPLOY_PATH}' ${DEPLOY_PATH}/deploy-prod.sh"
'''
} }
} }
} }
stage('Deploy') {
steps {
sh '''
bash <<'EOF'
set -euxo pipefail
SSH_KEY="/var/lib/jenkins/.ssh/plesk_agency_deploy"
SSH_USER="deploy"
SSH_OPTS="-i ${SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
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 "ssh -n ${SSH_OPTS}" \
docker-compose.prod.yml \
"$SSH_USER@$APP_HOST:$DEPLOY_PATH/"
rsync -az \
-e "ssh -n ${SSH_OPTS}" \
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') { stage('Smoke check') {
steps { steps {
sh 'chmod +x scripts/smoke-check.sh && scripts/smoke-check.sh ${APP_BASE_URL}' sh '''
bash -eo pipefail << EOF
set -euo pipefail
chmod +x scripts/smoke-check.sh
scripts/smoke-check.sh "${APP_BASE_URL}"
EOF
'''
} }
} }
} }

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

@@ -4,6 +4,8 @@ import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import { formatDateTime } from "@/lib/dates"; import { formatDateTime } from "@/lib/dates";
export const dynamic = "force-dynamic";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
type SearchParams = { type SearchParams = {
@@ -88,9 +90,11 @@ export default async function ActivityPage({
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(200); .limit(200);
const actionOptions = Array.from( const actionOptions: string[] = Array.from(
new Set((recentForActions ?? []).map((entry: any) => String(entry.action))), new Set<string>(
).sort(); (recentForActions ?? []).map((entry: any) => String(entry.action)),
),
).sort((a, b) => a.localeCompare(b));
const instanceNameById = new Map<string, string>( const instanceNameById = new Map<string, string>(
(instances ?? []).map((instance: any) => [ (instances ?? []).map((instance: any) => [

View File

@@ -6,6 +6,8 @@ import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link"; 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"],
"id" | "name" "id" | "name"

View File

@@ -2,9 +2,6 @@ services:
web: web:
container_name: plesk-agency-portal container_name: plesk-agency-portal
image: ${APP_IMAGE:-plesk-agency-portal:latest} image: ${APP_IMAGE:-plesk-agency-portal:latest}
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env

38
docs/GUARDRAILS.md Normal file
View File

@@ -0,0 +1,38 @@
# GUARDRAILS
These systems are currently working and must not be broken by AI refactors.
## CI/CD
Jenkins pipeline builds Docker image and deploys it.
Deployment process:
docker save → scp → docker load
## Container
Name:
plesk-agency-portal
Deploy path:
/opt/plesk-agency-portal
## Reverse Proxy
Managed by Nginx Proxy Manager
Public URL:
https://portal.rdbcloud.co.uk
## Auth
Supabase Magic Link authentication
Environment variables:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_URL
AI tools must avoid large rewrites or architectural changes without explicit instruction.

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

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

@@ -109,6 +109,14 @@ Configure these values in Jenkins job/folder/global environment or credentials-b
The pipeline assumes Jenkins credentials are managed outside this repository. 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 ## Deploy flow
Deployment is intentionally simple and bash-based: Deployment is intentionally simple and bash-based:

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

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

@@ -4,27 +4,21 @@ set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}" DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}" APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
SOURCE_PATH="${SOURCE_PATH:-${DEPLOY_PATH}}"
mkdir -p "${DEPLOY_PATH}" mkdir -p "${DEPLOY_PATH}"
for file in docker-compose.prod.yml deploy-prod.sh smoke-check.sh rollback-prod.sh; do
if [ -f "${SOURCE_PATH}/${file}" ]; then
cp "${SOURCE_PATH}/${file}" "${DEPLOY_PATH}/${file}"
fi
done
chmod +x "${DEPLOY_PATH}/deploy-prod.sh" \
"${DEPLOY_PATH}/smoke-check.sh" \
"${DEPLOY_PATH}/rollback-prod.sh" 2>/dev/null || true
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2 echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1 exit 1
fi fi
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
echo "Missing .env in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}" cd "${DEPLOY_PATH}"
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build --force-recreate
docker compose -f docker-compose.prod.yml ps APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml ps

View File

@@ -21,10 +21,20 @@ if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
exit 1 exit 1
fi 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}" cd "${DEPLOY_PATH}"
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build --force-recreate
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}" "${DEPLOY_PATH}/scripts/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps docker compose -f docker-compose.prod.yml ps

View File

@@ -11,17 +11,26 @@ fi
URL="${BASE_URL%/}/api/health" URL="${BASE_URL%/}/api/health"
TMP_FILE="$(mktemp)" TMP_FILE="$(mktemp)"
MAX_ATTEMPTS=20
SLEEP_SECONDS=3
trap 'rm -f "${TMP_FILE}"' EXIT trap 'rm -f "${TMP_FILE}"' EXIT
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}") 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 [ "${HTTP_CODE}" = "200" ]; then
echo "Smoke check failed: ${URL} returned ${HTTP_CODE}" >&2 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
cat "${TMP_FILE}" >&2 || true echo "Smoke check passed: ${URL} (attempt ${attempt}/${MAX_ATTEMPTS})"
exit 1 exit 0
fi fi
fi
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}" if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then
sleep "${SLEEP_SECONDS}"
fi
done
echo "Smoke check passed: ${URL}" echo "Smoke check failed after ${MAX_ATTEMPTS} attempts: ${URL}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1