Skip to content

feat: competitor intelligence ingest + seedance2 fixes#1

Merged
mtmtian merged 11 commits into
mainfrom
feat/competitor-intel
Jul 10, 2026
Merged

feat: competitor intelligence ingest + seedance2 fixes#1
mtmtian merged 11 commits into
mainfrom
feat/competitor-intel

Conversation

@mtmtian

@mtmtian mtmtian commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Implements PR #1 of the competitor-intel roadmap (docs/growth/09-pipeline-adex-integration.md §3):

  • db: CompetitorCreative + RemixJob Prisma models (docs/growth/07 §4 + 09's segmentPlan addition), purely additive, migration 20260710180000_competitor_intel.
  • api: POST /api/ingest/competitor?org= — HMAC-authed (clones ingest/scenes's pattern), idempotent upsert on (orgId, source, externalId), GCS media handling (link directly if already on our bucket, fetch+upload otherwise). GET /api/competitors — session-scoped listing (requireAuthWithOrg, never trusts client orgId), filter by appName/level, order by adDays/impressions.
  • platform: two seedance2.ts fixes found via real Ark calls — read the generated video URL from content[].video_url.url first (was assuming output.video_url), and round duration to whole seconds (Ark 400s on a fractional value).
  • test: e2e/competitor-ingest.spec.ts — HMAC happy path (create then idempotent update), bad-signature 401, GET /api/competitors auth requirement + listing.
  • docs: adds docs/growth/07-competitor-intel-remix.md, 08-competitor-creative-pipeline.md, 09-pipeline-adex-integration.md (this PR's spec).

Verification results

  • npm run lint — 0 errors, 34 pre-existing warnings (none from this diff).
  • npx tsc --noEmit — clean.
  • npm run build — succeeds; /api/ingest/competitor and /api/competitors both registered as dynamic routes.
  • npx prisma validate — schema valid.
  • Migration generated via prisma migrate diff --from-schema <pre-change schema> --to-schema prisma/schema.prisma --script (no local Postgres available in the worktree — see .claude/rules/schema.md's Cloud SQL/shadow-DB constraints). Pure additive DDL (2 CREATE TABLE, indexes, FKs to Organization).
  • e2e/smoke.spec.ts — 14/14 pass locally (no regression).
  • e2e/competitor-ingest.spec.ts — could not run to completion locally (no DATABASE_URL/live Postgres in this environment). Ran it anyway to confirm wiring: it fails at the expected DATABASE_URL is not set boundary inside /api/auth/register, and the one test that doesn't need a DB (GET /api/competitors requires a logged-in session → 401) passes. Needs a real run against a live DB before merge sign-off.

Scope notes

Per docs/growth/09 §3, explicitly not in this PR: daily-sync cron (Phase 3), the Remix engine API (/api/creatives/remix, Phase 2), competitor panel UI (next PR).

🤖 Generated with Claude Code

mtmtian added 11 commits July 10, 2026 17:47
Adds the competitor-intel data model per docs/growth/07 §4 (+ 09 §3's
segmentPlan addition): CompetitorCreative caches AppGrowing ad metadata
and AI analysis, idempotent on (orgId, source, externalId); RemixJob is
a queryable ledger for turning competitor creatives into our-own-IP
assets (not wired to an engine yet — Phase 2). Purely additive, both
relate to Organization like the rest of the schema.

Migration generated via `prisma migrate diff --from-schema <base> --to-schema
prisma/schema.prisma --script` (no local Postgres available in this
worktree — see .claude/rules/schema.md).
POST /api/ingest/competitor?org=<orgId> — clones the ingest/scenes
HMAC + idempotent-upsert pattern for competitor ad creatives pushed by
the local creative-pipeline/AppGrowing bridge. Accepts a single item or
an array; mediaUrl already on our GCS bucket is linked directly, an
external mediaUrl is fetched once and uploaded to GCS. Per-item
created/updated/failed result. Connector parsing/mapping logic lives in
src/lib/platforms/appgrowing.ts so the ingest source is isolated from
the route, per docs/growth/07 §5.

GET /api/competitors — session-scoped listing (requireAuthWithOrg,
never trusts a client orgId), filterable by appName/level, orderable
by adDays/impressions.

Ref: docs/growth/09-pipeline-adex-integration.md §3
Real-world doubao-seedance-2-0-260128 task responses return the
generated video URL at content[].video_url.url, not output.video_url —
add resolveVideoUrl() that prefers content, falls back to output for
older/other model shapes. Also round the requested duration to whole
seconds before submitting to Ark; a fractional duration gets a 400
InvalidParameter.

Found via first real Seedance2 calls in the local creative-pipeline
tooling (commit e06e215/1224f1c).

Ref: docs/growth/09-pipeline-adex-integration.md §3
Register→login→GET /api/orgs→POST /api/ingest/competitor round trip:
HMAC-signed ingest creates then idempotently updates a
CompetitorCreative row, a bad signature 401s, GET /api/competitors
requires a session and reflects the upsert. playwright.config.ts's
webServer.env gains INVITE_CODES_DISABLED + INGEST_WEBHOOK_SECRET so
the spec can self-provision a user/org and sign requests without a
PlatformAuth row.

Verified the code path against a build with no DATABASE_URL (fails at
the expected `DATABASE_URL is not set` boundary, confirming
registration/ingest wiring is correct) — no live Postgres was
available in this worktree to run the full assertions end-to-end.
Adds the design docs this PR implements: 07 (competitor-intel + remix
proposal, incl. the CompetitorCreative/RemixJob model proposal), 08
(creative pipeline stages), 09 (this PR's scope — pipeline↔adex
integration, PR #1 boundaries).
WHY: product decision — the whole pipeline lands on adex, not local
tooling. Control plane stays in the Next.js service; minute-scale
audio/video batch work moves to a Cloud Run Jobs worker whose image
packages the already-verified creative-pipeline scripts. Adds
per-stage design table, PR roadmap (#1-oratis#4), cost + model-tier policy.
WHY: verified against live task cgt-20260710153030-s5t5x — real
doubao-seedance-2-0-260128 returns content as an OBJECT {video_url:string}
(not an array) and duration at TOP LEVEL. Prior resolveVideoUrl threw
TypeError (content.find is not a function), leaving Assets stuck
'generating' — the PR's core fix did not work against the target model.
WHAT: resolveVideoUrl handles object/array/legacy shapes (array form skips
echoed reference_video inputs); resolveDuration reads top-level→output→
usage; generate route rounds duration before the Int? persist (was a 500
after a paid Ark job started).
WHY: code review — the competitors list filtered level via a rawMeta JSON
path that nothing writes (dead filter), while its sibling segmentPlan got
a real column. level is the primary routing axis (09 §3), so it warrants a
first-class indexed column, not an untyped blob key.
WHY/WHAT (xhigh review findings):
- SSRF: ingest fetched caller-supplied mediaUrl with no validation → new
  storage.uploadFromUrl blocks non-http(s) + private/link-local hosts.
- OOM/timeout: whole video buffered, unbounded batch → 100MB cap + 50-item
  batch cap (413).
- competitors list masked all errors as empty 200 → returns 500 on failure;
  level filter now hits the real column.
- parseHumanNumber fixes impressions/adDays silently dropping '710.4K' /
  '1,942' (the ranking signals); mergeRawMeta no longer corrupts array
  payloads; asset type prefers content-type over extension; isOwnGcsUrl +
  gcsPublicPrefix consolidated into storage.ts (was duplicated).
WHY: register is rate-limited 5/hour/IP; registering per-test flaked across
retries/reruns. Register once in beforeAll and share the authed context;
the anon test uses the default unauthenticated request fixture (config
baseURL, no hardcoded localhost).
@mtmtian mtmtian merged commit bf1c252 into main Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant