Commit graph

7 commits

Author SHA1 Message Date
DJP
d2271f9cf3 Deploy scripts: resolve repo root from script location
The previous BACKEND_DIR_V2=/opt/social-reporting-v2 hardcode failed on
the production host because we did an in-place cutover: V1 and V2 share
a single checkout at /opt/social-reporting. ./v2/deploy/deploy-v2.sh
exited immediately with "V2 dir not found".

Both deploy-v2.sh and rollback-to-v1.sh now derive their repo root from
the script's own location, so they work whatever the parent directory
is named. setup-v2.sh is left alone — it's the first-time provisioner
and creating /opt/social-reporting-v2 from scratch is still a fine
default for greenfield installs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:15:22 -04:00
DJP
7024acfdf0 deploy: chown briefs/ to uid 1000 so container can write per-report dirs
The V2 container runs as uid 1000 (Dockerfile.v2:39 USER 1000). When the
pipeline tried to mkdir briefs/<brief-id>/ on the production server, it hit
EACCES because the host directory was owned by root (V1 had a similar fix
in deploy.sh that we never ported).

- cutover-in-place.sh: chown -R 1000:1000 briefs/ before docker up.
- deploy-v2.sh: same chown on every redeploy + use --env-file (was missing).

Immediate manual fix on the running server (until next deploy):
  sudo chown -R 1000:1000 /opt/social-reporting/briefs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:06:56 -04:00
DJP
a9f4dcf71a Finish V2: serve dashboards, downscale covers, post-run Apify cost re-poll,
enable mp4 download, smart Stage 4 cache, default Apify live in prod

Closes the last gaps so the operator app is end-to-end usable in production.

Server:
- routes/reports.ts: GET /api/reports/:id/dashboard[/<file>] serves files
  out of the report's brief outputs/ tree (HTML bundle, dataset_v2.json,
  any covers referenced relatively). Auth-gated by team viewer role.
  Path-traversal guarded.
- index.ts: two new route patterns (with and without trailing path).

Client:
- routes/reports/detail.tsx: "Open dashboard" is a target=_blank anchor at
  /api/reports/:id/dashboard/, "Download" same URL with download attribute.
  No more dead SPA-internal link.

Pipeline polish (the four open items from the smoke test):
- stage_10_build.ts: covers are now downscaled via ffmpeg (240px / q=6)
  before base64 inlining. Hard ceiling per cover 60 KB; falls back to the
  original only if it already fits. Honours V3 brief's ≤3 MB HTML bundle.
- lib/apify_client.ts: post-run cost is re-polled with backoff (0/5/15/30s)
  instead of a single read. TIKTOK_COMMENTS reports $0 immediately and
  $5+ later — without this the soft cap can't fire on it.
- stage_2_pass1_scrape.ts: shouldDownloadVideos:true (and shouldDownloadCovers:true)
  by default so videoMeta.downloadAddr is populated for Stage 4 frame
  extraction. Disable with DISABLE_VIDEO_DOWNLOADS=true if the budget is
  tight.
- stage_4_pass2_enrich.ts: Stage 5 backfill candidates aren't in the
  transcripts/comments cache. New loadOrFetchActor() reads what's cached,
  identifies missing ids, fetches just those from Apify, and merges back
  into the cache. Backfill no longer drops every candidate.

Production defaults:
- .env.example: APIFY_LIVE_APPROVED=true (commented; operators can flip
  to false for dry-runs).
- cutover-in-place.sh: sets APIFY_LIVE_APPROVED=true if not already in .env
  after the migration step, so a fresh prod cutover doesn't accidentally
  dry-run.

62/62 unit tests pass; tsc + vite build green; bundle 269 kB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:31:38 -04:00
DJP
7d70c0c155 Bake VITE_AZURE_* into the SPA at docker build time; sweep V1 leftovers in cutover
Two issues from the first server cutover:

1. SPA loaded white-on-black blank because import.meta.env.VITE_AZURE_TENANT_ID
   and VITE_AZURE_CLIENT_ID were undefined at runtime. Vite reads VITE_* at
   *build time* and inlines them into the bundle; passing them only as
   runtime container env vars is too late.
   - Dockerfile.v2: declare ARG VITE_AZURE_TENANT_ID, VITE_AZURE_CLIENT_ID,
     VITE_BASE; export as ENV before `npm run build`.
   - docker-compose.v2.yml: forward AZURE_TENANT_ID / AZURE_CLIENT_ID /
     VITE_BASE through `build.args` so the cutover .env values reach Vite.

