Commit graph

21 commits

Author SHA1 Message Date
DJP
98bcae6f31 Build before QA: dashboard ready when sign-off panel appears
Two complaints, both right:
1. QA sign-off was asking humans to OK trends + paid/organic without
   anything visual to review — the dashboard wasn't built yet.
2. The QA panel just said "yes/no" and gave reviewers no specific
   guidance on what they should be looking at.

Reordered the pipeline: after Stage 9 QA gates pass, immediately run
MoM compare (if prior_report_id) + Stage 10 Build, THEN park at
status=qa with the dashboard already rendered. Stage 10 has no Claude
cost (just file-copy + Vite build) so running it before sign-off costs
nothing meaningful, and the rationale "save spend if QA fails" never
actually applied — paid review work is already done by Stage 8.

QA panel rewrite:
- Big "Open dashboard ↗" button at the top
- Inline "What you're signing off" checklist split CM vs Strategist
  (paid/organic + comments themes + sentiment risk on one side; trend
  names + relevance + lens artefacts on the other)
- Removed the now-redundant Build button
- Second sign-off auto-completes the report; no extra click needed
- Status text walks them through the next action ("Open the dashboard,
  walk through the views you're responsible for, then sign.")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:44:42 -04:00
DJP
404425a06e Add Cancel button + POST /api/reports/:id/cancel route
The run page now has a Cancel button next to the status pill while a
pipeline is non-terminal. Clicking it (after a confirm prompt) hits
POST /api/reports/:id/cancel, which marks the row as failed with
"Cancelled by <email>" and SIGTERMs the spawned tsx child's whole
process group — so the Claude CLI subshell, ffmpeg, and Apify-poll
loops all stop together rather than orphaning.

Implementation notes:
- Replaced runningReportId with runningChild that holds the child handle
- Removed child.unref() so we can manage it. Pulled both the run and
  retry spawn paths into a single spawnPipeline() helper to avoid drift
- Mark the row before sending the signal so the spawn-exit handler's
  COALESCE doesn't overwrite "Cancelled by" with "killed by SIGTERM"
- Cancel from a different server process (e.g. after a server restart)
  returns 409 with a hint that the child handle is no longer in scope —
  the user can mark it failed manually if needed

Already-completed stages are preserved on disk via .state sentinels, so
"Cancel + edit brief + Force re-run" works without re-spending on
finished stages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:48:40 -04:00
DJP
0c27ecc180 Live banner: show total spend + heartbeat across Claude stages
Banner was showing only the Apify-side running cost from the heartbeat
file, so Stage 1 Claude spend ($0.06+) showed as $0.00 and Claude-only
stages (6/7/8) wouldn't show any spend at all. Now reads total cost
straight from the report row (apify + claude broken out as a sub-line)
and refreshes the heartbeat on every Claude cost event so the banner
stays alive between Apify scrapes too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:23:34 -04:00
DJP
8e25914939 Run page liveness: heartbeat banner + fix Apify $0 + Stage 4 budget cap
Three bugs surfaced by the Dove2 demo run on prod and addressed together
because they all conspire to make the run page look dead:

1. Apify cost events were never persisted. Stage 2 imported onApifyCost
   and registered an empty no-op callback inside its run loop, silently
   overwriting the CLI's DB-writing handler set in cli.ts:logCost(). The
   APIFY total stuck at $0.00 even though Stage 2 had spent $5+ in real
   billing. Removed the override; the CLI's callback now wins.

2. Stage 4 inherited Stage 2's Pass-1 soft cap and skipped every actor.
   resetBudget() sets a hard ceiling (95% of brief.budget_usd) and
   setSoftCap() sets the Pass-1 cap (50%). Stage 2 fills the soft cap,
   but Stage 4 never released it — every TIKTOK_TRANSCRIPTS / COMMENTS /
   PROFILE call returned "budget reached — skipping" and the manifest
   gate failed at 0% coverage. Stage 4 now calls setSoftCap(null) at
   entry so it stays bounded only by the hard ceiling.

3. Even between cost events the run page had no liveness signal. Apify
   actors run 1-3 minutes per scrape with no DB writes in flight, so the
   UI looked frozen. Added a best-effort heartbeat: apify_client writes
   .state/live_activity.json on every Apify status poll (every 5s),
   GET /reports/:id includes it on the response, and the run page shows
   a live banner with the current activity, elapsed time, last
   heartbeat age (flags as suspicious past 90s), and running Apify
   spend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:12:53 -04:00
DJP
fa4b356af7 Report detail: show elapsed time + live activity for running stages
Stage 2 hashtag scrapes can take 1-3 min each, and the run page gave no
sign of life between cost events — the active stage just had a pulsing
dot. Now shows the elapsed run time in the header, a 'working…' marker
on the active stage, the latest cost event under it (e.g. 'apify ·
hashtag:#hairtok · 30s ago'), and a hint that long Apify silences are
mid-flight scrapes, not stalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:02:03 -04:00
DJP
564b6d9274 Stage 2: surface actor exceptions; BriefReports: show real error
The "Spent $0.00 across 0 scrapes" diagnostic was technically correct but
hid the actual cause when every Apify actor call threw before returning.
The most common case in the wild — a missing or invalid APIFY_TOKEN — left
no useful trail in the UI.

Stage 2 now:
- Tracks actor exceptions per scrape into an actorErrors[] array.
- If spend log ends up empty AND any errors were captured, throws with the
  first 3 errors inline so the operator sees the actual reason in the UI:
  "every Apify call (7/7 attempted) threw before returning data … hashtag:#hairtok:
  Apify start failed: 401 Unauthorized … Fix the token in v2/.env, restart V2,
  click Force re-run."
- Persists actor_errors into spend_log.json for forensic inspection too.

BriefReports panel:
- Now shows the actual error message (e.g. "404 Not Found") rather than a
  generic "Could not load reports."
- Detects 404 specifically and prompts: "The /api/briefs/:id/reports endpoint
  is missing on the server. Pull the latest main and run
  `docker compose ... up -d --build`."

Together these answer the "is the server actually running new code?" question
without needing to SSH and grep docker logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:47:38 -04:00
DJP
15aa5a6494 Demo brief: fill out every field with realistic, meaty content
The Dove demo was minimal — 3 competitors, 2 KPIs, 4 interests, one-line
positioning. New users importing it would see a thin brief and assume the
app expected thin briefs. Beefed up to actually demo the brief shape:

- 7 competitors (Olay, Garnier, Pantene, Nivea, Cerave, Aveeno, Tresemme)
- 5 KPIs reflecting how a brand strategist actually frames trend reports
  (cultural territory, hook patterns, emerging behaviours, paid/organic
  map, sentiment risk)
- 8 audience interests including the everything-shower / anti-influencer
  vocabulary the brief is meant to surface
- Proper "real beauty, real care" positioning paragraph
- Detailed business question with both why-now and what-should-Dove-do
- Substantive context_vision explaining what success looks like for the
  demo run vs the follow-on $200 brief with MoM compare
- $50 budget (was $30) so 5+ scrapes can run before the Pass-1 cap
- Explicit prior_report_id: null so the field is visible in the editor

Both the operator-app inline DEMO_BRIEF and v2/examples/dove-demo-brief.json
updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:37:06 -04:00
DJP
32dbd8aa7d Brief edit: prefill once, never clobber typing, fall back when full is missing
After importing a brief via JSON paste, edit appeared not to work — the
textarea would either be empty or silently revert as React Query refetched
on window focus (every refetch fired the prefill useEffect, blowing away
whatever the user had typed). On top of that, if the server's `full` field
wasn't returned for any reason the textarea stayed permanently empty.

Fixes:
- Initialise the textarea exactly once via a useRef seed flag. Subsequent
  data refetches don't overwrite user-typed content.
- When `full` is missing, fall back to reconstructing the BRIEF_INPUT shape
  from public columns (client_name, brand positioning, kpis, quality floor,
  etc.) so the user has something to edit. Surfaces an amber banner noting
  competitors/audience/geo are blank and need re-entering.
- New "Reset to saved" link in the header to deliberately discard local
  changes and reload from the server.
- Disable Save when the textarea is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:35:41 -04:00
DJP
3e71df8a79 Lower default engagement floor 10×; richer Stage 3 diagnostic
The 1000 likes / 10000 plays defaults are calibrated for top-of-funnel
beauty/fitness scrapes; in narrower TikTok niches almost every video lands
below them and Stage 2 returns 0 keepers. Defaults dropped to 100 likes /
1000 plays across:
- server/schemas/brief.ts (Zod default)
- db/init.sql (column default for new DBs)
- examples/dove-demo-brief.json
- operator-app's brief-form initial values
- operator-app's "Load Dove demo" inline brief

Stage 3 empty-pass1 error now reads pass1/spend_log.json and reports the
actual scrape breakdown — total $ spent, total raw videos returned, and
how many got dropped by each floor (zero-plays / min_plays / min_likes /
min_stl_pct). So instead of a generic "lower the floor", the user sees:
"Spent $5.42 across 7 scrapes; 1400 videos returned. Dropped: 12 zero-plays,
1305 below min_plays=10000, 31 below min_likes=1000."

Existing briefs are unaffected (column default applies to NEW rows). For the
in-flight Dove2 run the user can edit the brief and lower the floor, then
click Retry pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:28:23 -04:00
DJP
a829983bb9 Brief detail: surface this brief's report runs so status survives navigation
When you navigated away from a brief and came back, there was no indication
that a pipeline had already run for it — the page just showed the brief
fields and a "Run pipeline" button, making completed/in-flight runs invisible
without first hitting Home.

Now the brief detail page renders a "Reports for this brief" section listing
every run for the brief — status pill, run id, total cost, started/finished
relative timestamps, click-through to the run page. Auto-refreshes every 3s
while any run is non-terminal so an in-flight pipeline shows live progress
even when the user navigated to the brief instead of the report page.

Server:
- db/reports.ts: listReportsForBrief(brief_id, limit).
- routes/reports.ts: handleListReportsForBrief.
- index.ts: GET /api/briefs/:id/reports.

Client:
- api/reports.ts: useReportsForBrief hook with conditional polling.
- routes/briefs/detail.tsx: BriefReports section with status pills, in-flight
  shortcut link, empty state when no runs exist yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:22:38 -04:00
DJP
32d80ff07e Brief delete: list-page button, editor-can-delete-own, fs cleanup, error fix
Two issues addressed:

1) Delete brief was awkward — only on the detail page, only for admin/owner,
   and the on-disk briefs/<id>/ tree was orphaned even after a successful
   DB delete. Server policy is now:
   - team owner / admin: delete any brief in team
   - team editor: delete only briefs they own
   - team viewer: cannot delete
   - super-admin: bypass
   Plus: refuses to delete if a non-terminal pipeline run is active for the
   brief, and rm -rf's briefs/<id>/ after the FK cascade so scrape data,
   datasets and dashboards don't pile up.
   Operator UI: per-row Delete button on the briefs list page (with confirm)
   for users who have delete rights on each brief; detail-page rule loosened
   to match the server's editor-owner policy.

2) Pipeline error messages were being clobbered. The server's child-on-exit
   handler called finishReport() unconditionally, overwriting the actual
   error the pipeline's main()-catch had just written. Now we use a
   COALESCE update that only sets error_message when it's still NULL —
   so the real failure (e.g. "Stage 3: pass1_videos.json is empty…") wins
   over the generic "Pipeline exited with code 1". Stage 3's empty-pass1
   error message is also now diagnostic: it lists the brief's current
   engagement-floor values and suggests what to lower.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:02 -04:00
DJP
376802db41 Pipeline retry: idempotent all + retry endpoint + UI buttons
The pipeline `all` command now skips stages whose .state/stage{N}.done
sentinel is present (unless --force), so retrying picks up exactly where
the previous run failed without re-spending on completed stages. Stage 5
(validate) is the deliberate exception — it always re-runs because
--drop-failing changes the selection set.

- pipeline/cli.ts: `all` wraps every stage in a maybeRun() helper that
  checks the sentinel + writes one after running. Validate runs every
  retry; if it doesn't reach 100% coverage the pipeline now fails LOUDLY
  with a clear error rather than crashing in Stage 6.
- routes/reports.ts: handleRetryReport — POST /api/reports/:id/retry,
  resets reports.status to pending (clears error_message + finished_at),
  spawns `pipeline cli all` with --drop-failing (and --force when the
  client passes {force:true}). Same singleton-running guard as run.
- operator-app: useRetryReport hook + two new buttons on Reports detail:
  - "Retry pipeline" / "Force re-run" on the failure panel.
  - "Retry with drop-failing" on the manifest panel (Stage 5 specific).

Items explicitly deferred (documented in head):
- SSE for live progress (3s React Query polling already in place).
- Conversational CLI brief intake (V3 §0; the operator-app form covers it).

