Four phases shipped together. Each is a logical deploy unit on its own;
keeping the diff atomic so the rename runbook + migrations stay aligned.
Phase 1 — restore HP's formal review workflow
- Prisma: FeedbackItem, ReviewSession, ReviewSessionItem + enums
- New ApprovalType (NONE | SIMPLE | FORMAL) on PipelineStageDefinition
and PipelineStageTemplate. Stage row UI branches per type.
- feedback-service + review-session-service ported from HP (no ColorProbe)
- annotation-service auto-creates a FeedbackItem; revision-service
carries forward unresolved action items into the new revision.
- API: /api/reviews/*, /api/stages/[id]/feedback, /api/feedback/[id]
- Hooks: use-feedback, use-review-sessions
- UI: feedback-checklist, feedback-item-card, feedback-progress-bar,
create-session-dialog, session-builder, session-presenter,
session-summary, plus a new stage-review-panel
- Pages: /reviews list + detail, deliverable annotation review page
- Pipeline editor gets the approvalType select; sidebar gets Reviews
Phase 2 — full Dow Jones → L'Oréal rebrand + slug rename
- URL slug /dow-prod-tracker → /loreal-prod-tracker (next.config,
base path, redirects)
- docker-compose name + DB → loreal_prod_tracker; server path
/opt/loreal-prod-tracker; apache template renamed
- All visible strings → L'Oréal; sidebar bg #002B5C → black
- docs/RENAME_RUNBOOK.md describes the one-shot server migration
- Internal modules dow-excel-service/dow-import + OMG webhook domain
dowjones.com deliberately preserved (orthogonal to the rebrand)
Phase 3 — external /api/v1 for projects + deliverables
- API-key auth already in middleware; finished idempotency support
via new IdempotencyRecord model + src/lib/api/idempotency.ts
- Default-pipeline fallback in createProject when no template id given
- POST/GET /api/v1/projects + POST /api/v1/projects/[id]/deliverables
- docs/EXTERNAL_API.md with curl examples
Phase 4 — Box bidirectional integration
- JWT app-auth via jose (no extra deps). Config mounted as a docker
compose secret; deploy.sh stubs an empty {} so compose can start
before the operator drops the real JSON.
- Outbound: pushDeliverableToBox auto-fires on !APPROVED → APPROVED
in deliverable-status-service; "Send to client (Box)" manual button
on the approval stage row. Folder naming
{omgJobNumber}_{slug}_v{round}. 3-attempt exp backoff. BoxPushLog
audit.
- Inbound: /api/webhooks/box receives Box's signed events, matches by
OMG # + slug, creates a new Revision, routes to assignee or notifies
project owner. BoxInboundLog audit + two new NotificationType
values (BOX_UNMATCHED_FILE, NEW_FILE_AWAITING_REVIEWER).
- Naming-convention logic isolated in external-delivery-service so an
OMG-API transport can swap in later without touching matchers.
- Admin /settings/box page surfaces config status + recent activity.
Three Prisma migrations to apply on next deploy:
20260512000000_restore_review_workflow
20260512100000_idempotency_records
20260512200000_box_integration
URL rename is a one-shot — see docs/RENAME_RUNBOOK.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
366 lines
18 KiB
Bash
366 lines
18 KiB
Bash
#!/usr/bin/env bash
|
|
# deploy.sh — idempotent deploy script for loreal-prod-tracker
|
|
# Idempotent — safe to run multiple times (initial deploy or update)
|
|
# Run as normal user; uses sudo internally for apt/apache/ufw
|
|
set -euo pipefail
|
|
|
|
# ── Colours ──────────────────────────────────────────────────────────────────
|
|
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
|
|
info() { echo -e "${GREEN}[deploy]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
|
|
error() { echo -e "${RED}[error]${NC} $*" >&2; }
|
|
|
|
# ── Args ─────────────────────────────────────────────────────────────────────
|
|
SKIP_PULL=false
|
|
for arg in "$@"; do [[ "$arg" == "--skip-pull" ]] && SKIP_PULL=true; done
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
# Preferred host ports. If either is in use on the host, we auto-pick the
|
|
# next free one (see find_free_port below) and update the Apache conf +
|
|
# docker-compose env vars to match. Callers can override explicitly by
|
|
# exporting APP_HOST_PORT / DB_HOST_PORT before running this script.
|
|
PREFERRED_APP_PORT=3002
|
|
PREFERRED_DB_PORT=5492
|
|
|
|
# Compose project name — belt-and-braces with the `name:` field in
|
|
# docker-compose.yml. CLAUDE.md rule: the shared optical-dev server runs
|
|
# multiple apps from deploy dirs and Compose defaults the project name to
|
|
# the parent directory, so without this they collide on containers
|
|
# (loreal-prod-tracker-db-1 vs hp-prod-tracker-db-1) and volumes.
|
|
COMPOSE_PROJECT=loreal-prod-tracker
|
|
|
|
# ── Port helpers ────────────────────────────────────────────────────────────
|
|
# is_port_free uses bash's /dev/tcp — no external tool needed, works on
|
|
# Linux and macOS. If opening a TCP connection to localhost:PORT succeeds,
|
|
# something is listening (port busy). If it fails, port is free.
|
|
is_port_free() {
|
|
local p=$1
|
|
(: </dev/tcp/127.0.0.1/"$p") >/dev/null 2>&1 && return 1 || return 0
|
|
}
|
|
|
|
# Find the lowest free port >= start, scanning up to 50 candidates.
|
|
# Our own stopped containers don't count as "in use" — we `docker compose
|
|
# down` before this runs, so a previous deploy's port is freed.
|
|
find_free_port() {
|
|
local start=$1
|
|
local p
|
|
for ((p=start; p<start+50; p++)); do
|
|
if is_port_free "$p"; then
|
|
echo "$p"; return 0
|
|
fi
|
|
done
|
|
error " No free port in range ${start}..$((start+49))"
|
|
return 1
|
|
}
|
|
|
|
# ── Sudo check ───────────────────────────────────────────────────────────────
|
|
if ! sudo -n true 2>/dev/null; then
|
|
warn "This script needs sudo for apt/apache/ufw — you may be prompted for your password."
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 1: Prerequisites
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 1: Checking prerequisites..."
|
|
|
|
install_if_missing() {
|
|
local cmd=$1 pkg=${2:-$1}
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
info " Installing $pkg..."
|
|
sudo apt-get install -y "$pkg" -qq
|
|
else
|
|
info " $cmd: OK"
|
|
fi
|
|
}
|
|
|
|
sudo apt-get update -qq
|
|
|
|
install_if_missing docker docker.io
|
|
install_if_missing git git
|
|
install_if_missing curl curl
|
|
install_if_missing ufw ufw
|
|
|
|
if ! docker compose version &>/dev/null 2>&1; then
|
|
info " Installing docker-compose-plugin..."
|
|
sudo apt-get install -y docker-compose-plugin -qq
|
|
fi
|
|
|
|
if ! command -v apache2 &>/dev/null; then
|
|
info " Installing apache2..."
|
|
sudo apt-get install -y apache2 -qq
|
|
fi
|
|
|
|
info " Enabling Apache modules..."
|
|
sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite -q
|
|
|
|
# Ensure current user can run docker without sudo
|
|
if ! groups | grep -q docker; then
|
|
sudo usermod -aG docker "$USER"
|
|
warn "Added $USER to docker group — you may need to re-login for this to take effect"
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 2: Pull latest code
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 2: Pulling latest code..."
|
|
|
|
if [[ "$SKIP_PULL" == true ]]; then
|
|
warn " Skipping git pull (--skip-pull)"
|
|
else
|
|
# Switch to SSH remote if still on HTTPS (avoids credential prompts)
|
|
current_remote=$(git remote get-url origin 2>/dev/null || true)
|
|
if [[ "$current_remote" == https://* ]]; then
|
|
ssh_remote=$(echo "$current_remote" \
|
|
| sed 's|https://bitbucket.org/|git@bitbucket.org:|' \
|
|
| sed 's|\.git$||')
|
|
git remote set-url origin "${ssh_remote}.git"
|
|
info " Switched remote to SSH: $(git remote get-url origin)"
|
|
fi
|
|
|
|
git fetch origin
|
|
LOCAL=$(git rev-parse HEAD)
|
|
REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "")
|
|
if [[ "$LOCAL" == "$REMOTE" ]]; then
|
|
info " Already up to date ($(git rev-parse --short HEAD))"
|
|
else
|
|
git pull --ff-only || { error "git pull failed — resolve conflicts manually then re-run"; exit 1; }
|
|
info " Updated to $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)"
|
|
fi
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 3: Environment file
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 3: Checking .env..."
|
|
|
|
if [[ ! -f .env ]]; then
|
|
if [[ -f .env.example ]]; then
|
|
cp .env.example .env
|
|
warn " .env was missing — copied from .env.example"
|
|
warn " Edit .env with real secrets before continuing (Ctrl-C to abort)"
|
|
read -rp " Press Enter to continue or Ctrl-C to abort..."
|
|
else
|
|
error " .env and .env.example both missing — cannot continue"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Warn on unset critical vars
|
|
for var in AUTH_SECRET AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_REDIRECT_URI DB_PASSWORD; do
|
|
val=$(grep -E "^${var}=" .env | cut -d= -f2- | tr -d '"' || true)
|
|
[[ -z "$val" ]] && warn " WARNING: $var is not set in .env"
|
|
done
|
|
|
|
info " .env OK"
|
|
|
|
# Box JWT app config — docker-compose declares ./secrets/box-config.json as
|
|
# a docker secret. Compose refuses to start when the file is missing, so
|
|
# we stub an empty {} if it doesn't exist. Box features stay disabled
|
|
# (isBoxConfigured() returns false) until the user drops the real config
|
|
# from the Box developer console at this path.
|
|
mkdir -p "$SCRIPT_DIR/secrets"
|
|
if [[ ! -f "$SCRIPT_DIR/secrets/box-config.json" ]]; then
|
|
echo '{}' > "$SCRIPT_DIR/secrets/box-config.json"
|
|
info " Created empty secrets/box-config.json — Box features disabled until configured"
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 4: Build and start Docker containers
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 4: Building and starting containers..."
|
|
|
|
# Stop our own containers first — that releases any ports they held so the
|
|
# probe below doesn't consider them "in use".
|
|
docker compose -p "$COMPOSE_PROJECT" down --remove-orphans
|
|
|
|
# Probe host ports. App and DB are both fully containerized — the host ports
|
|
# are only used by (a) Apache's reverse proxy pointing at the app, and (b)
|
|
# psql-from-host debugging for the DB. If the preferred port is busy (e.g.
|
|
# hp-prod-tracker on 3001, some other project squatting 5492), we pick the
|
|
# next free one automatically rather than failing the deploy.
|
|
if [[ -z "${APP_HOST_PORT:-}" ]]; then
|
|
APP_HOST_PORT=$(find_free_port "$PREFERRED_APP_PORT")
|
|
[[ -z "$APP_HOST_PORT" ]] && { error " Could not find a free app port"; exit 1; }
|
|
fi
|
|
if [[ -z "${DB_HOST_PORT:-}" ]]; then
|
|
DB_HOST_PORT=$(find_free_port "$PREFERRED_DB_PORT")
|
|
[[ -z "$DB_HOST_PORT" ]] && { error " Could not find a free db port"; exit 1; }
|
|
fi
|
|
export APP_HOST_PORT DB_HOST_PORT
|
|
|
|
if [[ "$APP_HOST_PORT" != "$PREFERRED_APP_PORT" ]]; then
|
|
warn " Preferred app port $PREFERRED_APP_PORT is busy — using $APP_HOST_PORT"
|
|
else
|
|
info " App host port: $APP_HOST_PORT"
|
|
fi
|
|
if [[ "$DB_HOST_PORT" != "$PREFERRED_DB_PORT" ]]; then
|
|
warn " Preferred db port $PREFERRED_DB_PORT is busy — using $DB_HOST_PORT"
|
|
else
|
|
info " DB host port: $DB_HOST_PORT"
|
|
fi
|
|
|
|
# Persist the chosen ports to .env so subsequent `docker compose up`
|
|
# calls (without going through deploy.sh) pick them up automatically.
|
|
# Compose auto-loads .env for variable substitution in the working dir.
|
|
# Without this, running `docker compose up -d` would fall back to the
|
|
# defaults in docker-compose.yml and collide with whatever else is on
|
|
# 5492 / 3002. Replaces any existing lines so a port change is reflected.
|
|
for varline in "APP_HOST_PORT=${APP_HOST_PORT}" "DB_HOST_PORT=${DB_HOST_PORT}"; do
|
|
varname="${varline%%=*}"
|
|
if grep -q "^${varname}=" .env; then
|
|
sed -i.bak "s|^${varname}=.*|${varline}|" .env && rm -f .env.bak
|
|
else
|
|
echo "${varline}" >> .env
|
|
fi
|
|
done
|
|
info " Persisted APP_HOST_PORT=$APP_HOST_PORT DB_HOST_PORT=$DB_HOST_PORT to .env"
|
|
|
|
docker compose -p "$COMPOSE_PROJECT" up -d --build
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 5: Wait for Postgres
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 5: Waiting for Postgres..."
|
|
|
|
MAX_WAIT=30
|
|
for i in $(seq 1 $MAX_WAIT); do
|
|
if docker compose -p "$COMPOSE_PROJECT" exec -T db pg_isready -U postgres &>/dev/null; then
|
|
info " Postgres ready (${i}s)"
|
|
break
|
|
fi
|
|
if [[ $i -eq $MAX_WAIT ]]; then
|
|
error " Postgres did not become ready within ${MAX_WAIT}s"
|
|
docker compose -p "$COMPOSE_PROJECT" logs db --tail 20
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 6: Wait for app health check
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 6: Waiting for app to be healthy (Prisma migrations run on startup)..."
|
|
|
|
HEALTH_URL="http://localhost:${APP_HOST_PORT}/loreal-prod-tracker/api/health"
|
|
for i in $(seq 1 40); do
|
|
if curl -sf "$HEALTH_URL" &>/dev/null; then
|
|
info " App healthy (${i}s)"
|
|
break
|
|
fi
|
|
if [[ $i -eq 40 ]]; then
|
|
error " App did not become healthy within 120s"
|
|
error " Check logs: docker compose -p $COMPOSE_PROJECT logs app --tail 50"
|
|
docker compose -p "$COMPOSE_PROJECT" logs app --tail 30
|
|
exit 1
|
|
fi
|
|
echo -n "."
|
|
sleep 3
|
|
done
|
|
echo ""
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 7: Seed database (only if empty — first deploy or clean-slate)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 7: Checking if database needs seeding..."
|
|
|
|
# Count orgs via psql. A fresh DB returns 0; a seeded DB returns ≥ 1.
|
|
# `-tA` strips headers/padding so we get a clean integer. 2>/dev/null
|
|
# swallows the "relation does not exist" error that shows up if migrations
|
|
# haven't actually run (shouldn't happen — Step 6 waits for healthy, which
|
|
# happens after migrate deploy — but belt-and-braces).
|
|
ORG_COUNT=$(docker compose -p "$COMPOSE_PROJECT" exec -T db \
|
|
psql -U postgres -d loreal_prod_tracker -tA \
|
|
-c "SELECT COUNT(*) FROM organizations;" 2>/dev/null \
|
|
| tr -d '[:space:]' || echo "0")
|
|
ORG_COUNT="${ORG_COUNT:-0}"
|
|
|
|
if [[ "$ORG_COUNT" == "0" ]]; then
|
|
info " Empty DB detected — running one-time seed (admin + pipeline + teams)..."
|
|
docker compose -p "$COMPOSE_PROJECT" exec -T app npm run db:seed
|
|
info " Seed complete — check the output above for the admin email + temp password"
|
|
else
|
|
info " DB already seeded ($ORG_COUNT org(s)) — skipping"
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 8: Apache — add Include if not already present
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 8: Configuring Apache..."
|
|
|
|
# Apache vhost resolution: standard Ubuntu uses a2ensite to symlink
|
|
# sites-enabled → sites-available. But optical-dev has a separately-managed
|
|
# sites-enabled file (not a symlink), so editing sites-available silently
|
|
# does nothing — Apache reads sites-enabled. Detect and pick the right file.
|
|
APACHE_SITES_ENABLED="/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
|
|
APACHE_SITES_AVAILABLE="/etc/apache2/sites-available/optical-dev.oliver.solutions.conf"
|
|
|
|
if [[ -L "$APACHE_SITES_ENABLED" ]]; then
|
|
# Proper symlink setup — edits to sites-available propagate.
|
|
APACHE_CONF="$APACHE_SITES_AVAILABLE"
|
|
elif [[ -f "$APACHE_SITES_ENABLED" ]]; then
|
|
# Separately-managed file — edit sites-enabled directly.
|
|
APACHE_CONF="$APACHE_SITES_ENABLED"
|
|
warn " sites-enabled file is NOT a symlink to sites-available — editing $APACHE_CONF directly"
|
|
else
|
|
APACHE_CONF="$APACHE_SITES_AVAILABLE"
|
|
fi
|
|
|
|
APACHE_TMPL="$SCRIPT_DIR/apache/loreal-prod-tracker.conf.tmpl"
|
|
APACHE_SNIPPET="$SCRIPT_DIR/apache/loreal-prod-tracker.conf"
|
|
INCLUDE_LINE=" Include $APACHE_SNIPPET"
|
|
|
|
# Render the template with the chosen APP_HOST_PORT — the committed .tmpl
|
|
# has ${APP_HOST_PORT} placeholders that we expand here. Always re-render
|
|
# so a changed port on a subsequent deploy is reflected without manual edits.
|
|
sed "s|\\\${APP_HOST_PORT}|${APP_HOST_PORT}|g" "$APACHE_TMPL" > "$APACHE_SNIPPET"
|
|
info " Rendered $APACHE_SNIPPET (APP_HOST_PORT=$APP_HOST_PORT)"
|
|
|
|
if [[ ! -f "$APACHE_CONF" ]]; then
|
|
warn " $APACHE_CONF not found — skipping Include insertion (add manually: $INCLUDE_LINE)"
|
|
elif grep -qF "$APACHE_SNIPPET" "$APACHE_CONF"; then
|
|
info " Include already present — reloading Apache to pick up the re-rendered snippet"
|
|
sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \
|
|
|| { error "Apache config test failed — check $APACHE_SNIPPET"; sudo apache2ctl configtest; exit 1; }
|
|
sudo systemctl reload apache2
|
|
info " Apache reloaded"
|
|
else
|
|
# Remove the old manually-added inline block before inserting the canonical Include
|
|
sudo sed -i '/# .*HP-PROD-TRACKER\|HP-PROD-TRACKER.*3001/d' "$APACHE_CONF"
|
|
sudo sed -i '/ProxyPass[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF"
|
|
sudo sed -i '/ProxyPassReverse[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF"
|
|
|
|
# Insert Include before </VirtualHost>
|
|
sudo sed -i "s|</VirtualHost>|$INCLUDE_LINE\n</VirtualHost>|" "$APACHE_CONF"
|
|
info " Added: $INCLUDE_LINE"
|
|
|
|
sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \
|
|
|| { error "Apache config test failed — check $APACHE_CONF"; sudo apache2ctl configtest; exit 1; }
|
|
sudo systemctl reload apache2
|
|
info " Apache reloaded OK"
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 9: UFW Firewall
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 9: Configuring UFW firewall..."
|
|
|
|
sudo ufw default deny incoming 2>/dev/null || true
|
|
sudo ufw default allow outgoing 2>/dev/null || true
|
|
sudo ufw allow 22/tcp
|
|
sudo ufw allow 80/tcp
|
|
sudo ufw --force enable
|
|
info " UFW enabled (22/tcp, 80/tcp allowed)"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Done
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
docker compose -p "$COMPOSE_PROJECT" ps
|
|
echo ""
|
|
info "Deploy complete!"
|
|
info " Commit : $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)"
|
|
info " App : https://optical-dev.oliver.solutions/loreal-prod-tracker"
|
|
info " Ports : app=$APP_HOST_PORT db=$DB_HOST_PORT (internal container ports unchanged)"
|
|
info " Logs : docker compose -p $COMPOSE_PROJECT logs -f app"
|