Skip to content

feat(marketing-analytics): add granular conversion goal write endpoints#73012

Draft
jabahamondes wants to merge 2 commits into
javier/ma-typed-conversion-goalsfrom
javier/ma-conversion-goal-endpoint
Draft

feat(marketing-analytics): add granular conversion goal write endpoints#73012
jabahamondes wants to merge 2 commits into
javier/ma-typed-conversion-goalsfrom
javier/ma-conversion-goal-endpoint

Conversation

@jabahamondes

Copy link
Copy Markdown
Contributor

Note

Fourth in a stack, on top of #72996#72798#72301.

Problem

There is no way to change one conversion goal. The only write path is PATCH /api/projects/:id/ with the entire marketing_analytics_config, which means:

  • Last write wins on the whole list. Two people editing different goals in two tabs, and one of them silently loses their goal. The UI does read-modify-write on the full array today, so this is reachable now, not hypothetical.
  • An API client has to send back goals it never touched, which means fetching, mutating and reposting a blob it may not fully understand.
  • The scope is wrong. Editing a conversion goal requires project:write, which also grants every other project setting.

Changes

Three actions on the existing marketing analytics viewset, all behind marketing_analytics:write (a scope that already existed and had no endpoints using it):

Method Path Does
POST conversion_goals/create Appends one goal, server assigns the id
PATCH conversion_goals/{id}/update Merges fields into one goal, keeps its position
DELETE conversion_goals/{id}/delete Removes one goal

Each returns the written goal plus the full list after the write, so a client can reconcile without a second request.

Three things worth reviewing:

Row lock. Every write runs in a transaction with select_for_update() on the config row. The goals live in a JSON column, so a concurrent read-modify-write would otherwise drop one of the two edits. This is the main reason the endpoint exists.

Validation against the schema. The body is validated with a pydantic TypeAdapter over the three ConversionGoalFilter shapes, so a malformed goal is a 400 instead of a corrupt row that only explodes later at query time. Union errors are collapsed into one readable message per field, since raw pydantic reports one error per union member.

It writes the column, not the setter. Flagging this because it looks wrong at first glance:

# already validated against the schema models above
config._conversion_goals = goals

The conversion_goals setter runs validate_conversion_goals, which predates the schema models and requires a top-level name on every goal. The schema treats name as optional (conversion_goal_name is the required one), so the legacy validator rejects goals that are perfectly valid per the schema. Rather than degrade the new endpoint to satisfy a stale rule, this path validates against the schema itself, which is strictly stronger. Realigning validate_conversion_goals with the schema is worth doing and belongs in its own PR - it affects the existing PATCH path and could reject configs that are live today, so it needs its own blast-radius discussion.

The route names carry the /create, /update and /delete suffixes because the collection path conversion_goals is already taken by the existing read action, and DRF routers cannot dispatch two actions that differ only by HTTP method on the same path.

How did you test this code?

13 new tests in products/marketing_analytics/backend/test_api_conversion_goals.py, all passing, plus the existing config suite (8 passed) and spectacular --fail-on-warn (exit 0). All run by me (Claude); I did not click through this in the UI.

What each group is actually guarding, since a test that does not name its regression should not be here:

  • Create appends, update and delete touch one goal - the whole point of the endpoint. If someone reimplements these as a blob overwrite, these fail.
  • Update does not report a goal as a duplicate of itself - the uniqueness check has to skip the row being updated. I got this wrong on the first pass; without the test it would have shipped as "you cannot save a goal twice".
  • Malformed goal is a 400 and nothing is persisted (5 parameterized cases) - guards the validation being swapped for a passthrough.
  • Unknown id is a 404 (2 parameterized cases) - guards silently creating or no-oping instead.

Two cases I wrote and then deleted, because they asserted my assumptions rather than real behavior: kind has a default so omitting it is valid, and pydantic coerces "yes" to True in lax mode. Both were my error, not the code's.

Not covered: concurrent writes racing on the lock. Exercising it needs committed transaction boundaries and a second connection, which means TransactionTestCase and real threads - expensive and flaky for a guarantee Postgres provides. The lock is one line and visible in review.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Nothing to update yet. Once the MCP write tools land on top of this, the marketing analytics docs should mention that goals are writable via API.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

I directed this, Claude wrote it. Invoked /improving-drf-endpoints (schema annotations, help_text, where to register team-nested routes) and /writing-tests (which is what forced the "name the regression" pass on the test list above, and cut two tests that did not earn it).

Design notes. A dedicated ConversionGoalViewSet under its own route was the first idea, rejected because this product still registers through the legacy dual-route helper and a nested prefix would have risked colliding with it; actions on the existing viewset keep the routing boring. The response returns both the single goal and the full list because the caller usually wants to re-render the whole list, and a second round trip to fetch it would be wasted.

The _conversion_goals write is the one deliberate wart, explained above. Next step in this line is the MCP write tools sitting on these endpoints.

@jabahamondes jabahamondes self-assigned this Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Bundle size — no change

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 64.22 MiB · no change

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.23 MiB · 22 files no change ███░░░░░░░ 28.6% of 4.29 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.16 MiB · 2,988 files no change █████████░ 88.2% of 9.25 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
281.3 KiB ../node_modules/.pnpm/posthog-js@1.404.0/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
235.5 KiB src/taxonomy/core-filter-definitions-by-group.json
223.2 KiB ../node_modules/.pnpm/posthog-js@1.404.0/node_modules/posthog-js/dist/module.js
164.0 KiB src/queries/validators.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.8 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
93.2 KiB ../packages/quill/packages/quill/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Dist folder size — no change

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1341.59 MiB · no change

ℹ️ MCP UI apps size — 31 app(s), 16602.7 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 599.5 KB 187.4 KB
action 457.8 KB 187.4 KB
action-list 564.3 KB 187.4 KB
cohort 456.8 KB 187.4 KB
cohort-list 563.3 KB 187.4 KB
email-template 456.6 KB 187.4 KB
error-details 472.4 KB 187.4 KB
error-issue 457.5 KB 187.4 KB
error-issue-list 564.2 KB 187.4 KB
experiment 561.5 KB 187.4 KB
experiment-list 565.1 KB 187.4 KB
experiment-results 563.2 KB 187.4 KB
feature-flag 567.1 KB 187.4 KB
feature-flag-list 570.9 KB 187.4 KB
feature-flag-testing 461.0 KB 187.4 KB
insight-actors 562.1 KB 187.4 KB
invite-email-preview 456.0 KB 187.4 KB
llm-costs 559.5 KB 187.4 KB
session-recording 458.6 KB 187.4 KB
session-summary 463.9 KB 187.4 KB
survey 458.4 KB 187.4 KB
survey-global-stats 562.2 KB 187.4 KB
survey-list 565.0 KB 187.4 KB
survey-stats 562.2 KB 187.4 KB
trace-span 457.2 KB 187.4 KB
trace-span-list 564.2 KB 187.4 KB
workflow 457.1 KB 187.4 KB
workflow-list 563.7 KB 187.4 KB
query-results 744.8 KB 187.4 KB
render-ui 825.3 KB 187.4 KB
visual-review-snapshots 461.6 KB 187.4 KB

@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

jabahamondes and others added 2 commits July 22, 2026 18:57
Adds create, update and delete for a single conversion goal, scoped by
marketing_analytics:write. Until now the only way to change a goal was to PATCH
the whole config blob, which overwrites concurrent edits and gives an API client
no way to touch one goal. Validates against the schema models and takes a row
lock so two writers can't clobber each other.
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