62/62 unit tests pass. SPA build 284 kB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:11:37 -04:00
DJP
46675e9a99 Wire the rest: lens artefacts, brief edit, manifest panel, QA sign-off + build gate
Closes the V3 brief gaps that were called out in the audit.

Stage 8c lens artefacts (V3 §8c):
- prompts/lens_enrichment.md: rubric for Hooks Library / Visual Vernacular /
  Audio Atlas / Sentiment Map.
- stages/stage_8c_lenses.ts: zod-validated lens generation from atomic
  insights + per-video analyses; writes lenses/{hooks_library,visual_vernacular,
  audio_atlas,sentiment_map}.json.
- stage_10_build.ts: dataset_v2.json now includes the four lens arrays.
- cli.ts: new `lenses` command; `all` runs 8c after Stage 8 (fail-soft);
  `build` runs 8c too in case the previous run skipped it.

Pipeline split for sign-off enforcement (V3 §9):
- cli.ts `all` command now stops at status=qa after Stage 9. The operator app
  drives Stage 10 separately via POST /api/reports/:id/build, which the
  server only allows after CM + Strategist sign-offs from two different
  humans.
- routes/reports.ts: handleQaSignoff (POST /api/reports/:id/qa/sign with
  role=cm|strategist), handleBuildReport (verifies both signoffs + different
  user_ids, then spawns `cli.ts build`). handleGetReport now also returns
  manifest summary + qa.{cm_signoff,strategist_signoff}.

