Banners Targeting Engine
Banners Targeting Engine
The Banners module includes a server-side contextual targeting engine. When a public serving request arrives at GET /api/banners/serve/:slug, the engine extracts a targeting context from the HTTP request headers and evaluates all active targeting rules for each candidate campaign. A campaign passes only if ALL of its rules evaluate to true for the given context.
1. Targeting Context Extraction
Context is extracted entirely from HTTP request headers — no client-sent body data. The implementation is in BannerServingService.buildTargetingContext(req).
| Dimension | Source Header | Extraction Logic |
|---|---|---|
deviceType | User-Agent | Regex: /iPad|Android(?!.*Mobile)/i → "tablet"; /Mobile|Android|iPhone|iPod/i → "mobile"; otherwise → "desktop". Empty UA → "desktop". |
countryCode | x-country-code (fallback: cf-ipcountry) | First non-null value. null if both absent. |
isLoggedIn | Authorization | Presence of Authorization: Bearer ... header → true. Token is NOT verified. |
language | Accept-Language | Primary tag: split on ,, take first, split on [-;], lowercase. null if absent. |
isNewVisitor | x-device-id (or x-session-id) | Absence of both headers → true. Presence of either → false. |
referrerDomain | Referer | new URL(referer).hostname. null if absent or unparseable. |
hourOfDay | server clock | new Date().getHours() (local time) |
dayOfWeek | server clock | new Date().getDay() (0=Sunday, 6=Saturday) |
userSegment is defined in the type but always null — the user_segment rule type evaluation is deferred to SP5.
2. Targeting Context Type
interface TargetingContext {
deviceType: "mobile" | "tablet" | "desktop";
countryCode: string | null;
isLoggedIn: boolean;
language: string | null;
hourOfDay: number; // 0–23, local
dayOfWeek: number; // 0=Sunday, 6=Saturday, local
isNewVisitor: boolean;
referrerDomain: string | null;
}3. Rule Types
The implementation lives in BannerServingService.evaluateSingleRule(rule, ctx). Each rule has a ruleType, operator, and value (jsonb). 9 rule types are defined in the targeting_rule_type enum:
ruleType | Value type | Valid operators |
|---|---|---|
device | string[] (e.g., ["mobile", "tablet"]) | in, not_in |
country | string[] (ISO 3166-1 alpha-2, uppercase) | in, not_in |
user_segment | any | (any operator — always returns true) |
login_state | boolean | eq, not_in |
language | string[] (lowercased) | in, not_in |
hour_of_day | number or [number, number] | between, eq, lt, gt |
day_of_week | number[] (0=Sunday, 6=Saturday) | in, not_in |
new_visitor | boolean | eq |
referrer_domain | string[] (substring match) | in, not_in, contains |
3.1 device
| Example Rule | Passes When |
|---|---|
operator: "in", value: ["mobile", "tablet"] | Device is mobile or tablet |
operator: "not_in", value: ["desktop"] | Device is not desktop |
3.2 country
| Example Rule | Passes When |
|---|---|
operator: "in", value: ["NP", "IN"] | Country is Nepal or India (uppercase compare) |
operator: "not_in", value: ["US"] | Country is not USA |
Null behavior: if countryCode is null, the rule passes (returns true). This is a fail-open behavior — campaigns with country: in [...] rules will NOT be filtered out for requests without a country header.
3.3 login_state
| Example Rule | Passes When |
|---|---|
operator: "eq", value: true | Client sent a Bearer Authorization header |
operator: "not_in", value: true | Client did NOT send a Bearer token |
3.4 language
| Example Rule | Passes When |
|---|---|
operator: "in", value: ["ne", "en"] | Primary Accept-Language is Nepali or English |
operator: "not_in", value: ["zh"] | Language is not Chinese |
Null behavior: if language is null, the rule passes (fail-open).
3.5 hour_of_day
| Example Rule | Passes When |
|---|---|
operator: "between", value: [9, 17] | Current hour is 9 through 17 inclusive |
operator: "lt", value: 6 | Current hour < 6 (night) |
operator: "gt", value: 20 | Current hour > 20 (late evening) |
operator: "eq", value: 12 | Current hour is exactly 12 |
3.6 day_of_week
| Example Rule | Passes When |
|---|---|
operator: "in", value: [1, 2, 3, 4, 5] | Weekday (Mon–Fri) |
operator: "in", value: [0, 6] | Weekend |
operator: "not_in", value: [0] | Not Sunday |
3.7 new_visitor
| Example Rule | Passes When |
|---|---|
operator: "eq", value: true | No x-device-id AND no x-session-id header present |
operator: "eq", value: false | Either header present |
3.8 referrer_domain
| Example Rule | Passes When |
|---|---|
operator: "in", value: ["google.com"] | referer hostname contains "google.com" (substring) |
operator: "not_in", value: ["nomor.tech"] | referer does NOT contain "nomor.tech" |
operator: "contains", value: ["facebook"] | Same as in — substring match |
Null behavior: if referrerDomain is null, returns true if operator is not_in, otherwise returns false (i.e., in and contains return false for absent referrer).
3.9 user_segment
Always returns true. Defined in schema and accepted via API. No filtering at this stage. Future implementation will resolve user segment from session/customer service and evaluate against a stored segment list.
4. Evaluation Algorithm
For each active campaign C:
rules = campaign_targeting_rules WHERE campaign_id = C.id AND is_active = true
If rules is empty:
→ campaign PASSES (serve to everyone)
For each rule R in rules:
result = evaluateSingleRule(R, context)
If result is FALSE:
→ campaign FAILS (skip; not included in banners)
If all rules passed:
→ campaign PASSESAND logic: a campaign with 3 rules passes only if all 3 pass. There is no OR grouping at the rule level. For OR-style targeting, create two separate campaigns.
Fail-open on exceptions: if evaluateSingleRule throws (e.g., malformed value jsonb), the rule is treated as passing and a warn is logged:
try { /* evaluation */ } catch {
this.logger.warn(`Targeting rule evaluation failed ruleType=${ruleType} operator=${operator}`);
return true; // fail-open
}5. Operator–RuleType Compatibility Matrix
Validated by TargetingAdminService.VALID_OPERATORS (module-level constant) when creating or updating a rule. Throws TARGETING_RULE_INVALID_OPERATOR on mismatch.
| Rule Type | eq | not_in | in | between | lt | gt | contains |
|---|---|---|---|---|---|---|---|
device | — | ✓ | ✓ | — | — | — | — |
country | — | ✓ | ✓ | — | — | — | — |
login_state | ✓ | ✓ | — | — | — | — | — |
language | — | ✓ | ✓ | — | — | — | — |
hour_of_day | ✓ | — | — | ✓ | ✓ | ✓ | — |
day_of_week | — | ✓ | ✓ | — | — | — | — |
new_visitor | ✓ | — | — | — | — | — | — |
referrer_domain | — | ✓ | ✓ | — | — | — | ✓ |
user_segment | (any) | (any) | (any) | (any) | (any) | (any) | (any) |
6. Targeting Hash and Cache Key Segmentation
Different clients receive different banners depending on their targeting context. Caching a single response per placement slug would serve the wrong banners to users with different contexts.
The serving endpoint uses a targeting hash to create per-context cache segments:
const str = [
ctx.deviceType,
ctx.countryCode ?? "",
ctx.isLoggedIn ? "1" : "0",
ctx.language ?? "",
ctx.hourOfDay,
ctx.dayOfWeek,
ctx.isNewVisitor ? "1" : "0",
ctx.referrerDomain ?? "",
].join("|");
const hash = createHash("sha256").update(str).digest("hex").slice(0, 16);
const cacheKey = CacheKeyUtil.build("banners:serve", [
["slug", slug],
["ctx", hash],
]);Examples:
- Mobile user in Nepal:
banners:serve:homepage-hero:a3f9c1d4... - Desktop user in India:
banners:serve:homepage-hero:7b2e8a91...
Each is independently cached and invalidated.
16-character hex prefix provides 2^64 possible hash values — negligible collision probability.
7. Cache Invalidation After Schedule Sync
When the SYNC_SCHEDULE_STATUS BullMQ job runs and changes any banner or campaign status, the serving cache may contain stale data. The processor calls:
await redisCacheService.invalidatePattern("banners:serve:*");This invalidates all serving cache entries for all placements and all contexts simultaneously. The next request to any placement re-queries the database.
Failure handling: invalidatePattern errors are caught and logged as warn — not rethrown. The 60-second TTL (BANNER_SERVE_CACHE_TTL_SECONDS) ensures eventual consistency.
8. Adding New Rule Types
To add a new targeting rule type:
- Add the new value to the
targeting_rule_typeenum inpackages/db/src/schema/banners/targeting.ts - Generate and run a Drizzle migration
- Add an entry to the
VALID_OPERATORSmap inTargetingAdminService(for validation) - Add a
casetoevaluateSingleRule()inBannerServingService - Update
TargetingAdminService.validateOperators()if the rule has value-shape constraints - Add a unit test in
__tests__/unit/banner-serving.service.unit.spec.tscovering the new rule type - Update this documentation with the new rule type spec
No changes are required to the campaign_targeting_rules schema itself — the value jsonb column is flexible.