Two prompts, one per repo this feature touches:
Backend (nstore-ondc-buyer) — teach the banner query to evaluate UTM exclude mode.
CMS Dashboard (ondc-buyer-dashboard) — turn the UTM field into a None/Include/Exclude control and persist the mode.
No frontend (mall4all) prompt — the buyer app already sends utm_source to the banner endpoint; include vs exclude is decided entirely server-side, so there is zero frontend change.
Shared Contract — the utm_source scope entry
Both repos agree on one new field. A banner/slot’s scope array carries at most one utm_source entry:
None → no utm_source entry in scope at all Include → { type: “utm_source”, mode: “include”, values: [“YoutubeAds”, “Meta”], sequence_number: N } Exclude → { type: “utm_source”, mode: “exclude”, values: [“BFLApp”], sequence_number: N }
New field is mode, value “include” or “exclude”, living on the utm_source scope entry — NOT a banner-level field. It must stay independent of the banner-wide visibilityMode that governs city/pincode.
Back-compat: banners created before this feature have a utm_source entry with no mode. A missing mode means “include” everywhere — existing inclusion banners must not change behaviour.
Values are stored and compared case-insensitively (unchanged from inclusion).
One mode at a time is structural: a banner has a single utm_source entry, so it is either include OR exclude OR absent — never both.
Backend Prompt
Repo: nstore-ondc-buyer (Koa 3 / Node 20 / MongoDB native driver, aggregation pipelines). Baseline: nstore-ondc-buyer @ eba5c45. Re-verify file paths if your checkout differs.
If the PRD is attached, read it; otherwise this prompt is self-sufficient. Propose a plan and wait for approval, then implement fully and self-verify against DONE WHEN.
WHAT WE’RE BUILDING
Banners can already be included by utm_source (show only to listed sources). We’re adding the inverse: exclude — show to everyone except listed sources.
A banner like “Visit the BFL app” must be hidden from users who arrived with utm_source=BFLApp, and shown to everyone else — including users with no/unknown UTM.
This is a pure read-query change: make the banner-matching aggregation honour an exclude mode on the utm_source scope entry. No writes, no new endpoint.
DECISIONS (resolved — do not re-litigate)
The mode lives on the utm_source scope entry as mode: “include” | “exclude” (see Shared Contract). It is independent of the banner-level visibilityMode field — do not route UTM through visibilityMode.
Missing mode means include (back-compat for existing inclusion banners).
A user with no/expired utm_source (the param is absent) is not excluded — exclude-mode banners still render for them. UNKNOWN is never an excluded source.
Scope is routes/partner.route.js only. routes/cms.route.js does not receive utm_source and is out of scope — do not touch it.
HOW THE CODEBASE WORKS (the slice that matters)
GET /partners/cms/v2 (routes/partner.route.js) builds a Mongo aggregation that $lookups banners from cms_bannersV2 and filters them by the request’s city, area_code, device, and utm_source.
City and pincode filtering uses the inline scopeFilter(type, values) helper, which already branches on $visibilityMode (inclusive vs exclusive). That is the city/pincode mechanism — leave it alone.
utm_source is filtered by a separate, hand-rolled $or block inside the $lookup pipeline’s $expr.$and — the block whose conditions reference $$s.type == ‘utm_source’ and utm_source.toLowerCase(). Today this block is inclusion-only; it has no exclude branch. This block is the entire change.
The request param is utm_source (a string, optional). utmValues and utm_source are already in scope where the block is built.
Pattern to follow for the exclude branch: the else arm of scopeFilter’s $cond — { $not: { $in: [lowerVal, lowerScopeValues] } }. Mirror that style.
WHAT TO BUILD
Rewrite the utm_source $or block so the banner passes the UTM gate when any of these hold:
No active UTM filter — there is no utm_source scope entry with a non-empty values array. Keep the existing Case-A subexpression exactly as-is.
Include pass — a utm_source entry exists with non-empty values, its mode is include (mode == “include” or mode is missing), AND the request utm_source is present and matches a value (case-insensitive). This is today’s behaviour plus a guard that it’s not an exclude entry.
Exclude pass — a utm_source entry exists with non-empty values, its mode == “exclude”, AND the user is not excluded: either the request utm_source is absent, OR it does not match any value (case-insensitive).
For Case 2, use $ifNull on the mode field with a default of “include”, then confirm it is not “exclude” before checking the values match. For Case 3, use $ifNull on the mode field with a default of “include”, confirm it equals “exclude”, then confirm the request utm_source is either absent or not in the values list — use $not $in with $toLower on each stored value.
VERIFY: confirm the exact variable name for the request param at that point in the file (utm_source vs rest.utm_source) and reuse it verbatim — the absent-param branch depends on it being falsy when unset.
Caching: results are keyed by createCacheKey which already includes utm_source. No cache-key change needed — but confirm utm_source is part of the key so include and exclude results don’t collide.
DO NOT TOUCH
scopeFilter() and the city/pincode visibilityMode logic — UTM must not flow through visibilityMode.
routes/cms.route.js (/cms/banners, /cms/bannersV2) — no UTM there by design.
Coupon UTM logic (routes/offer.route.js, routes/wallet.route.js) — out of scope.
The 24h TTL / UTM persistence — that’s frontend, untouched.
EDGE CASES & FAILURE MODES
Exclude + user matches → banner hidden (no placeholder; just absent from the banners array).
Exclude + user does NOT match → banner shown.
Exclude + no/expired utm_source → banner shown (UNKNOWN not excluded).
Include + no utm_source → banner hidden (unchanged).
Legacy entry, no mode → behaves as include (unchanged).
Empty values with a mode set → treated as no active filter (Case 1) → banner shown. CMS prevents saving this, but the query must not crash on it.
Mixed banner (utm exclude + city include on same banner) → each dimension evaluated independently; banner shows only if it passes both gates.
FILES YOU’LL LIKELY TOUCH
routes/partner.route.js — the single utm_source $or block inside the /cms/v2 $lookup pipeline. One file, one block. Anything beyond this is a review flag.
DATA / MIGRATION
No migration. Existing utm_source entries have no mode; the $ifNull defaults handle them. No backfill.
HOW TO VERIFY
Run: npm run dev (node index.js). No jest coverage exists for this pipeline — verify by query.
Seed or locate a partner with a cms_bannersV2 banner whose scope has { type: ‘utm_source’, mode: ‘exclude’, values: [‘bflapp’] } mapped onto a page (e.g. homePage).
Hit the endpoint three ways and confirm the banner’s presence in cms[].banners:
GET /partners/cms/v2?id=PARTNER&page=homePage&utm_source=BFLApp → banner absent.
GET /partners/cms/v2?id=PARTNER&page=homePage&utm_source=YoutubeAds → banner present.
GET /partners/cms/v2?id=PARTNER&page=homePage (no utm) → banner present.
Regression: an existing mode-less include banner still hides for non-matching/no-utm users and shows for matching users. Pass no_cache=true between checks to bypass NodeCache.
DONE WHEN
[ ] Exclude-mode banner does NOT render when request utm_source matches a value (case-insensitive).
[ ] Exclude-mode banner renders when utm_source does not match.
[ ] Exclude-mode banner renders when utm_source is absent/unknown.
[ ] Include-mode and legacy (mode-less) banners behave exactly as before.
[ ] Banner with no utm_source scope entry is unaffected.
[ ] scopeFilter, city/pincode/visibilityMode, and cms.route.js are untouched.
CMS Dashboard Prompt
Repo: ondc-buyer-dashboard (Nuxt 3 / Vue 3 / NuxtUI — U* components). Baseline: ondc-buyer-dashboard @ a19b0cb. Re-verify file paths if your checkout differs.
If the PRD is attached, read it; otherwise this prompt is self-sufficient. Propose a plan and wait for approval, then implement fully and self-verify against DONE WHEN.
WHAT WE’RE BUILDING
Today the banner form has a single free-text “UTM Source” input that always means include.
Replace it with a three-state control: None / Include / Exclude.
None (default) → no UTM filtering, no source input shown, no utm_source scope entry saved.
Include → show the source input; banner shows only to listed sources (today’s behaviour).
Exclude → show the source input; banner shows to everyone except listed sources.
Persist the choice as mode on the utm_source scope entry (see Shared Contract). Add an info popover so operators understand Include vs Exclude.
DECISIONS (resolved — do not re-litigate)
Mode is stored on the utm_source scope entry as mode: “include” | “exclude”. None means do not push a utm_source entry at all (and on edit, ensure any prior entry is dropped).
This is separate from the existing “Banner visibility mode” (Inclusive/Exclusive) radio that controls city/pincode — leave that control alone; do not reuse it for UTM.
Edit-load of a legacy banner (a utm_source entry with values but no mode) → preselect Include.
Apply to the four banner templates the ops team actually uses: pages/dashboard/category/[id].vue, pages/dashboard/v2/[id].vue, pages/dashboard/allCategoriesV2.vue, pages/dashboard/homePageV2.vue. VERIFY with the lead whether fintasticsHome.vue, demoPages/[id].vue, or EMIV2.vue also need it before widening.
HOW THE CODEBASE WORKS (the slice that matters)
Each template is large (~130KB+) and the banner form logic is duplicated twice: an edit-existing-slot path (state slotData, save fn handleSave, hydrate fn handleEdit) and an add-new-slot path (state newSlot, save fn handleAddSlot). Every change below must be made in BOTH paths in EACH template.
The current UTM input is a UInput bound to utmSource (edit) and newSlot.utmSource (add). Values are comma-separated free text, split and whitespace-stripped on save.
On save, the utm_source scope entry is pushed as { type: ‘utm_source’, values: utmSourceData.filter(x=>x!=‘’), sequence_number }. On edit, handleEdit reads it back via data.scope[i].type == ‘utm_source’ into utmSource.value.
Patterns to imitate (both already in the file):
The “Banner visibility mode” URadio group (URadio bound to slotData.visibilityMode with values ‘inclusive’ / ‘exclusive’) — copy this exact radio pattern for the new None/Include/Exclude control.
The existing UTooltip usage (e.g. UTooltip with text “Add new slots”) — use the same for the info popover.
WHAT TO BUILD (apply in BOTH the add and edit paths, in EACH of the 4 templates)
State — add a UTM mode field defaulting to “none”:
Edit path: a utmMode ref alongside the existing utmSource ref.
Add path: newSlot.utmMode = “none” alongside newSlot.utmSource.
Template — replace the bare UTM input with:
A URadio group bound to the mode: None / Include / Exclude (values “none” / “include” / “exclude”).
An info icon (ⓘ) beside the “UTM source” label using UTooltip with copy:
Include — Banner shows only to users arriving from sources you list.
Exclude — Banner shows to everyone except users arriving from sources you list. Use this to hide the banner from a specific channel.
The existing source UInput, now shown only when mode is not “none”.
An inline error shown when utmError is truthy: “Add at least one source or set UTM filter to None.”
Switching behaviour — watch the mode: when it changes, clear the source value (utmSource / newSlot.utmSource = “”). Selecting None also hides the input.
Validation on save (in handleSave and handleAddSlot) — if mode is “include” or “exclude” and the parsed source list is empty → set the inline error and return (block save). None saves with no validation.
Build the scope entry on save:
Mode “none” → do NOT push a utm_source entry. On the edit path, also ensure any existing utm_source entry is not carried over (the entry must be absent in the saved scope).
Mode “include” or “exclude” → push { type: ‘utm_source’, mode: MODE, values: PARSED_NON_EMPTY_DEDUPED_LIST, sequence_number: as today }. Keep the existing split-and-filter logic that strips empties.
Hydrate on edit (handleEdit) — find the utm_source scope entry:
Present → set utmMode = entry.mode (default to “include” if missing), set utmSource.value = entry.values.toString().
Absent → utmMode = “none”, utmSource.value = “”.
Reset paths — wherever utmSource is reset to “” today (cancel/close/reset), also reset the mode to “none”.
DO NOT TOUCH
The existing “Banner visibility mode” radio and its visibilityMode plumbing (city/pincode).
City / pincode inputs and their scope entries.
The backend — it reads mode off the entry; the CMS only has to write it.
EDGE CASES & FAILURE MODES
Switch Include to Exclude → previous source list cleared; operator re-enters sources for the new mode.
Switch to None from Include/Exclude → input hidden, list cleared, no utm_source entry saved (banner becomes unrestricted on next publish).
Include/Exclude selected, empty list, Save → blocked, inline error shown.
Edit a legacy include banner (entry has values, no mode) → radio preselects Include; saving writes mode: “include” going forward.
Edit a None banner (no entry) → radio shows None, input hidden.
Duplicate / empty / whitespace sources → stripped before save (keep existing filter behaviour); no duplicates persisted.
FILES YOU’LL LIKELY TOUCH
pages/dashboard/category/[id].vue
pages/dashboard/v2/[id].vue
pages/dashboard/allCategoriesV2.vue
pages/dashboard/homePageV2.vue
Per file: template block ×2 for add/edit, plus handleSave, handleAddSlot, handleEdit, and the reset/cancel + reactive-state declarations. New files: none.
DATA / MIGRATION
No migration. Legacy entries (no mode) load as Include and gain mode: “include” only when re-saved. None-state banners simply have no utm_source entry.
HOW TO VERIFY
Run: npm run dev (nuxt dev). No automated coverage — verify in the running dashboard.
For each of the 4 templates, in both Add slot and Edit slot:
Default radio is None, no source input visible.
Select Include → input appears; save with sources → reopen → Include and sources persisted.
Select Exclude → input appears; save with sources → reopen → Exclude and sources persisted; inspect the saved banner’s scope has { type: ‘utm_source’, mode: ‘exclude’, values: […] }.
Select Include or Exclude, leave empty, Save → blocked with inline error.
Switch Include to Exclude → source list clears.
Switch to None → input hides, list clears; save → banner has no utm_source entry.
Open an existing legacy include banner → radio shows Include, sources intact.
Cross-check with the backend: a banner saved as Exclude [‘BFLApp’] is hidden in the buyer app for utm_source=BFLApp and shown otherwise.
DONE WHEN
[ ] UTM control defaults to None on new banner creation; no source input shown.
[ ] Three-state None/Include/Exclude radio; only one selectable.
[ ] Selecting Include/Exclude reveals the source input; None hides it and clears the list.
[ ] Info popover explains Include vs Exclude in plain language.
[ ] Switching Include to/from Exclude clears the previous source list.
[ ] Saving Include/Exclude with an empty list is blocked with the inline error.
[ ] None always saves without error and persists no utm_source entry.
[ ] Saved utm_source entry carries mode = include or exclude; values lowercase-insensitive, no dupes/empties.
[ ] Edit-load preselects the correct mode (legacy → Include; absent → None).
[ ] All four templates updated, both add and edit paths; “Banner visibility mode” radio untouched.
Generated from PRD/utm-banner-exclusion.requirements.md (FINALIZED). Baselines: backend eba5c45, dashboard a19b0cb, frontend 4b3ac14 (no FE change). Zero open decisions.