Brief edit (PATCH):
- db/briefs.ts: updateBrief.
- routes/briefs.ts: handleUpdateBrief, with editor+ team role.
- /api/briefs/:id PATCH route added.
- operator-app: useUpdateBrief hook; new /briefs/:id/edit route — minimal
  JSON-textarea form, prefilled from brief.full, with Zod-issue surfacing.
- briefs/detail: "Edit" button next to Export/Run.

Reports detail UI:
- ManifestPanel: when manifest summary is in the response, render asset-
  status grid + collapsible missing-videos list + the exact CLI command to
  --drop-failing-backfill.
- SignoffPanel: two cards (CM + Strategist) showing signed-by-email/at;
  "Sign as ..." button per side; client-side guard prevents the same user
  signing both; "Build report" button enabled only when both signoffs
  present + different humans.
- Dashboard static-serve route + Open dashboard / Download bundle from
  earlier session re-confirmed wired.

Server clean, vite build green at 282 kB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:57:49 -04:00
DJP
e47c5fa308 Briefs: import + export + demo brief; clean cli usage text
- briefs/list: new "Import JSON" button; collapsible panel with file upload,
  paste textarea, "Load Dove demo" button (inlines the same demo we ship
  in v2/examples/dove-demo-brief.json). On submit POSTs the JSON to
  /api/briefs and surfaces server-side Zod issues inline.
- briefs/detail: new "Export JSON" button — downloads `<slug>.brief.json`
  using the brief_yaml the server now exposes under `full`.
- v2/examples/dove-demo-brief.json: real Dove TikTok demo brief, $30
  budget, ready to run end-to-end via the Run pipeline button.
