dow-prod-tracker/DEPLOY.md
DJP 1b73d6b8db L'Oréal rebuild: restore review workflow, full rename, /api/v1, Box integration
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>
2026-05-12 17:51:53 -04:00

11 KiB

Deploying loreal-prod-tracker

Target: https://optical-dev.oliver.solutions/loreal-prod-tracker, hosted on the shared Oliver Agency dev box alongside hp-prod-tracker.

Run the deploy script from the repo root on the server:

cd /opt/loreal-prod-tracker          # or wherever you cloned it
./deploy.sh

The first run will install Docker, Compose, Apache and UFW if missing, prompt you to fill in .env from .env.example, then bring everything up. Subsequent runs are idempotent — safe to re-run for updates.


Why this deploy is different from the rest

The shared server runs multiple apps from sibling deploy directories. Two things must hold or those apps will silently corrupt each other (this has bitten us before — see CLAUDE.md):

  1. docker-compose.yml MUST pin a top-level name: field. We pin name: loreal-prod-tracker. Without this, Compose defaults the project name to the parent directory name. If two apps both live under deploy/, they collapse onto the same project and fight over containers (deploy-db-1) and volumes (deploy_pgdata). Deploying one would silently evict the other and destroy its data.

  2. Every docker compose invocation in deploy.sh passes -p loreal-prod-tracker as belt-and-braces. This is redundant with name: today, but if anyone moves the name: line out of the compose file, or runs commands by hand from a different cwd, the -p flag is the safety net.

The deploy script also enforces:

Concern Value Where
Compose project name loreal-prod-tracker name: in docker-compose.yml + -p in deploy.sh
App port (host) 3002 preferred, auto-bumped if busy deploy.sh probes, exports APP_HOST_PORT, renders apache/loreal-prod-tracker.conf from the .tmpl
DB port (host) 5492 preferred, auto-bumped if busy deploy.sh probes, exports DB_HOST_PORT into compose
DB name loreal_prod_tracker docker-compose.yml env + .env DATABASE_URL
App URL path /loreal-prod-tracker next.config.ts basePath
Apache reverse proxy → 127.0.0.1:${APP_HOST_PORT}/loreal-prod-tracker rendered on each deploy from apache/loreal-prod-tracker.conf.tmpl

Auto port selection. Everything runs in Docker — the container-internal ports (app 3000, db 5432) never change. The host ports are only used by Apache's reverse proxy (for the app) and direct psql debugging (for the db). If 3002 or 5492 happens to be in use (another app on the shared server, something on your laptop), deploy.sh picks the next free port upward and stitches it through: compose env var, Apache ProxyPass, health check, "Deploy complete" banner. No manual port negotiation.

To force specific ports, export them before running:

APP_HOST_PORT=3005 DB_HOST_PORT=5495 ./deploy.sh

hp-prod-tracker lives on 3001. Dow prefers 3002; the probe will skip 3001 if something (like HP) is on it.


.env.production checklist

Required env vars on the server:

DATABASE_URL=postgresql://postgres:<DB_PASSWORD>@db:5432/loreal_prod_tracker?schema=public
DB_PASSWORD=<long random>
AUTH_SECRET=<openssl rand -base64 32>

# Auth — local credentials in MVP. Flip to true post-MVP once Entra is wired.
NEXT_PUBLIC_AUTH_ENTRA_ENABLED=false

# Entra (only required when NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true)
AZURE_CLIENT_ID=
AZURE_TENANT_ID=
AZURE_REDIRECT_URI=https://optical-dev.oliver.solutions/loreal-prod-tracker/login

# OMG webhook receiver
OMG_WEBHOOK_SECRET=<openssl rand -hex 32>     # share with Shashank
OMG_WEBHOOK_ALLOW_INSECURE=false

# AI chat (optional — chat features degrade gracefully if absent)
ANTHROPIC_API_KEY=
OLLAMA_HOST=http://10.24.42.219:11434
OLLAMA_CHAT_MODEL=gemma4:latest
OLLAMA_EMBED_MODEL=nomic-embed-text

# Cron + API key (used for scheduled tasks + machine-to-machine API access)
CRON_SECRET=<openssl rand -hex 32>
API_KEY=<openssl rand -hex 32>

# Initial admin (optional — overrides the default admin@dowjones.com / random pw)
DOW_ADMIN_EMAIL=admin@dowjones.com
DOW_ADMIN_PASSWORD=                            # leave blank → seed prints a random one

First-time setup on a fresh server

# 1. Clone — /opt is standard for third-party apps on Debian/Ubuntu
sudo mkdir -p /opt && sudo chown $USER:$USER /opt
cd /opt
git clone git@bitbucket.org:zlalani/loreal-prod-tracker.git
cd loreal-prod-tracker

# 2. Configure
cp .env.example .env
$EDITOR .env                                    # fill in real secrets

# 3. Deploy (installs deps, builds, runs migrations, configures Apache + UFW)
./deploy.sh

# 4. Seed the Dow tenant + initial admin (one-time)
docker compose -p loreal-prod-tracker exec app npm run db:seed
# → save the printed admin email + temp password

Updating an existing deployment

cd /opt/loreal-prod-tracker
./deploy.sh                                     # pulls, rebuilds, restarts

The Prisma migration in prisma/migrations/20260420000000_init/ runs on container startup (via the standalone Next.js entrypoint). New migrations land automatically.


