@affset/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/client.js +131 -0
- package/dist/config.js +100 -0
- package/dist/index.js +21 -0
- package/dist/lib/format.js +148 -0
- package/dist/lib/integrationUrls.js +128 -0
- package/dist/lib/linkArgs.js +53 -0
- package/dist/lib/patch.js +23 -0
- package/dist/lib/payoutRules.js +46 -0
- package/dist/lib/targeting.js +246 -0
- package/dist/lib/time.js +234 -0
- package/dist/lib/toolResult.js +35 -0
- package/dist/lib/urls.js +15 -0
- package/dist/lib/zones.js +83 -0
- package/dist/server.js +316 -0
- package/dist/tools/createCampaign.js +211 -0
- package/dist/tools/createZone.js +117 -0
- package/dist/tools/cutZones.js +224 -0
- package/dist/tools/deletePayoutRule.js +70 -0
- package/dist/tools/getStats.js +72 -0
- package/dist/tools/getTrackingLink.js +119 -0
- package/dist/tools/getZoneUrl.js +94 -0
- package/dist/tools/listCampaigns.js +81 -0
- package/dist/tools/listConversions.js +237 -0
- package/dist/tools/listPayoutRules.js +72 -0
- package/dist/tools/listSubLabels.js +31 -0
- package/dist/tools/listTargetingRules.js +69 -0
- package/dist/tools/listTargetingTypes.js +39 -0
- package/dist/tools/listTeam.js +57 -0
- package/dist/tools/listZones.js +70 -0
- package/dist/tools/removeTargetingRule.js +109 -0
- package/dist/tools/setCampaignStatus.js +32 -0
- package/dist/tools/setPayoutGoal.js +69 -0
- package/dist/tools/setPayoutRule.js +119 -0
- package/dist/tools/setSubLabels.js +102 -0
- package/dist/tools/setTargetingRule.js +125 -0
- package/dist/tools/updateCampaign.js +218 -0
- package/dist/tools/updateZone.js +118 -0
- package/dist/tools/whoami.js +42 -0
- package/dist/types.js +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { SUB_KEYS } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Escape a value for a Markdown table cell. Pipes/newlines break table layout;
|
|
4
|
+
* backticks and brackets are escaped too since these cells often carry
|
|
5
|
+
* traffic-source-controlled strings (zone names, sub values) rendered straight
|
|
6
|
+
* into the model's context — unescaped backticks let that data break out of its
|
|
7
|
+
* cell and forge what reads like a fenced code block or inline instruction.
|
|
8
|
+
*/
|
|
9
|
+
export function mdCell(value) {
|
|
10
|
+
return value
|
|
11
|
+
.replace(/\|/g, "\\|")
|
|
12
|
+
.replace(/`/g, "\\`")
|
|
13
|
+
.replace(/\[/g, "\\[")
|
|
14
|
+
.replace(/\r?\n/g, " ")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_UNTRUSTED_CAP = 500;
|
|
18
|
+
/**
|
|
19
|
+
* Truncate a value that came from outside the tenant's own dashboard — sub values,
|
|
20
|
+
* zone/campaign names on rows the API returned, conversion payloads — before it
|
|
21
|
+
* reaches the model's context. These fields round-trip through public, unauthenticated
|
|
22
|
+
* endpoints (a click, a conversion pixel), so nothing bounds their length or content
|
|
23
|
+
* on the way in; without a cap here, one long field could bury an injected
|
|
24
|
+
* instruction past whatever a client renders or a reviewer reads.
|
|
25
|
+
*/
|
|
26
|
+
export function capUntrusted(value, max = DEFAULT_UNTRUSTED_CAP) {
|
|
27
|
+
if (value.length <= max)
|
|
28
|
+
return value;
|
|
29
|
+
return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`;
|
|
30
|
+
}
|
|
31
|
+
/** Format a number as USD, e.g. 1234.5 -> "$1,234.50". */
|
|
32
|
+
export function money(n) {
|
|
33
|
+
if (n === undefined || n === null)
|
|
34
|
+
return "—";
|
|
35
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Format a USD amount that can be smaller than a cent. Payout rules accept down
|
|
39
|
+
* to $0.00001 and push/mVAS payouts really do sit there, where `money()`'s two
|
|
40
|
+
* decimals would render $0.005 as "$0.00" — a preview the operator would confirm
|
|
41
|
+
* believing it said something else. Ordinary amounts still show two decimals.
|
|
42
|
+
*/
|
|
43
|
+
export function moneyPrecise(n) {
|
|
44
|
+
if (n === undefined || n === null)
|
|
45
|
+
return "—";
|
|
46
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 5 })}`;
|
|
47
|
+
}
|
|
48
|
+
/** Format a fraction as a percentage, e.g. 0.0145 -> "1.45%". */
|
|
49
|
+
export function pct(fraction) {
|
|
50
|
+
return `${(fraction * 100).toFixed(2)}%`;
|
|
51
|
+
}
|
|
52
|
+
/** Format ROI (a fraction) with sign, e.g. 0.35 -> "+35%". Null -> "—". */
|
|
53
|
+
export function roi(fraction) {
|
|
54
|
+
if (fraction === undefined || fraction === null)
|
|
55
|
+
return "—";
|
|
56
|
+
const sign = fraction >= 0 ? "+" : "";
|
|
57
|
+
return `${sign}${(fraction * 100).toFixed(0)}%`;
|
|
58
|
+
}
|
|
59
|
+
/** Conversion rate for a row (conversions / clicks), 0 when no clicks. */
|
|
60
|
+
export function conversionRate(row) {
|
|
61
|
+
return row.clicks > 0 ? row.conversions / row.clicks : 0;
|
|
62
|
+
}
|
|
63
|
+
/** The human label for a row given the grouping dimension. */
|
|
64
|
+
export function rowLabel(row, groupBy) {
|
|
65
|
+
switch (groupBy) {
|
|
66
|
+
case "date":
|
|
67
|
+
return row.date ?? "—";
|
|
68
|
+
case "campaign_id":
|
|
69
|
+
return row.campaign_name ?? row.campaign_id ?? "—";
|
|
70
|
+
case "zone_id":
|
|
71
|
+
return row.zone_name ?? row.zone_id ?? "—";
|
|
72
|
+
case "country":
|
|
73
|
+
return row.country || "(none)";
|
|
74
|
+
case "conversion_type":
|
|
75
|
+
// From the conversion pixel's `type=` query param — a public, unauthenticated
|
|
76
|
+
// endpoint, so this is attacker-controlled free text, not a tenant setting.
|
|
77
|
+
return capUntrusted(row.conversion_type || "(none)");
|
|
78
|
+
case "publisher_email":
|
|
79
|
+
return row.publisher_email || "(none)";
|
|
80
|
+
default:
|
|
81
|
+
// sub1..sub5 — attributed from click/pixel query params, same trust level.
|
|
82
|
+
return capUntrusted(row[groupBy] || "(none)");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const HEADER_BY_GROUP = {
|
|
86
|
+
date: "Date",
|
|
87
|
+
campaign_id: "Campaign",
|
|
88
|
+
zone_id: "Zone",
|
|
89
|
+
country: "Country",
|
|
90
|
+
conversion_type: "Conv. type",
|
|
91
|
+
publisher_email: "Publisher",
|
|
92
|
+
sub1: "sub1",
|
|
93
|
+
sub2: "sub2",
|
|
94
|
+
sub3: "sub3",
|
|
95
|
+
sub4: "sub4",
|
|
96
|
+
sub5: "sub5",
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Column title for a grouping: for sub1..sub5 the tenant's label (from the stats
|
|
100
|
+
* response `sub_labels`) with the raw key appended for addressability —
|
|
101
|
+
* "Zone (sub1)" — since filters/group_by still take the raw key. Raw key otherwise.
|
|
102
|
+
*/
|
|
103
|
+
export function groupHeader(groupBy, subLabels) {
|
|
104
|
+
if (SUB_KEYS.includes(groupBy)) {
|
|
105
|
+
const label = subLabels?.[groupBy]?.trim();
|
|
106
|
+
if (label)
|
|
107
|
+
return `${label} (${groupBy})`;
|
|
108
|
+
}
|
|
109
|
+
return HEADER_BY_GROUP[groupBy];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Render grouped stats as a Markdown table with a totals row. CR is derived
|
|
113
|
+
* client-side; ROI comes from the API (blank until media_cost is populated).
|
|
114
|
+
*/
|
|
115
|
+
export function formatStatsTable(rows, groupBy, subLabels) {
|
|
116
|
+
if (rows.length === 0)
|
|
117
|
+
return "_No data for this period._";
|
|
118
|
+
const header = groupHeader(groupBy, subLabels);
|
|
119
|
+
const lines = [
|
|
120
|
+
`| ${header} | Impr | Clicks | Conv | CR | Payout | Cost | ROI |`,
|
|
121
|
+
"|---|--:|--:|--:|--:|--:|--:|--:|",
|
|
122
|
+
];
|
|
123
|
+
let tImpr = 0;
|
|
124
|
+
let tClicks = 0;
|
|
125
|
+
let tConv = 0;
|
|
126
|
+
let tPayout = 0;
|
|
127
|
+
let tCost = 0;
|
|
128
|
+
let anyCost = false;
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
tImpr += row.impressions ?? 0;
|
|
131
|
+
tClicks += row.clicks;
|
|
132
|
+
tConv += row.conversions;
|
|
133
|
+
tPayout += row.payout ?? 0;
|
|
134
|
+
tCost += row.media_cost ?? 0;
|
|
135
|
+
if ((row.media_cost ?? 0) > 0)
|
|
136
|
+
anyCost = true;
|
|
137
|
+
lines.push(`| ${mdCell(rowLabel(row, groupBy))} | ${row.impressions ?? 0} | ${row.clicks} | ${row.conversions} | ${pct(conversionRate(row))} | ${money(row.payout)} | ${money(row.media_cost)} | ${roi(row.roi)} |`);
|
|
138
|
+
}
|
|
139
|
+
const totalCr = tClicks > 0 ? tConv / tClicks : 0;
|
|
140
|
+
const totalRoi = anyCost && tCost > 0 ? (tPayout - tCost) / tCost : null;
|
|
141
|
+
lines.push(`| **Total** | **${tImpr}** | **${tClicks}** | **${tConv}** | **${pct(totalCr)}** | **${money(tPayout)}** | **${money(tCost)}** | **${roi(totalRoi)}** |`);
|
|
142
|
+
if (!anyCost) {
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push("_Cost/ROI are blank — media_cost not populated for this slice._");
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two URLs a media buyer hands to a traffic source, and the base they hang off.
|
|
3
|
+
*
|
|
4
|
+
* - **Zone URL** — `/serve/{zone_id}`. The network sends traffic here and affset
|
|
5
|
+
* picks a campaign out of the zone's rotation (active campaigns only).
|
|
6
|
+
* - **Tracking link** — `/track/click/{campaign_id}/{zone_id}`. Straight to one
|
|
7
|
+
* campaign, no rotation and no targeting checks. The campaign and zone still have
|
|
8
|
+
* to be active because the public tracking path reads the active-serving cache.
|
|
9
|
+
*
|
|
10
|
+
* Both carry the same query convention: `source_click_id` (the network's own click
|
|
11
|
+
* token, echoed back on postback) plus the five analytics sub slots.
|
|
12
|
+
*/
|
|
13
|
+
import { SUB_KEYS } from "../types.js";
|
|
14
|
+
import { mdCell } from "./format.js";
|
|
15
|
+
/** Query parameter carrying the traffic source's click token. */
|
|
16
|
+
export const SOURCE_CLICK_ID_PARAM = "source_click_id";
|
|
17
|
+
/**
|
|
18
|
+
* Default click-token macro. RichAds' spelling — the pilot source; other networks
|
|
19
|
+
* substitute their own (`[CLICK_ID]`, `${SUBID}`, …).
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_SOURCE_CLICK_ID = "{clickid}";
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the origin for URLs that leave the building. A tenant with a custom API
|
|
24
|
+
* domain must see that domain: these URLs get pasted into a network's campaign
|
|
25
|
+
* settings, so they have to be the final ones.
|
|
26
|
+
*
|
|
27
|
+
* Never fails — the links matter more than the branding, so a bad or unreadable
|
|
28
|
+
* setting falls back to the configured API base.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchTenantIntegration(client, config) {
|
|
31
|
+
try {
|
|
32
|
+
const settings = await client.get("/api/tenant");
|
|
33
|
+
return {
|
|
34
|
+
baseUrl: integrationBaseUrl(settings.custom_api_domain, config.baseUrl),
|
|
35
|
+
subLabels: settings.sub_labels ?? {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { baseUrl: stripTrailingSlash(config.baseUrl), subLabels: {} };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** `custom_api_domain` is tenant-editable free text — only use it if it parses. */
|
|
43
|
+
export function integrationBaseUrl(customApiDomain, fallback) {
|
|
44
|
+
const domain = customApiDomain?.trim();
|
|
45
|
+
if (!domain)
|
|
46
|
+
return stripTrailingSlash(fallback);
|
|
47
|
+
// A non-http scheme has to be rejected, not papered over: prefixing "https://"
|
|
48
|
+
// onto "ftp://files.example.com" parses as host "ftp" with the rest as the path,
|
|
49
|
+
// which would silently hand the network "https://ftp".
|
|
50
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(domain)?.[1]?.toLowerCase();
|
|
51
|
+
if (scheme && scheme !== "http" && scheme !== "https") {
|
|
52
|
+
return stripTrailingSlash(fallback);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(scheme ? domain : `https://${domain}`);
|
|
56
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
57
|
+
parsed.username ||
|
|
58
|
+
parsed.password ||
|
|
59
|
+
(parsed.pathname !== "/" && parsed.pathname !== "") ||
|
|
60
|
+
parsed.search ||
|
|
61
|
+
parsed.hash) {
|
|
62
|
+
return stripTrailingSlash(fallback);
|
|
63
|
+
}
|
|
64
|
+
return stripTrailingSlash(parsed.origin);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return stripTrailingSlash(fallback);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function stripTrailingSlash(url) {
|
|
71
|
+
return url.replace(/\/+$/, "");
|
|
72
|
+
}
|
|
73
|
+
/** `/serve/{zone_id}` — the zone URL, for campaign rotation. */
|
|
74
|
+
export function buildZoneUrl(baseUrl, zoneId, params = {}) {
|
|
75
|
+
return withQuery(`${stripTrailingSlash(baseUrl)}/serve/${encodeURIComponent(zoneId)}`, params);
|
|
76
|
+
}
|
|
77
|
+
/** `/track/click/{campaign_id}/{zone_id}` — straight to one campaign. */
|
|
78
|
+
export function buildTrackingLink(baseUrl, campaignId, zoneId, params = {}) {
|
|
79
|
+
const path = `/track/click/${encodeURIComponent(String(campaignId))}/${encodeURIComponent(zoneId)}`;
|
|
80
|
+
return withQuery(`${stripTrailingSlash(baseUrl)}${path}`, params);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Values go in verbatim, not percent-encoded: what these carry are the traffic
|
|
84
|
+
* source's own macros (`{clickid}`, `[CLICK_ID]`, `${SUBID}`), which the source
|
|
85
|
+
* expands before the request ever reaches affset. Encoding them would hand the
|
|
86
|
+
* network a URL it cannot substitute into.
|
|
87
|
+
*
|
|
88
|
+
* `source_click_id` leads — it is what conversion postbacks depend on, so it should
|
|
89
|
+
* read as the primary parameter in the copied URL. All five sub slots are emitted as
|
|
90
|
+
* a template for the buyer to fill in or delete.
|
|
91
|
+
*/
|
|
92
|
+
function withQuery(base, { sourceClickId = DEFAULT_SOURCE_CLICK_ID, subs = {}, cost, subLabels = {} }) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
const clickId = sourceClickId.trim();
|
|
95
|
+
if (clickId)
|
|
96
|
+
parts.push(`${SOURCE_CLICK_ID_PARAM}=${clickId}`);
|
|
97
|
+
for (const key of SUB_KEYS) {
|
|
98
|
+
const explicit = subs[key]?.trim();
|
|
99
|
+
parts.push(`${key}=${explicit || `{${placeholderName(key, subLabels[key])}}`}`);
|
|
100
|
+
}
|
|
101
|
+
const costMacro = cost?.trim();
|
|
102
|
+
if (costMacro)
|
|
103
|
+
parts.push(`cost=${costMacro}`);
|
|
104
|
+
return parts.length > 0 ? `${base}?${parts.join("&")}` : base;
|
|
105
|
+
}
|
|
106
|
+
/** "Creative name" -> `creative_name`; falls back to the raw sub key. */
|
|
107
|
+
export function placeholderName(key, label) {
|
|
108
|
+
const slug = (label ?? "")
|
|
109
|
+
.trim()
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.replace(/\s+/g, "_")
|
|
112
|
+
.replace(/[^a-z0-9_-]/g, "");
|
|
113
|
+
return slug || key;
|
|
114
|
+
}
|
|
115
|
+
/** "sub1 = Creative, sub2 = Placement" — so the buyer can see what belongs where. */
|
|
116
|
+
export function subLegend(subLabels, subs = {}) {
|
|
117
|
+
const labelled = SUB_KEYS.flatMap((key) => {
|
|
118
|
+
const value = subs[key]?.trim();
|
|
119
|
+
const label = subLabels[key]?.trim();
|
|
120
|
+
if (!value && !label)
|
|
121
|
+
return [];
|
|
122
|
+
return value
|
|
123
|
+
? [`\`${key}\` = ${mdCell(value)}${label ? ` (${mdCell(label)})` : ""}`]
|
|
124
|
+
: [`\`${key}\` = ${mdCell(label)}`];
|
|
125
|
+
});
|
|
126
|
+
return labelled.length > 0 ? labelled.join(" · ") : null;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=integrationUrls.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input shape shared by the two integration-URL tools. Both hand a URL to a traffic
|
|
3
|
+
* source, so both take the same knobs: which click-token macro to use, what to put
|
|
4
|
+
* in the five sub slots, and whether to import the network's cost macro.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { SUB_KEYS } from "../types.js";
|
|
8
|
+
import { DEFAULT_SOURCE_CLICK_ID } from "./integrationUrls.js";
|
|
9
|
+
const LINK_VALUE_MAX = 255;
|
|
10
|
+
/**
|
|
11
|
+
* Values are deliberately not percent-encoded so ad-network macro syntax survives.
|
|
12
|
+
* Reject characters that would instead terminate or split the generated query value.
|
|
13
|
+
*/
|
|
14
|
+
const linkValue = (label) => z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.max(LINK_VALUE_MAX)
|
|
18
|
+
// eslint-disable-next-line no-control-regex -- matching control chars is the point: reject them.
|
|
19
|
+
.refine((value) => !/[&#\u0000-\u001f\u007f]/.test(value), {
|
|
20
|
+
message: `${label} cannot contain &, #, or control characters; percent-encode literal separators.`,
|
|
21
|
+
});
|
|
22
|
+
/** Sub slots are declared one by one so the model can see all five. */
|
|
23
|
+
const subSchema = (key) => linkValue(key)
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(`Value or macro for ${key} (analytics only). Defaults to a placeholder named ` +
|
|
26
|
+
"after the tenant's label for this slot.");
|
|
27
|
+
export const linkInputSchema = {
|
|
28
|
+
source_click_id: linkValue("source_click_id")
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("The traffic source's click-token macro, e.g. `{clickid}` (RichAds), `[CLICK_ID]`, " +
|
|
31
|
+
`\`\${SUBID}\`. Default \`${DEFAULT_SOURCE_CLICK_ID}\`. This is what conversion ` +
|
|
32
|
+
'postbacks echo back via {source_click_id} — pass "" only to omit it deliberately.'),
|
|
33
|
+
cost: linkValue("cost")
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("The network's cost macro, e.g. `{cost}`. Adds `&cost=…` so media cost is imported " +
|
|
36
|
+
"and ROI shows up in get_stats. Omit if the source cannot pass cost."),
|
|
37
|
+
sub1: subSchema("sub1"),
|
|
38
|
+
sub2: subSchema("sub2"),
|
|
39
|
+
sub3: subSchema("sub3"),
|
|
40
|
+
sub4: subSchema("sub4"),
|
|
41
|
+
sub5: subSchema("sub5"),
|
|
42
|
+
};
|
|
43
|
+
/** Pull the flat sub1..sub5 args into the record the URL builders take. */
|
|
44
|
+
export function collectSubs(args) {
|
|
45
|
+
const subs = {};
|
|
46
|
+
for (const key of SUB_KEYS) {
|
|
47
|
+
const value = args[key];
|
|
48
|
+
if (value !== undefined)
|
|
49
|
+
subs[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return subs;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=linkArgs.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mdCell } from "./format.js";
|
|
2
|
+
/** Render a before → after markdown table. */
|
|
3
|
+
export function renderDiff(changes) {
|
|
4
|
+
if (changes.length === 0)
|
|
5
|
+
return "_No field changes._";
|
|
6
|
+
const lines = [
|
|
7
|
+
"| Field | From | To |",
|
|
8
|
+
"|---|---|---|",
|
|
9
|
+
...changes.map((c) => `| ${mdCell(c.field)} | ${mdCell(c.from)} | ${mdCell(c.to)} |`),
|
|
10
|
+
];
|
|
11
|
+
return lines.join("\n");
|
|
12
|
+
}
|
|
13
|
+
/** Display helper for nullable / missing values. */
|
|
14
|
+
export function displayValue(value) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return "(unset)";
|
|
17
|
+
if (value === null)
|
|
18
|
+
return "(null)";
|
|
19
|
+
if (typeof value === "string" && value.trim() === "")
|
|
20
|
+
return "(empty)";
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=patch.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payout-rule helpers shared by the payout tools.
|
|
3
|
+
*
|
|
4
|
+
* The API has no update for a payout rule: the (campaign, zone) pair is unique,
|
|
5
|
+
* so a create over an existing scope returns 409 and changing a payout means
|
|
6
|
+
* delete-then-create. That leaves a window where the campaign has no rule at all,
|
|
7
|
+
* and a campaign with no matching rule resolves every conversion to $0 — silently,
|
|
8
|
+
* because a missing payout is a normal state rather than an error. `replacePayout`
|
|
9
|
+
* exists so that window is always either closed or reported.
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchPayoutRules(client, campaignId) {
|
|
12
|
+
const res = await client.get(`/api/campaigns/${encodeURIComponent(campaignId)}/payout_rules`);
|
|
13
|
+
return res.payout_rules ?? [];
|
|
14
|
+
}
|
|
15
|
+
/** The rule governing one scope: `null` zoneId is the campaign-wide rule. */
|
|
16
|
+
export function findPayoutRule(rules, zoneId) {
|
|
17
|
+
return rules.find((r) => (zoneId == null ? r.zone_id == null : r.zone_id === zoneId));
|
|
18
|
+
}
|
|
19
|
+
/** Delete the rule governing one scope (`null` zoneId = the campaign-wide rule). */
|
|
20
|
+
export async function deletePayoutScope(client, campaignId, zoneId) {
|
|
21
|
+
await client.delete(`/api/campaigns/${encodeURIComponent(campaignId)}/payout_rules`, zoneId == null ? undefined : { zone_id: zoneId });
|
|
22
|
+
}
|
|
23
|
+
export async function createPayoutRule(client, campaignId, zoneId, payout) {
|
|
24
|
+
return client.post(`/api/campaigns/${encodeURIComponent(campaignId)}/payout_rules`, zoneId == null ? { payout } : { payout, zone_id: zoneId });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Replace the payout for one scope. On a failed create the previous rule is
|
|
28
|
+
* restored, so a network blip or a rejected value cannot leave the campaign
|
|
29
|
+
* paying $0 on every conversion.
|
|
30
|
+
*/
|
|
31
|
+
export async function replacePayout(client, campaignId, zoneId, existing, payout) {
|
|
32
|
+
await deletePayoutScope(client, campaignId, zoneId);
|
|
33
|
+
try {
|
|
34
|
+
return { rule: await createPayoutRule(client, campaignId, zoneId, payout) };
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
try {
|
|
38
|
+
await createPayoutRule(client, campaignId, zoneId, existing.payout);
|
|
39
|
+
return { rolledBack: existing, cause };
|
|
40
|
+
}
|
|
41
|
+
catch (rollbackCause) {
|
|
42
|
+
return { lost: existing, cause, rollbackCause };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=payoutRules.js.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeting helpers shared by the targeting tools.
|
|
3
|
+
*
|
|
4
|
+
* The serve path matches `targeting_rules.rule` with an exact, case-sensitive
|
|
5
|
+
* string compare against what Cloudflare and Bowser produce for the request
|
|
6
|
+
* (lite-adserver `campaignSelectionService`): `CF-IPCountry` is upper-case
|
|
7
|
+
* ISO-3166 alpha-2, OS/browser names are Bowser's own spellings, device type is
|
|
8
|
+
* one of three lower-case words. A rule an operator reads as obviously correct —
|
|
9
|
+
* `geo: br,mx`, `os: android` — therefore matches nothing, and a whitelist that
|
|
10
|
+
* matches nothing takes the campaign out of rotation without an error anywhere.
|
|
11
|
+
* Everything written through these tools is normalised to the serve path's
|
|
12
|
+
* spelling first, and anything unrecognised comes back as a warning rather than
|
|
13
|
+
* being silently stored.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Seeded types the `/serve` path never evaluates. They accept writes through the
|
|
17
|
+
* API and then do nothing, so an hours rule reads as a working dayparting setup
|
|
18
|
+
* while the campaign keeps buying around the clock.
|
|
19
|
+
*/
|
|
20
|
+
export const UNENFORCED_TYPES = {
|
|
21
|
+
capping: 'not evaluated on /serve — use `unique_users` ("visits/hours") for frequency capping',
|
|
22
|
+
weekdays: "not evaluated on /serve — no dayparting; pause the campaign instead",
|
|
23
|
+
hours: "not evaluated on /serve — no dayparting; pause the campaign instead",
|
|
24
|
+
};
|
|
25
|
+
/** Device types `detectDeviceType` can return; anything else never matches. */
|
|
26
|
+
const DEVICE_TYPES = ["desktop", "mobile", "tablet"];
|
|
27
|
+
/**
|
|
28
|
+
* Bowser OS names worth aliasing, keyed by their lower-case form.
|
|
29
|
+
*
|
|
30
|
+
* The `windows_*` and `chrome_os` keys are the ids the dashboard's selectors
|
|
31
|
+
* wrote into rules before they stored serve-path names; campaigns saved then
|
|
32
|
+
* still carry them, and the ad server resolves them the same way
|
|
33
|
+
* (`normalizeOsName` in lite-adserver/src/utils/deviceDetection.ts).
|
|
34
|
+
*/
|
|
35
|
+
const OS_ALIASES = {
|
|
36
|
+
android: "Android",
|
|
37
|
+
ios: "iOS",
|
|
38
|
+
iphone: "iOS",
|
|
39
|
+
ipad: "iOS",
|
|
40
|
+
windows: "Windows",
|
|
41
|
+
windows_7: "Windows",
|
|
42
|
+
windows_10: "Windows",
|
|
43
|
+
windows_11: "Windows",
|
|
44
|
+
"windows 7": "Windows",
|
|
45
|
+
"windows 10": "Windows",
|
|
46
|
+
"windows 11": "Windows",
|
|
47
|
+
"windows phone": "Windows Phone",
|
|
48
|
+
macos: "macOS",
|
|
49
|
+
"mac os": "macOS",
|
|
50
|
+
osx: "macOS",
|
|
51
|
+
"mac os x": "macOS",
|
|
52
|
+
linux: "Linux",
|
|
53
|
+
"chrome os": "Chrome OS",
|
|
54
|
+
chrome_os: "Chrome OS",
|
|
55
|
+
chromeos: "Chrome OS",
|
|
56
|
+
};
|
|
57
|
+
/** Bowser browser names worth aliasing, keyed by their lower-case form. */
|
|
58
|
+
const BROWSER_ALIASES = {
|
|
59
|
+
chrome: "Chrome",
|
|
60
|
+
chromium: "Chromium",
|
|
61
|
+
firefox: "Firefox",
|
|
62
|
+
safari: "Safari",
|
|
63
|
+
opera: "Opera",
|
|
64
|
+
edge: "Microsoft Edge",
|
|
65
|
+
"microsoft edge": "Microsoft Edge",
|
|
66
|
+
ie: "Internet Explorer",
|
|
67
|
+
"internet explorer": "Internet Explorer",
|
|
68
|
+
samsung: "Samsung Internet for Android",
|
|
69
|
+
samsung_internet: "Samsung Internet for Android",
|
|
70
|
+
"samsung internet": "Samsung Internet for Android",
|
|
71
|
+
"samsung internet for android": "Samsung Internet for Android",
|
|
72
|
+
"android browser": "Android Browser",
|
|
73
|
+
uc: "UC Browser",
|
|
74
|
+
"uc browser": "UC Browser",
|
|
75
|
+
vivaldi: "Vivaldi",
|
|
76
|
+
yandex: "Yandex Browser",
|
|
77
|
+
"yandex browser": "Yandex Browser",
|
|
78
|
+
};
|
|
79
|
+
/** Mirrors lite-adserver `isValidZoneId` (UUID v4 or legacy `zone-{id}`). */
|
|
80
|
+
const ZONE_ID_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|zone-\d+)$/i;
|
|
81
|
+
/** Mirrors lite-adserver UNIQUE_USERS_MAX_VISITS / _MAX_HOURS. */
|
|
82
|
+
const UNIQUE_USERS_MAX_VISITS = 1000;
|
|
83
|
+
const UNIQUE_USERS_MAX_HOURS = 8760;
|
|
84
|
+
export async function fetchTargetingTypes(client) {
|
|
85
|
+
const res = await client.get("/api/targeting-rule-types");
|
|
86
|
+
return res.targeting_rule_types ?? [];
|
|
87
|
+
}
|
|
88
|
+
export async function fetchTargetingRules(client, campaignId) {
|
|
89
|
+
const res = await client.get(`/api/campaigns/${encodeURIComponent(campaignId)}/targeting_rules`);
|
|
90
|
+
return res.targeting_rules ?? [];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Write the campaign's full rule set. The endpoint is a sync, not an append:
|
|
94
|
+
* every rule the caller wants to keep must be echoed back with its id, or it is
|
|
95
|
+
* deleted. Callers build `rules` from a fresh read for exactly that reason.
|
|
96
|
+
*/
|
|
97
|
+
export async function syncTargetingRules(client, campaignId, rules) {
|
|
98
|
+
const res = await client.post(`/api/campaigns/${encodeURIComponent(campaignId)}/targeting_rules`, rules);
|
|
99
|
+
return res.targeting_rules ?? [];
|
|
100
|
+
}
|
|
101
|
+
/** Strip everything the sync endpoint does not accept back. */
|
|
102
|
+
export function toSyncPayload(rules) {
|
|
103
|
+
return rules.map((r) => ({
|
|
104
|
+
...(r.id !== undefined ? { id: r.id } : {}),
|
|
105
|
+
targeting_rule_type_id: r.targeting_rule_type_id,
|
|
106
|
+
targeting_method: r.targeting_method,
|
|
107
|
+
rule: r.rule,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
/** Resolve a caller-supplied type id or name against an already-fetched catalog. */
|
|
111
|
+
export function resolveTargetingType(types, type) {
|
|
112
|
+
if (types.length === 0) {
|
|
113
|
+
return { error: "No targeting rule types available in this tenant." };
|
|
114
|
+
}
|
|
115
|
+
const raw = String(type).trim();
|
|
116
|
+
if (typeof type === "number" || /^\d+$/.test(raw)) {
|
|
117
|
+
const id = typeof type === "number" ? type : parseInt(raw, 10);
|
|
118
|
+
const match = types.find((t) => t.id === id);
|
|
119
|
+
if (!match) {
|
|
120
|
+
const known = types.map((t) => `${t.id}=${t.name}`).join(", ");
|
|
121
|
+
return { error: `Unknown targeting type id ${id}. Known: ${known}.` };
|
|
122
|
+
}
|
|
123
|
+
return { type: match };
|
|
124
|
+
}
|
|
125
|
+
const name = raw.toLowerCase();
|
|
126
|
+
const match = types.find((t) => t.name.toLowerCase() === name);
|
|
127
|
+
if (!match) {
|
|
128
|
+
const known = types.map((t) => t.name).join(", ");
|
|
129
|
+
return { error: `Unknown targeting type "${type}". Known: ${known}.` };
|
|
130
|
+
}
|
|
131
|
+
return { type: match };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Normalise a rule value to the spelling the serve path compares against.
|
|
135
|
+
* Returns an error for values that provably cannot match, and notes for values
|
|
136
|
+
* that were rewritten or that could not be checked.
|
|
137
|
+
*/
|
|
138
|
+
export function normalizeRuleValue(typeName, rule) {
|
|
139
|
+
const raw = rule.trim();
|
|
140
|
+
if (!raw)
|
|
141
|
+
return { error: "rule must be a non-empty string." };
|
|
142
|
+
switch (typeName.toLowerCase()) {
|
|
143
|
+
case "geo":
|
|
144
|
+
return normalizeList(raw, "geo", (value) => {
|
|
145
|
+
if (!/^[A-Za-z]{2}$/.test(value)) {
|
|
146
|
+
return {
|
|
147
|
+
error: `"${value}" is not an ISO-3166 alpha-2 country code. ` +
|
|
148
|
+
"Geo is matched against `CF-IPCountry` (e.g. BR, MX, IN).",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { value: value.toUpperCase() };
|
|
152
|
+
});
|
|
153
|
+
case "device_type":
|
|
154
|
+
return normalizeList(raw, "device_type", (value) => {
|
|
155
|
+
const lower = value.toLowerCase();
|
|
156
|
+
if (!DEVICE_TYPES.includes(lower)) {
|
|
157
|
+
return {
|
|
158
|
+
error: `"${value}" is not a device type. Use ${DEVICE_TYPES.join(", ")}.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { value: lower };
|
|
162
|
+
});
|
|
163
|
+
case "os":
|
|
164
|
+
return normalizeList(raw, "os", (value) => aliasOrWarn(value, OS_ALIASES, "OS"));
|
|
165
|
+
case "browser":
|
|
166
|
+
return normalizeList(raw, "browser", (value) => aliasOrWarn(value, BROWSER_ALIASES, "browser"));
|
|
167
|
+
case "zone_id":
|
|
168
|
+
return normalizeList(raw, "zone_id", (value) => {
|
|
169
|
+
if (!ZONE_ID_RE.test(value)) {
|
|
170
|
+
return {
|
|
171
|
+
error: `"${value}" is not a zone id. Zone rules take zone UUIDs ` +
|
|
172
|
+
"(see `list_zones`); anything else is dropped by the serve path.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { value };
|
|
176
|
+
});
|
|
177
|
+
case "unique_users":
|
|
178
|
+
return normalizeUniqueUsers(raw);
|
|
179
|
+
default:
|
|
180
|
+
return { value: raw, notes: [] };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Apply `check` across a comma-separated list, de-duplicating the result. */
|
|
184
|
+
function normalizeList(raw, label, check) {
|
|
185
|
+
const parts = raw
|
|
186
|
+
.split(",")
|
|
187
|
+
.map((p) => p.trim())
|
|
188
|
+
.filter((p) => p.length > 0);
|
|
189
|
+
if (parts.length === 0) {
|
|
190
|
+
return { error: `${label} rule must list at least one value.` };
|
|
191
|
+
}
|
|
192
|
+
const values = [];
|
|
193
|
+
const notes = [];
|
|
194
|
+
for (const part of parts) {
|
|
195
|
+
const checked = check(part);
|
|
196
|
+
if ("error" in checked)
|
|
197
|
+
return checked;
|
|
198
|
+
if (!values.includes(checked.value))
|
|
199
|
+
values.push(checked.value);
|
|
200
|
+
if (checked.note)
|
|
201
|
+
notes.push(checked.note);
|
|
202
|
+
}
|
|
203
|
+
const normalized = values.join(",");
|
|
204
|
+
if (normalized !== raw) {
|
|
205
|
+
notes.unshift(`Normalised \`${raw}\` → \`${normalized}\` to match the serve path.`);
|
|
206
|
+
}
|
|
207
|
+
return { value: normalized, notes };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* OS and browser names come from Bowser and cannot be enumerated safely — an
|
|
211
|
+
* unknown value may be a real one this list has not seen. Pass it through, but
|
|
212
|
+
* say so: an unmatched value in a whitelist stops the campaign serving.
|
|
213
|
+
*/
|
|
214
|
+
function aliasOrWarn(value, aliases, label) {
|
|
215
|
+
const known = aliases[value.toLowerCase()];
|
|
216
|
+
if (known)
|
|
217
|
+
return { value: known };
|
|
218
|
+
return {
|
|
219
|
+
value,
|
|
220
|
+
note: `⚠️ \`${value}\` is not a ${label} name this server recognises — it is stored as typed ` +
|
|
221
|
+
`and matched exactly. Confirm it against a real ${label} value in \`get_stats\` first.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** `visits/hours`, matching lite-adserver `parseUniqueUsersRule`. */
|
|
225
|
+
function normalizeUniqueUsers(raw) {
|
|
226
|
+
const match = /^\s*(\d+)(?:\s*[/,]\s*(\d+))?\s*$/.exec(raw);
|
|
227
|
+
if (!match) {
|
|
228
|
+
return {
|
|
229
|
+
error: `"${raw}" is not a unique_users rule. Use "visits/hours" (e.g. "1/24" for ` +
|
|
230
|
+
"one impression per user per day). A malformed rule is skipped at serve time, " +
|
|
231
|
+
"so the cap would silently not apply.",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const visits = Number(match[1]);
|
|
235
|
+
const hours = match[2] === undefined ? 24 : Number(match[2]);
|
|
236
|
+
if (visits < 1 || visits > UNIQUE_USERS_MAX_VISITS) {
|
|
237
|
+
return { error: `unique_users visits must be 1–${UNIQUE_USERS_MAX_VISITS} (got ${visits}).` };
|
|
238
|
+
}
|
|
239
|
+
if (hours < 1 || hours > UNIQUE_USERS_MAX_HOURS) {
|
|
240
|
+
return { error: `unique_users hours must be 1–${UNIQUE_USERS_MAX_HOURS} (got ${hours}).` };
|
|
241
|
+
}
|
|
242
|
+
const value = `${visits}/${hours}`;
|
|
243
|
+
const notes = value === raw ? [] : [`Normalised \`${raw}\` → \`${value}\` (visits/hours).`];
|
|
244
|
+
return { value, notes };
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=targeting.js.map
|