- pipeline/cli.ts: usage() text de-stale-ified — every stage is real now,
  the "TODO Phase X" tags removed; new commands documented (`all`,
  `backfill-covers`, `--run-id`, `--drop-failing`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:36 -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
1d2801d3c3 Wire reports end-to-end: trigger, track, poll, view
Closes the gap between "brief exists" and "report ships". The Phase A
placeholders for Home and Reports/detail are now real, and the brief detail
page can actually start a pipeline run.

Server (no schema changes — reports table already existed):
- db/reports.ts: createReport, getReport, getReportWithBrief, listReportsForTeam,
  updateReportStatus, finishReport, logCostEvent (atomically updates the
  reports row's running totals), listCostEvents.
- routes/reports.ts: GET /api/reports (active team), GET /api/reports/:id
  (with cost_events), POST /api/briefs/:id/run that
    1. authorises (editor+ on the brief's team),
    2. creates a reports row (status=pending),
    3. spawns the pipeline as a detached child running
       `tsx pipeline/cli.ts all --report <brief_id> --run-id <reports.id>`,
    4. returns the new report id.
  Singleton flag prevents two concurrent runs (mirrors V1).

Pipeline:
- cli.ts: new --run-id flag. New `all` command drives every stage in order
  via a withStage() helper that updates reports.status / current_stage at
  each step. Cost callbacks now ALSO write to cost_events when run-id is
  set, tagged with the current stage. main()'s catch handler calls
  finishReport(runId, 'failed', err.message) so the UI doesn't poll forever
  on a crash.

Client:
- api/reports.ts: useRecentReports, useReport (auto-polls every 3s while
  status is non-terminal), useRunPipeline.
- routes/home.tsx: real recent-reports list — status pill, brief client +
  business question, cost split, relative time.
- routes/reports/detail.tsx: full run page — header with status pill,
  10-step pipeline progress with current-stage pulse, error block on
  failure, three-tile cost summary (total / apify / claude), cost-event
  log (most recent first, scrollable, sticky header), "Open dashboard"
  + "Download HTML bundle" actions when the run completes.
- routes/briefs/detail.tsx: Run pipeline button is now functional for
  editors+, with a confirm dialog (warns about Apify/Claude spend),
  navigates to the new /reports/:id on success, surfaces 409 if another
  run is in flight.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:18:57 -04:00
DJP
1ca7b9c759 Wire teams + admin pages: list/create teams, manage members, toggle super-admin
Replaces the three Phase A scaffold placeholders with working pages backed by
the existing server endpoints (no server changes).

- src/api/teams.ts (new): useTeams, useTeam, useCreateTeam, useAddMember,
  useUpdateMemberRole, useRemoveMember. All with cache invalidation.
- src/api/admin.ts (new): useAllUsers, useToggleSuperAdmin.
- routes/teams/list.tsx: list of teams (cards with role badge + slug + Personal
  marker), inline "Create a team" form (POSTs /api/teams), each card links to
  /teams/:id. Inline 409 / validation handling.
- routes/teams/detail.tsx: team header with my role; members table; owners can
  change member roles via dropdown, owners + admins can remove members
  (confirm dialog); below the table, an Invite form (email + role select)
  matching POST /api/teams/:id/members. Per-row + per-form error surfacing.
- routes/admin/users.tsx: full users table — email, name, super-admin badge,
  created/last-login timestamps, promote/revoke button. Disables the toggle
  for the current user when they're super-admin (matches the server's
  self-demotion guard); also shows "(you)" indicator next to your own row.

Bundle size: 258 kB → /social-reports/assets/index-C0ofQc9Y.js (+7 kB gzipped).
TS strict + vite build pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:10:40 -04:00
DJP
855c07c76f TeamSwitcher + MeResponse: read /api/me's teams, not memberships
The server returns { user, teams, active_team } (server/routes/me.ts:19-30).
The SPA's MeResponse type had `memberships`, so TeamSwitcher's `data.memberships.length`
crashed on initial render after sign-in:
  TypeError: Cannot read properties of undefined (reading 'length')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:04:14 -04:00
DJP
1d71200aab Stop .gitignore from swallowing src/routes/briefs/
The unanchored `briefs/` rule (intended for the runtime per-report tree at
the repo root) also matched `v2/operator-app/src/routes/briefs/` — so the
brief-form components never made it into git, and the prod docker build
failed at vite with "Could not resolve ./routes/briefs/list".

Fix: anchor to the two specific paths that should be ignored, /briefs/
and /v2/briefs/. The React routes dir under src/ is now tracked.

Adds the four missing files: list.tsx, new.tsx, detail.tsx, _form-bits.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:44:08 -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