Verification — must pass before declaring deploy done

  1. Volume isolation — both apps' volumes must be distinct:

    docker volume ls | grep -E "hp-prod-tracker|loreal-prod-tracker"
    

    Expect to see hp-prod-tracker_pgdata, hp-prod-tracker_uploads_data, loreal-prod-tracker_pgdata, loreal-prod-tracker_uploads_data — four distinct volumes.

  2. HP unaffected — hit HP's URL in a browser and confirm it loads. If it fails, the new deploy collided with it (don't continue — fix first).

  3. Health checkcurl https://optical-dev.oliver.solutions/loreal-prod-tracker/api/health should return 200.

  4. Login — open the URL in a browser, sign in with the seed admin credentials, get redirected to /change-password?first=1, set a real password, land on /dashboard.

  5. XLSX ingest — upload Dow Jones_Studio Tracker_Example_*.xlsx via POST /api/projects/bulk-import?commit=false (curl or via the UI button). Expect a preview JSON with normalized rows + any row errors. Then commit with ?commit=true and spot-check a project in psql:

    docker compose -p loreal-prod-tracker exec db \
      psql -U postgres -d loreal_prod_tracker \
      -c "select \"omgJobNumber\", name, status, \"clientTeamId\" from projects limit 5;"
    
  6. OMG webhook — when Shashank confirms the payload shape, share OMG_WEBHOOK_SECRET and have OMG POST to https://optical-dev.oliver.solutions/loreal-prod-tracker/api/webhooks/omg with header X-OMG-Signature: sha256=<hex hmac of body>.

  7. Per-team visibility — invite a test user as CLIENT_VIEWER, assign them to one client team only (Settings → Client Teams), accept the invite link, sign in, confirm they only see that team's projects.


Rollback

If a deploy goes wrong:

cd /opt/loreal-prod-tracker
git log --oneline -5                           # find previous good commit
git checkout <previous-commit>
./deploy.sh --skip-pull                        # rebuild from that commit

If the database itself is in a bad state (rare — Prisma migrations are forward-only), restore from the latest postgres dump in /srv/backups/ (set up your own cron for these — not yet automated).


Common issues

"port is already allocated" — very unlikely now; deploy.sh auto-picks a free port before docker compose up. If it still happens, something grabbed the port between the probe and the bind. Just rerun ./deploy.sh. If it's a stale loreal-prod-tracker container from a botched deploy, docker compose -p loreal-prod-tracker down will release it first.

Apache returns 502 — the app container isn't running or its health check is failing. docker compose -p loreal-prod-tracker logs app --tail 50.

"Project with that OMG number already exists in a different organization" — a previous import landed projects under a different org (e.g. dev seed). Either delete those projects, or recreate the org with the canonical dowjones.com domain (the seed and webhook both look up the org by domain).

/api/webhooks/omg returns 401 — signature mismatch. Verify both ends share the exact OMG_WEBHOOK_SECRET and that OMG sends the header as X-OMG-Signature: sha256=<hex>. For local stub testing only, set OMG_WEBHOOK_ALLOW_INSECURE=true in .env and restart.


Backups

The database is business-critical. A nightly pg_dump runs on the host via cron; the dumps land in /srv/backups/loreal-prod-tracker/ and auto-prune after 30 days. On top of that, admins can grab a full XLSX snapshot from the Dashboard's "Export Full XLSX" button — same shape as the Dow upstream XLSX so a worst-case recovery can re-ingest through the bulk-import endpoint.

First-time setup

# 1. Make sure the backup dir exists + is writable
sudo mkdir -p /srv/backups/loreal-prod-tracker
sudo chown "$USER" /srv/backups/loreal-prod-tracker

# 2. Test the script by hand
cd /opt/loreal-prod-tracker
./scripts/backup-db.sh
ls -lh /srv/backups/loreal-prod-tracker/   # should show loreal-prod-tracker_YYYY-MM-DD_HHMMSS.sql.gz

# 3. Wire it to cron — midnight every night
crontab -e
# add this line:
0 0 * * * /opt/loreal-prod-tracker/scripts/backup-db.sh >> /var/log/loreal-prod-tracker-backup.log 2>&1

Adjust BACKUP_DIR / RETAIN_DAYS / COMPOSE_DIR via env vars on the cron line if your paths differ from the defaults.

Restoring from a dump

cd /opt/loreal-prod-tracker
BACKUP=/srv/backups/loreal-prod-tracker/loreal-prod-tracker_2026-04-22_000001.sql.gz

# 1. Stop the app so nothing writes during restore
docker compose -p loreal-prod-tracker stop app

# 2. Pipe the dump through psql. --clean --if-exists on the dump drops
#    existing tables first, so this is destructive — confirm you have
#    the right file.
gunzip -c "$BACKUP" | docker compose -p loreal-prod-tracker exec -T db \
  psql -U postgres -d loreal_prod_tracker

# 3. Bring the app back up
docker compose -p loreal-prod-tracker start app

# 4. Sanity-check via the app or a quick psql count
docker compose -p loreal-prod-tracker exec -T db \
  psql -U postgres -d loreal_prod_tracker -c "SELECT count(*) FROM projects;"

The host-local backups are fine for crash recovery but don't protect against the whole server vanishing. Sync the backup dir to object storage:

# Add to crontab after the pg_dump line
15 0 * * * aws s3 sync /srv/backups/loreal-prod-tracker/ s3://your-bucket/loreal-prod-tracker/ --delete

Or rsync to a separate host. Either way, don't skip this if the app is business-critical — a single-host backup is one drive failure away from zero.