diff --git a/README.md b/README.md index 31b4d71..0b4e999 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ For the full V2 spec see [DEVELOPER_BRIEF_V2.md](./DEVELOPER_BRIEF_V2.md). ## V1 archive -V1 source has been removed from `main`. It is preserved on the `v1-archive` -branch and the running deployment at `/opt/social-reporting` on the server. - -To roll back from V2 to V1: +V1 source is preserved on the `v1-archive` branch (frozen at the last V1 +commit) and is no longer kept on the deployed server. To roll back from +V2 to V1, the rollback script will re-clone `v1-archive` if needed: ```bash # On the server +export REPO_URL="https://x-token-auth:YOUR_TOKEN@bitbucket.org/zlalani/social-reporting-tool.git" bash /opt/social-reporting-v2/v2/deploy/rollback-to-v1.sh ``` diff --git a/v2/deploy/cutover-in-place.sh b/v2/deploy/cutover-in-place.sh new file mode 100755 index 0000000..a81f1b4 --- /dev/null +++ b/v2/deploy/cutover-in-place.sh @@ -0,0 +1,155 @@ +#!/bin/bash +set -euo pipefail + +# ═══════════════════════════════════════════════════════ +# Social Reporting V2 — In-place cutover +# Run from the existing V1 deployment directory: +# cd /opt/social-reporting && bash v2/deploy/cutover-in-place.sh +# (It will git pull first, so the v2/ tree appears.) +# +# What it does: +# 1. Stops V1 docker stack (so V1's compose file is freed BEFORE git pull deletes it). +# 2. git pull origin main — drops V1 dirs, adds v2/. +# 3. Migrates secrets from /opt/social-reporting/.env into v2/.env (preserves your +# APIFY_TOKEN, ANTHROPIC_API_KEY, AZURE_*, etc.; generates a new SESSION_SECRET). +# 4. Swaps the Apache conf to V2's, reloads. +# 5. Builds + starts V2 docker stack. +# ═══════════════════════════════════════════════════════ + +DIR="$(pwd)" +APACHE_CONF="/etc/apache2/conf-available/social-reports.conf" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[+]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +err() { echo -e "${RED}[x]${NC} $1"; exit 1; } + +[[ -d "$DIR/.git" ]] || err "Run this from the deployment dir (e.g. /opt/social-reporting). $DIR has no .git." +command -v docker >/dev/null || err "Docker not installed" +command -v apache2ctl >/dev/null || err "Apache not installed" + +# ─── Sanity-confirm ─── +warn "Cutover plan: stop V1 stack → git pull (V1 dirs deleted, v2/ appears) → swap Apache → start V2." +warn "Working dir: $DIR" +read -r -p "Proceed? [y/N] " ans +[[ "$ans" != "y" && "$ans" != "Y" ]] && err "Aborted" + +# ─── 1. Stop V1 BEFORE git pull (the pull deletes V1's docker-compose.yml) ─── +if [[ -f "$DIR/docker-compose.yml" ]]; then + log "Stopping V1 stack..." + docker compose -p social-listening down 2>/dev/null || warn "V1 was not running" +fi + +# Snapshot V1's .env so we can migrate values after the pull. +V1_ENV_TMP="" +if [[ -f "$DIR/.env" ]]; then + V1_ENV_TMP="$(mktemp)" + cp "$DIR/.env" "$V1_ENV_TMP" + log "Snapshotted V1 .env to $V1_ENV_TMP" +fi + +# ─── 2. git pull main (this removes V1 source, adds v2/) ─── +log "Pulling main..." +git pull origin main || err "git pull failed" +[[ -d "$DIR/v2" ]] || err "After pull, v2/ directory still missing — main may not be the V2 branch" + +cd "$DIR/v2" + +# ─── 3. Migrate secrets to v2/.env ─── +get_old() { [[ -n "$V1_ENV_TMP" && -f "$V1_ENV_TMP" ]] && grep "^$1=" "$V1_ENV_TMP" | head -1 | cut -d= -f2- || true; } +set_new() { + local key="$1" val="$2" + [[ -z "$val" ]] && return 0 + if grep -q "^${key}=" .env 2>/dev/null; then + sed -i.bak "s|^${key}=.*|${key}=${val}|" .env && rm -f .env.bak + else + echo "${key}=${val}" >> .env + fi +} + +if [[ ! -f "$DIR/v2/.env" ]]; then + log "Creating v2/.env from .env.example..." + cp .env.example .env +fi + +# Always-fresh SESSION_SECRET (V1's was tied to V1's HMAC; cutting over invalidates anyway) +set_new SESSION_SECRET "$(openssl rand -hex 32)" + +# Migrate secrets from V1 .env if present +for key in APIFY_TOKEN ANTHROPIC_API_KEY AZURE_TENANT_ID AZURE_CLIENT_ID DASH_USER DASH_PASS APIFY_LIVE_APPROVED ALLOWED_ORIGIN; do + val="$(get_old $key)" + [[ -n "$val" ]] && set_new "$key" "$val" +done + +# Generate a DB password if none present +if ! grep -q '^DB_V2_PASSWORD=.\+' .env; then + DB_PW="$(openssl rand -hex 16)" + set_new DB_V2_PASSWORD "$DB_PW" + set_new DATABASE_URL "postgresql://srv2_user:${DB_PW}@db-v2:5432/social_reporting_v2" +fi + +# Production knobs +set_new NODE_ENV production +set_new ALLOW_PASSWORD_FALLBACK false + +# Force one VITE_AZURE_* surfacing — vite needs them at build time +TENANT="$(grep '^AZURE_TENANT_ID=' .env | head -1 | cut -d= -f2-)" +CLIENT="$(grep '^AZURE_CLIENT_ID=' .env | head -1 | cut -d= -f2-)" +set_new VITE_AZURE_TENANT_ID "$TENANT" +set_new VITE_AZURE_CLIENT_ID "$CLIENT" + +if ! grep -q '^BOOTSTRAP_SUPER_ADMIN_EMAIL=.\+' .env; then + warn "BOOTSTRAP_SUPER_ADMIN_EMAIL is not set in v2/.env." + read -r -p "Email of first super-admin (must match the SSO sign-in): " admin_email + set_new BOOTSTRAP_SUPER_ADMIN_EMAIL "$admin_email" +fi + +# Cleanup +[[ -n "$V1_ENV_TMP" && -f "$V1_ENV_TMP" ]] && rm -f "$V1_ENV_TMP" + +log "v2/.env populated. Review with: less $DIR/v2/.env" + +# ─── 4. Apache: swap conf to V2's ─── +log "Swapping Apache config to V2..." +[[ -f "$APACHE_CONF" ]] && sudo cp "$APACHE_CONF" "${APACHE_CONF}.v1.bak.$(date +%s)" +sudo cp "$DIR/v2/deploy/apache-social-reports-v2.conf" "$APACHE_CONF" +for mod in proxy proxy_http headers rewrite; do + apache2ctl -M 2>/dev/null | grep -q "${mod}_module" || sudo a2enmod "$mod" +done +sudo a2enconf social-reports >/dev/null 2>&1 || true +sudo apache2ctl configtest || err "Apache config test failed" + +# ─── 5. Build + start V2 ─── +log "Building & starting V2 stack..." +docker compose -f docker-compose.v2.yml -f docker-compose.v2.prod.yml --env-file .env up -d --build + +log "Waiting for V2 backend (port 3457)..." +for i in {1..40}; do + curl -sf http://127.0.0.1:3457/api/health >/dev/null 2>&1 && { log "V2 healthy"; break; } + [ "$i" -eq 40 ] && err "V2 not responding — docker compose -p social-reporting-v2 logs app-v2" + sleep 2 +done + +log "Reloading Apache..." +sudo systemctl reload apache2 + +# ─── Optional: clean up V1 docker volume ─── +if docker volume ls --format '{{.Name}}' | grep -q '^social-listening_pgdata$'; then + warn "V1 docker volume 'social-listening_pgdata' is orphaned (V1 docker-compose.yml is gone after the pull)." + read -r -p "Remove V1 db volume too? [y/N] " yn + if [[ "$yn" == "y" || "$yn" == "Y" ]]; then + docker volume rm social-listening_pgdata && log "V1 db volume removed." + fi +fi + +echo "" +echo "════════════════════════════════════════════════════" +echo -e " ${GREEN}V2 in-place cutover done.${NC}" +echo " URL: https://optical-dev.oliver.solutions/social-reports/" +echo " Backend: 127.0.0.1:3457" +echo " Dir: $DIR (v2/ subdirectory)" +echo " Logs: docker compose -p social-reporting-v2 logs -f app-v2" +echo "" +echo " First super-admin sign-in:" +grep '^BOOTSTRAP_SUPER_ADMIN_EMAIL=' v2/.env || echo " (set BOOTSTRAP_SUPER_ADMIN_EMAIL in v2/.env)" +echo "════════════════════════════════════════════════════" diff --git a/v2/deploy/rollback-to-v1.sh b/v2/deploy/rollback-to-v1.sh index e01d314..eab1788 100755 --- a/v2/deploy/rollback-to-v1.sh +++ b/v2/deploy/rollback-to-v1.sh @@ -2,8 +2,12 @@ set -euo pipefail # Roll back from V2 → V1 at the /social-reports URL. -# V1 source must still be present at /opt/social-reporting (we don't delete it during cutover). +# +# V1 may or may not still be on disk: +# - If /opt/social-reporting/.git exists, we use it. +# - Otherwise, we re-clone the v1-archive branch (REPO_URL must be set). +REPO_URL="${REPO_URL:-}" BACKEND_DIR_V1="/opt/social-reporting" BACKEND_DIR_V2="/opt/social-reporting-v2" APACHE_CONF="/etc/apache2/conf-available/social-reports.conf" @@ -13,7 +17,13 @@ log() { echo -e "${GREEN}[+]${NC} $1"; } warn() { echo -e "${YELLOW}[!]${NC} $1"; } err() { echo -e "${RED}[x]${NC} $1"; exit 1; } -[[ -d "$BACKEND_DIR_V1/.git" ]] || err "V1 source not found at $BACKEND_DIR_V1 — cannot roll back" +if [[ ! -d "$BACKEND_DIR_V1/.git" ]]; then + [[ -z "$REPO_URL" ]] && err "V1 source not on disk and REPO_URL not set. Export REPO_URL and re-run." + warn "V1 source not on disk; cloning v1-archive branch..." + sudo mkdir -p "$BACKEND_DIR_V1" + sudo chown "$(whoami):$(whoami)" "$BACKEND_DIR_V1" + git clone -b v1-archive "$REPO_URL" "$BACKEND_DIR_V1" +fi warn "About to roll back /social-reports from V2 → V1." read -r -p "Proceed? [y/N] " ans @@ -29,12 +39,13 @@ sudo apache2ctl configtest || err "Apache config test failed" log "Starting V1 stack..." cd "$BACKEND_DIR_V1" -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +[[ -f .env ]] || { warn "V1 .env missing — copy from a backup or recreate before running again"; err "Aborting before docker up"; } +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build log "Waiting for V1..." -for i in {1..20}; do +for i in {1..30}; do curl -sf http://127.0.0.1:3456/status >/dev/null 2>&1 && { log "V1 healthy"; break; } - [ "$i" -eq 20 ] && err "V1 not responding — docker compose logs social-listening" + [ "$i" -eq 30 ] && err "V1 not responding — docker compose logs social-listening" sleep 2 done diff --git a/v2/deploy/setup-v2.sh b/v2/deploy/setup-v2.sh index b9cf635..93df10f 100755 --- a/v2/deploy/setup-v2.sh +++ b/v2/deploy/setup-v2.sh @@ -4,13 +4,15 @@ set -euo pipefail # ═══════════════════════════════════════════════════════ # Social Reporting V2 — Server Setup (one-time) # Target: optical-dev.oliver.solutions -# Cuts over from V1 at the same URL. V1 source kept on disk for rollback. +# Replaces V1 at the same URL. V1 is removed from the server; rollback re-clones +# the v1-archive branch (see rollback-to-v1.sh). # ═══════════════════════════════════════════════════════ REPO_URL="${REPO_URL:-}" BACKEND_DIR_V2="/opt/social-reporting-v2" BACKEND_DIR_V1="/opt/social-reporting" APACHE_CONF="/etc/apache2/conf-available/social-reports.conf" +PURGE_V1="${PURGE_V1:-}" # set to 'true' to delete /opt/social-reporting after V2 is healthy GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' log() { echo -e "${GREEN}[+]${NC} $1"; } @@ -42,17 +44,15 @@ if [[ ! -f "$BACKEND_DIR_V2/v2/.env" ]]; then warn "Edit $BACKEND_DIR_V2/v2/.env: APIFY_TOKEN, ANTHROPIC_API_KEY, AZURE_*, BOOTSTRAP_SUPER_ADMIN_EMAIL" fi -# ─── Cutover from V1 (graceful) ─── -warn "About to cut over the /social-reports URL from V1 → V2." -warn "V1 source remains at $BACKEND_DIR_V1 (untouched). Rollback: deploy/rollback-to-v1.sh" +# ─── Cutover ─── +warn "About to take over the /social-reports URL with V2." read -r -p "Proceed? [y/N] " ans [[ "$ans" != "y" && "$ans" != "Y" ]] && err "Aborted" +# Stop V1 stack if it's running (no-op if V1 was never deployed here). if [[ -d "$BACKEND_DIR_V1" ]]; then - log "Stopping V1 stack..." - cd "$BACKEND_DIR_V1" - docker compose -p social-listening down || warn "V1 was not running" - cd "$BACKEND_DIR_V2" + log "Stopping V1 stack (if running)..." + (cd "$BACKEND_DIR_V1" && docker compose -p social-listening down 2>/dev/null) || warn "V1 was not running" fi # ─── Apache: swap conf to V2 ─── @@ -80,12 +80,25 @@ done log "Reloading Apache..." sudo systemctl reload apache2 +# ─── Optional V1 purge ─── +if [[ "$PURGE_V1" == "true" && -d "$BACKEND_DIR_V1" ]]; then + warn "PURGE_V1=true — removing $BACKEND_DIR_V1 and the V1 docker volume" + docker volume rm social-listening_pgdata 2>/dev/null || warn "(V1 db volume already gone)" + sudo rm -rf "$BACKEND_DIR_V1" + log "V1 source and db volume removed. Rollback now re-clones the v1-archive branch." +fi + echo "" echo "════════════════════════════════════════════════════" echo -e " ${GREEN}V2 deployed!${NC}" echo " URL: https://optical-dev.oliver.solutions/social-reports/" echo " Backend: http://127.0.0.1:3457 (Docker)" echo " V2 dir: $BACKEND_DIR_V2" -echo " V1 dir: $BACKEND_DIR_V1 (kept for rollback)" +if [[ "$PURGE_V1" == "true" ]]; then +echo " V1: purged" +echo " Rollback: git checkout v1-archive on a new clone, then run V1's setup.sh" +else +echo " V1 dir: $BACKEND_DIR_V1 (still on disk; remove with PURGE_V1=true on next run)" echo " Rollback: bash $BACKEND_DIR_V2/v2/deploy/rollback-to-v1.sh" +fi echo "════════════════════════════════════════════════════" diff --git a/v2/operator-app/src/App.tsx b/v2/operator-app/src/App.tsx index 8124259..7c6cb9e 100644 --- a/v2/operator-app/src/App.tsx +++ b/v2/operator-app/src/App.tsx @@ -16,6 +16,8 @@ export default function App() { return ( } /> + {/* alias matches the V1 Azure-registered redirect URI (.../social-reports/login.html) */} + } /> diff --git a/v2/operator-app/src/api/client.ts b/v2/operator-app/src/api/client.ts index e85dd0e..1affc6a 100644 --- a/v2/operator-app/src/api/client.ts +++ b/v2/operator-app/src/api/client.ts @@ -12,8 +12,13 @@ export class ApiError extends Error { } } +// API base mirrors the Vite `base` (e.g. `/social-reports/`) so requests resolve +// to the Apache-proxied backend rather than the bare origin. +const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); + export async function fetcher(path: string, init?: RequestInit): Promise { - const url = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? path : `/${path}`}`; + const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? path : `/${path}`}`; + const url = `${BASE}${apiPath}`; const res = await fetch(url, { credentials: 'include', headers: { diff --git a/v2/operator-app/src/auth/msal.ts b/v2/operator-app/src/auth/msal.ts index fd00de6..d354bff 100644 --- a/v2/operator-app/src/auth/msal.ts +++ b/v2/operator-app/src/auth/msal.ts @@ -13,7 +13,8 @@ async function ensureMsalLoaded(): Promise { if (window.msal) return; await new Promise((resolve, reject) => { const s = document.createElement('script'); - s.src = '/msal-browser.min.js'; + const base = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/'); + s.src = `${base}msal-browser.min.js`; s.async = true; s.onload = () => resolve(); s.onerror = () => reject(new Error('Failed to load msal-browser.min.js')); @@ -27,11 +28,16 @@ export async function getMsal() { if (!tenantId || !clientId) { throw new Error('Missing VITE_AZURE_TENANT_ID or VITE_AZURE_CLIENT_ID'); } + // Match the Azure-registered redirect URI from V1: + // https://optical-dev.oliver.solutions/social-reports/login.html + // BASE_URL is '/social-reports/' in prod, '/' in dev. + const base = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/'); + const redirectUri = `${window.location.origin}${base}login.html`; pca = new window.msal.PublicClientApplication({ auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}`, - redirectUri: window.location.origin + '/login', + redirectUri, }, cache: { cacheLocation: 'sessionStorage' }, }); @@ -48,7 +54,8 @@ export async function handleRedirectAndExchange(): Promise<{ ok: boolean } | nul const app = await getMsal(); const result = await app.handleRedirectPromise(); if (!result?.idToken) return null; - const res = await fetch('/api/sso/token-exchange', { + const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); + const res = await fetch(`${base}/api/sso/token-exchange`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, diff --git a/v2/operator-app/src/main.tsx b/v2/operator-app/src/main.tsx index 79dec8e..4566810 100644 --- a/v2/operator-app/src/main.tsx +++ b/v2/operator-app/src/main.tsx @@ -9,7 +9,7 @@ import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/v2/operator-app/vite.config.ts b/v2/operator-app/vite.config.ts index 9d9460a..c8c09fe 100644 --- a/v2/operator-app/vite.config.ts +++ b/v2/operator-app/vite.config.ts @@ -1,15 +1,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// V2 ships behind Apache at /social-reports/ — same external URL V1 used. The base +// here makes built asset URLs resolve under that prefix; pair this with React +// Router's basename + apiFetch's prefix. +// +// Override via VITE_BASE in .env to e.g. '/' for local dev or a different path. +const base = process.env.VITE_BASE ?? '/social-reports/'; + export default defineConfig({ + base, plugins: [react()], server: { port: 5173, proxy: { - '/api': { - target: 'http://localhost:3457', - changeOrigin: true, - }, + '/api': { target: 'http://localhost:3457', changeOrigin: true }, + '/social-reports': { target: 'http://localhost:3457', changeOrigin: true }, }, }, build: {