Compare commits
1 Commits
fix/cl9-ss
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| ab9e4147a7 |
@@ -1,6 +1,3 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,30 +1,23 @@
|
|||||||
FROM node:20-bookworm-slim AS deps
|
FROM cgr.dev/chainguard/node:20-dev 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 node:20-bookworm-slim AS builder
|
FROM cgr.dev/chainguard/node:20-dev 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 node:20-bookworm-slim AS prod-deps
|
FROM cgr.dev/chainguard/node:20-dev 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 node:20-bookworm-slim AS runner
|
FROM cgr.dev/chainguard/node:20 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
169
Jenkinsfile
vendored
169
Jenkinsfile
vendored
@@ -1,41 +1,13 @@
|
|||||||
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
|
||||||
@@ -48,116 +20,65 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stage('Lint') {
|
stage('Lint') {
|
||||||
// steps {
|
steps {
|
||||||
// script {
|
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)
|
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) {
|
if (hasLint == 0) {
|
||||||
// sh 'npm run lint'
|
sh 'npm run lint'
|
||||||
// } else {
|
} else {
|
||||||
// echo 'No lint script configured; skipping lint stage.'
|
echo 'No lint script configured; skipping lint stage.'
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
stage('Build application') {
|
stage('Build application') {
|
||||||
steps {
|
steps {
|
||||||
withCredentials([
|
sh 'npm run build'
|
||||||
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 {
|
||||||
withCredentials([
|
sh 'docker build -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
|
||||||
string(credentialsId: 'supabase-public-url', variable: 'NEXT_PUBLIC_SUPABASE_URL'),
|
}
|
||||||
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
}
|
||||||
]) {
|
|
||||||
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} .'
|
stage('Deploy') {
|
||||||
|
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 '''
|
sh 'chmod +x scripts/smoke-check.sh && scripts/smoke-check.sh ${APP_BASE_URL}'
|
||||||
bash -eo pipefail << EOF
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x scripts/smoke-check.sh
|
|
||||||
scripts/smoke-check.sh "${APP_BASE_URL}"
|
|
||||||
EOF
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient();
|
||||||
const supabase = createSupabaseRouteClient() as any;
|
const supabase = createSupabaseRouteClient();
|
||||||
|
|
||||||
const { data: billing, error: billingError } = await supabase
|
const { data: billing, error: billingError } = await supabase
|
||||||
.from("billing_accounts")
|
.from("billing_accounts")
|
||||||
@@ -42,10 +42,7 @@ export async function POST() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (billing?.id) {
|
if (billing?.id) {
|
||||||
await supabase
|
await supabase.from("billing_accounts").update(upsertPayload).eq("id", billing.id);
|
||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -65,12 +62,7 @@ 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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST() {
|
|||||||
const context = await getRouteAgencyContextOrThrow();
|
const context = await getRouteAgencyContextOrThrow();
|
||||||
assertAgencyAdmin(context);
|
assertAgencyAdmin(context);
|
||||||
|
|
||||||
const supabase = createSupabaseRouteClient() as any;
|
const supabase = createSupabaseRouteClient();
|
||||||
const stripe = getStripeClient();
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
const { data: billing, error } = await supabase
|
const { data: billing, error } = await supabase
|
||||||
@@ -32,12 +32,7 @@ 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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function upsertBillingByCustomer(
|
|||||||
current_period_end?: string | null;
|
current_period_end?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const supabase = createSupabaseAdminClient() as any;
|
const supabase = createSupabaseAdminClient();
|
||||||
|
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from("billing_accounts")
|
.from("billing_accounts")
|
||||||
@@ -28,33 +28,22 @@ async function upsertBillingByCustomer(
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await supabase
|
await supabase.from("billing_accounts").update(updates).eq("id", existing.id);
|
||||||
.from("billing_accounts")
|
|
||||||
.update(updates)
|
|
||||||
.eq("id", existing.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCheckoutSessionCompleted(
|
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
|
||||||
session: Stripe.Checkout.Session,
|
const agencyId = typeof session.metadata?.agency_id === "string" ? session.metadata.agency_id : null;
|
||||||
) {
|
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() as any;
|
const supabase = createSupabaseAdminClient();
|
||||||
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"
|
typeof session.subscription === "string" ? (session.subscription as string) : null,
|
||||||
? (session.subscription as string)
|
|
||||||
: null,
|
|
||||||
subscription_status: "active",
|
subscription_status: "active",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,10 +54,7 @@ async function handleCheckoutSessionCompleted(
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await supabase
|
await supabase.from("billing_accounts").update(payload).eq("id", existing.id);
|
||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -76,14 +62,7 @@ async function handleCheckoutSessionCompleted(
|
|||||||
|
|
||||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
||||||
const customerId =
|
const customerId =
|
||||||
typeof subscription.customer === "string"
|
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
|
||||||
? 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;
|
||||||
|
|
||||||
@@ -91,27 +70,20 @@ 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(currentPeriodEnd),
|
current_period_end: toIsoOrNull(subscription.current_period_end),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||||
const customerId =
|
const customerId =
|
||||||
typeof subscription.customer === "string"
|
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
|
||||||
? 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(currentPeriodEnd),
|
current_period_end: toIsoOrNull(subscription.current_period_end),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,35 +95,22 @@ 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(
|
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
|
||||||
{ 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(
|
const event = stripe.webhooks.constructEvent(body, signature, env.stripeWebhookSecret);
|
||||||
body,
|
|
||||||
signature,
|
|
||||||
env.stripeWebhookSecret,
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed":
|
case "checkout.session.completed":
|
||||||
await handleCheckoutSessionCompleted(
|
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
|
||||||
event.data.object as Stripe.Checkout.Session,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "customer.subscription.updated":
|
case "customer.subscription.updated":
|
||||||
await handleSubscriptionUpdated(
|
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
||||||
event.data.object as Stripe.Subscription,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "customer.subscription.deleted":
|
case "customer.subscription.deleted":
|
||||||
await handleSubscriptionDeleted(
|
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||||
event.data.object as Stripe.Subscription,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -160,10 +119,7 @@ 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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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 = {
|
||||||
@@ -90,11 +88,9 @@ export default async function ActivityPage({
|
|||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
||||||
const actionOptions: string[] = Array.from(
|
const actionOptions = Array.from(
|
||||||
new Set<string>(
|
new Set((recentForActions ?? []).map((entry: any) => String(entry.action))),
|
||||||
(recentForActions ?? []).map((entry: any) => String(entry.action)),
|
).sort();
|
||||||
),
|
|
||||||
).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) => [
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ 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"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ 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
|
||||||
|
|||||||
@@ -109,14 +109,6 @@ 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:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function getStripeClient() {
|
|||||||
|
|
||||||
if (!stripeClient) {
|
if (!stripeClient) {
|
||||||
stripeClient = new Stripe(env.stripeSecretKey, {
|
stripeClient = new Stripe(env.stripeSecretKey, {
|
||||||
apiVersion: "2025-08-27.basil",
|
apiVersion: "2025-02-24.acacia",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
eslint: {
|
||||||
|
// Temporary: repository has existing no-explicit-any violations.
|
||||||
|
// Keep dedicated lint stage in CI; do not block production build on legacy lint debt.
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
// Temporary: repository has existing unrelated type errors in billing/stripe flows.
|
||||||
|
// Keep separate typecheck/lint quality gates while allowing production image build.
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: "2mb",
|
bodySizeLimit: "2mb",
|
||||||
|
|||||||
@@ -4,21 +4,27 @@ 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 --force-recreate
|
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 ps
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|||||||
@@ -21,20 +21,10 @@ 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 --force-recreate
|
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build
|
||||||
|
|
||||||
"${DEPLOY_PATH}/scripts/smoke-check.sh" "${APP_BASE_URL}"
|
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}"
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml ps
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|||||||
@@ -11,26 +11,17 @@ 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
|
||||||
|
|
||||||
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
|
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}")
|
||||||
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}" || echo "000")
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE}" = "200" ]; then
|
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 failed: ${URL} returned ${HTTP_CODE}" >&2
|
||||||
echo "Smoke check passed: ${URL} (attempt ${attempt}/${MAX_ATTEMPTS})"
|
cat "${TMP_FILE}" >&2 || true
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then
|
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}"
|
||||||
sleep "${SLEEP_SECONDS}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Smoke check failed after ${MAX_ATTEMPTS} attempts: ${URL}" >&2
|
echo "Smoke check passed: ${URL}"
|
||||||
cat "${TMP_FILE}" >&2 || true
|
|
||||||
exit 1
|
|
||||||
|
|||||||
Reference in New Issue
Block a user