2. cutover-in-place.sh stopped V1 with `-p social-listening`, but V1's actual
   compose project name was `social-reporting` (parent dir). Old V1 containers
   were left running. Now we try both project names AND sweep by container
   name pattern (anything matching social-listening or social-reporting-db-1
   that isn't a V2 container).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:00:15 -04:00
DJP
e223122abe Drop db-v2 host port binding in prod; add port pre-flight to cutover script
The host port 5437 on the optical-dev server was already allocated by
something (probably an old stopped-but-not-removed Postgres container
or another tracking app). V2 doesn't need a host port for db-v2 in
production — app-v2 reaches it over the docker network at db-v2:5432.

Per CLAUDE.md "always check for ports that are already used":

- docker-compose.v2.yml: remove the unconditional db-v2 host port
  binding. Compose's list-merge semantics meant `ports: []` in the prod
  override didn't actually clear the base list.
- docker-compose.v2.dev.yml (new): local-dev overlay that re-adds the
  host port for psql convenience. Use with `-f base -f dev`. Bound to
  127.0.0.1 so the db is never reachable from outside the dev machine.
- cutover-in-place.sh: pre-flight check on APP_V2_PORT (3457) — if
  it's held by something other than our own V2 container, abort with
  a clear message rather than failing mid-deploy.

Verified locally: `compose -f base -f prod up -d` brings up a stack
with db-v2 having no host port (just internal 5432/tcp), app-v2 on
127.0.0.1:3457, /api/health returns {ok:true}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:49:44 -04:00
DJP
5770b2579d Wire SPA + SSO redirect URI to /social-reports/ prefix; in-place cutover script
Phase A scaffolded the SPA at the bare origin (`/`); production lives behind
Apache at `/social-reports/`. Without these fixes, V2's built assets 404 and
Azure SSO rejects the redirect URI mismatch.

- Vite `base: /social-reports/` (overridable via VITE_BASE for dev).
- BrowserRouter basename = import.meta.env.BASE_URL.
- apiFetch + msal-browser script src + token-exchange URL all prefix BASE.
- MSAL redirectUri now matches V1's Azure-registered URI:
  `${origin}/social-reports/login.html`.
- New `<Route path="/login.html">` alias renders the same Login component
  so React Router matches the redirect URI when MSAL returns.

Deploy ergonomics (the user wants V1 gone from the server):
- v2/deploy/cutover-in-place.sh: run from /opt/social-reporting; stops V1,
  pulls main (v2/ appears, V1 dirs deleted), migrates secrets from V1's
  .env into v2/.env, swaps Apache, starts V2. Single command, no clone of
  a sibling dir needed.
- setup-v2.sh: PURGE_V1=true flag now cleans /opt/social-reporting and
  the V1 docker volume after V2 is healthy.
- rollback-to-v1.sh: re-clones the v1-archive branch when V1 is no longer
  on disk (REPO_URL required).

62/62 unit tests still pass; vite build emits assets under /social-reports/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:40:38 -04:00
DJP
b89e8b511e Add V2: multi-team social-reporting platform with manifest-gated linking
V2 lives entirely under v2/ and is built around three asks the team raised
about V1: per-video assets sometimes drifted onto the wrong trend, hashtag
scrapes returned junk that wasn't filterable per-client, and there was no
multi-user model behind Microsoft SSO.

Highlights:
- Stable TikTok numeric-id key for every per-video asset; URL form drift is
  logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5
  manifest hard-gates Stage 6 if any selected video is missing any required
  asset; --drop-failing auto-backfills from the next-best recipe candidates.
- Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied
  at Apify scrape time and re-validated locally; spend_log.json records
  raw_returned vs kept_after_floor per scrape.
- Users + teams + memberships with owner/admin/editor/viewer roles; SSO
  upserts a user keyed on Azure oid, auto-creates a personal team, and a
  super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first
  sign-in. Phase A integration test: 16/16 pass.
- 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 →
  validate → analyse → insights → trends → qa → build) wired through one
  CLI; each stage idempotent + resumable from disk via .state sentinels.
  §4.5 rubrics shipped under prompts/ and loaded into Claude calls.
- React 18 + Vite + TS + Tailwind operator SPA: brief intake form,
  team management, super-admin user list, help/FAQ ported from V1.
- Separate Docker Compose project (name: social-reporting-v2, port 3457,
  Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh
  scripts that take over V1's /social-reports URL and let us roll back.

Verification: 62 unit tests pass (auth/session, ids extractor with full URL
fixture, engagement floor, recipes, manifest, linking-fix, MoM compare).
Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21
fully-bundled videos → 25 editorial trends across 8 brief-driven categories,
with drift=0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:39:07 -04:00