@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,211 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { mdCell, moneyPrecise } from "../lib/format.js";
|
|
4
|
+
import { buildTrackingLink, fetchTenantIntegration } from "../lib/integrationUrls.js";
|
|
5
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
6
|
+
import { httpUrlError } from "../lib/urls.js";
|
|
7
|
+
import { resolveZone, zonePostbackNote } from "../lib/zones.js";
|
|
8
|
+
/** Geo targeting-rule-type id (matches the core migration seed). */
|
|
9
|
+
const GEO_RULE_TYPE_ID = 1;
|
|
10
|
+
/** Matches lite-adserver PAYOUT_MIN / PAYOUT_MAX. */
|
|
11
|
+
const PAYOUT_MIN = 0.00001;
|
|
12
|
+
const PAYOUT_MAX = 9999.99999;
|
|
13
|
+
const NAME_MAX = 120;
|
|
14
|
+
export const CREATE_CAMPAIGN_DESCRIPTION = "Create a campaign (offer) in the current namespace from a compact spec: advertiser " +
|
|
15
|
+
"email, offer URL, geo whitelist, payout, name. The advertiser (user_email) is required " +
|
|
16
|
+
"— it must already exist as a team member (same as the Advertiser dropdown in the dashboard). " +
|
|
17
|
+
"Everything else gets media-buying defaults: CPA model, " +
|
|
18
|
+
"rate 0 (no internal advertiser billing), created paused, a global payout rule when " +
|
|
19
|
+
"payout is given, and a ready-to-use tracking link prefilled with the sub convention " +
|
|
20
|
+
"(source_click_id + sub1..sub5, named by the tenant's sub labels). The campaign is " +
|
|
21
|
+
"created paused, so activate it before sending traffic through either URL. " +
|
|
22
|
+
"Geo whitelist applies to /serve rotation only; the tracking link itself is not geo-gated. " +
|
|
23
|
+
"DRY-RUN by default; pass confirm=true to apply. No money is spent by this call; the " +
|
|
24
|
+
"result echoes exactly what was created.";
|
|
25
|
+
export const createCampaignInputSchema = {
|
|
26
|
+
user_email: z
|
|
27
|
+
.string()
|
|
28
|
+
.trim()
|
|
29
|
+
.email()
|
|
30
|
+
.describe("Advertiser email that owns this campaign (required — the API rejects the call without it). " +
|
|
31
|
+
"Must already exist as a team member with the advertiser role; same as the dashboard's " +
|
|
32
|
+
"Advertiser dropdown. List candidates with list_team."),
|
|
33
|
+
offer_url: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.describe("Offer / lander URL the click redirects to. May carry {click_id} (affset's click id, " +
|
|
37
|
+
"for S2S postback back into affset) and {sub1}..{sub5} macros."),
|
|
38
|
+
name: z
|
|
39
|
+
.string()
|
|
40
|
+
.min(1)
|
|
41
|
+
.max(NAME_MAX)
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Campaign name / funnel tag. Default: derived from offer host, geo and payout."),
|
|
44
|
+
geo: z
|
|
45
|
+
.array(z.string().length(2))
|
|
46
|
+
.nonempty()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Geo whitelist as ISO 3166-1 alpha-2 codes, e.g. ["BR"] or ["BR","MX"]. Omit for worldwide. ' +
|
|
49
|
+
"Applies to /serve rotation only — the tracking link is not geo-gated."),
|
|
50
|
+
payout: z
|
|
51
|
+
.number()
|
|
52
|
+
.min(PAYOUT_MIN)
|
|
53
|
+
.max(PAYOUT_MAX)
|
|
54
|
+
.optional()
|
|
55
|
+
.describe(`Offer payout per conversion in USD (${PAYOUT_MIN}–${PAYOUT_MAX}; creates the campaign's global payout rule).`),
|
|
56
|
+
zone_id: z
|
|
57
|
+
.string()
|
|
58
|
+
.trim()
|
|
59
|
+
.min(1)
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Traffic-source zone for the tracking link. Optional when the namespace has exactly " +
|
|
62
|
+
"one active zone — it is picked automatically; otherwise the tool lists zones to choose from."),
|
|
63
|
+
confirm: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.default(false)
|
|
66
|
+
.describe("false = dry-run preview (default). true = create the campaign."),
|
|
67
|
+
};
|
|
68
|
+
export async function createCampaign(client, config, args) {
|
|
69
|
+
try {
|
|
70
|
+
// 1. Validate the compact spec locally (clear errors beat API 400s).
|
|
71
|
+
const urlErr = httpUrlError(args.offer_url, "offer_url");
|
|
72
|
+
if (urlErr)
|
|
73
|
+
return textError(urlErr);
|
|
74
|
+
const offerUrl = new URL(args.offer_url);
|
|
75
|
+
const geo = normalizeGeo(args.geo);
|
|
76
|
+
if ("error" in geo)
|
|
77
|
+
return textError(geo.error);
|
|
78
|
+
const geoCodes = geo.codes;
|
|
79
|
+
// 2. Resolve zone + tenant integration settings (link base, sub labels) in parallel.
|
|
80
|
+
// Safe for dry-run — both are reads.
|
|
81
|
+
const [zoneResult, integration] = await Promise.all([
|
|
82
|
+
resolveZone(client, args.zone_id),
|
|
83
|
+
fetchTenantIntegration(client, config),
|
|
84
|
+
]);
|
|
85
|
+
if ("error" in zoneResult) {
|
|
86
|
+
return textError(zoneResult.error);
|
|
87
|
+
}
|
|
88
|
+
const { zone, inactiveWarning } = zoneResult;
|
|
89
|
+
const name = args.name?.trim() || defaultName(offerUrl, geoCodes, args.payout);
|
|
90
|
+
const geoNote = geoCodes.length
|
|
91
|
+
? `${geoCodes.join(", ")} (whitelist; enforced on /serve, not on the tracking link)`
|
|
92
|
+
: "worldwide (no geo rule)";
|
|
93
|
+
const payoutPreview = args.payout !== undefined
|
|
94
|
+
? `${moneyPrecise(args.payout)} per conversion (global rule)`
|
|
95
|
+
: "_none set — add one to track revenue and use {payout} in postbacks_";
|
|
96
|
+
const summaryTable = [
|
|
97
|
+
"| Field | Value |",
|
|
98
|
+
"|---|---|",
|
|
99
|
+
`| Name | ${mdCell(name)} |`,
|
|
100
|
+
`| Advertiser | ${mdCell(args.user_email)} |`,
|
|
101
|
+
`| Offer URL | ${mdCell(args.offer_url)} |`,
|
|
102
|
+
`| Geo | ${geoNote} |`,
|
|
103
|
+
`| Payout | ${payoutPreview} |`,
|
|
104
|
+
`| Model | CPA, rate 0 (defaults — no internal advertiser billing) |`,
|
|
105
|
+
`| Status | paused (activate before sending traffic through either URL) |`,
|
|
106
|
+
`| Zone | ${mdCell(zone.name)} (\`${zone.id}\`) — ${zonePostbackNote(zone)} |`,
|
|
107
|
+
].join("\n");
|
|
108
|
+
if (!args.confirm) {
|
|
109
|
+
return textResult([
|
|
110
|
+
"**Dry run** — would create a campaign with:",
|
|
111
|
+
"",
|
|
112
|
+
summaryTable,
|
|
113
|
+
...(inactiveWarning ? ["", inactiveWarning] : []),
|
|
114
|
+
"",
|
|
115
|
+
"Call again with `confirm: true` to create it. The tracking link is returned after create.",
|
|
116
|
+
].join("\n"));
|
|
117
|
+
}
|
|
118
|
+
// 3. Create the campaign with media-buying defaults. rate stays 0 so internal
|
|
119
|
+
// advertiser billing/budget enforcement is untouched; revenue comes from the
|
|
120
|
+
// payout rule. The API forces status=paused on create.
|
|
121
|
+
const created = await client.post("/api/campaigns", {
|
|
122
|
+
name,
|
|
123
|
+
user_email: args.user_email.trim(),
|
|
124
|
+
redirect_url: args.offer_url,
|
|
125
|
+
payment_model: "cpa",
|
|
126
|
+
rate: 0,
|
|
127
|
+
start_date: Date.now(),
|
|
128
|
+
targeting_rules: geoCodes.length
|
|
129
|
+
? [
|
|
130
|
+
{
|
|
131
|
+
targeting_rule_type_id: GEO_RULE_TYPE_ID,
|
|
132
|
+
targeting_method: "whitelist",
|
|
133
|
+
rule: geoCodes.join(","),
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
: [],
|
|
137
|
+
});
|
|
138
|
+
// 4. Global payout rule (zone_id null). Failure here must not hide the created
|
|
139
|
+
// campaign — report it as a warning instead of failing the whole call.
|
|
140
|
+
let payoutNote = "_none set — add one to track revenue and use {payout} in postbacks_";
|
|
141
|
+
if (args.payout !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
await client.post(`/api/campaigns/${created.id}/payout_rules`, { payout: args.payout });
|
|
144
|
+
payoutNote = `${moneyPrecise(args.payout)} per conversion (global rule)`;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
const message = err instanceof AffsetApiError ? err.message : String(err);
|
|
148
|
+
payoutNote = `⚠️ campaign created, but setting the payout rule failed: ${message}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 5. Ready-to-paste tracking link with the sub convention prefilled.
|
|
152
|
+
const trackingLink = buildTrackingLink(integration.baseUrl, created.id, zone.id, {
|
|
153
|
+
subLabels: integration.subLabels,
|
|
154
|
+
});
|
|
155
|
+
return textResult([
|
|
156
|
+
`✅ Campaign **${mdCell(created.name)}** created (id \`${created.id}\`).`,
|
|
157
|
+
"",
|
|
158
|
+
"| Field | Value |",
|
|
159
|
+
"|---|---|",
|
|
160
|
+
`| Advertiser | ${mdCell(args.user_email)} |`,
|
|
161
|
+
`| Offer URL | ${mdCell(args.offer_url)} |`,
|
|
162
|
+
`| Geo | ${geoNote} |`,
|
|
163
|
+
`| Payout | ${payoutNote} |`,
|
|
164
|
+
`| Model | CPA, rate 0 (defaults — no internal advertiser billing) |`,
|
|
165
|
+
`| Status | ${created.status || "paused"} (activate before sending traffic through either URL) |`,
|
|
166
|
+
`| Zone | ${mdCell(zone.name)} (\`${zone.id}\`) — ${zonePostbackNote(zone)} |`,
|
|
167
|
+
...(inactiveWarning ? ["", inactiveWarning] : []),
|
|
168
|
+
"",
|
|
169
|
+
"**Tracking link** (give this to the traffic source):",
|
|
170
|
+
"```",
|
|
171
|
+
trackingLink,
|
|
172
|
+
"```",
|
|
173
|
+
"Replace each `{…}` placeholder with the source's macro; `{clickid}` is already " +
|
|
174
|
+
"correct for RichAds. Drop sub slots you don't need. Append `&cost={cost}` " +
|
|
175
|
+
"(the network's cost macro) to import media cost for ROI. This link returns " +
|
|
176
|
+
"404 while the campaign is paused; run it with `set_campaign_status` before use.",
|
|
177
|
+
"",
|
|
178
|
+
"_Only defaults were set — use `set_targeting_rule`, `set_payout_rule`, and budget fields on `update_campaign` for the rest._",
|
|
179
|
+
"_Need this link again later, or the rotating /serve URL instead? `get_tracking_link` / `get_zone_url`._",
|
|
180
|
+
].join("\n"));
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return errorResult(err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function normalizeGeo(raw) {
|
|
187
|
+
const codes = [];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const entry of raw ?? []) {
|
|
190
|
+
const code = entry.trim().toUpperCase();
|
|
191
|
+
if (!/^[A-Z]{2}$/.test(code)) {
|
|
192
|
+
return { error: `geo entries must be 2-letter ISO country codes, got "${entry}".` };
|
|
193
|
+
}
|
|
194
|
+
if (!seen.has(code)) {
|
|
195
|
+
seen.add(code);
|
|
196
|
+
codes.push(code);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { codes };
|
|
200
|
+
}
|
|
201
|
+
/** "offer.com BR $2" — a name the buyer can recognise in a list without opening it. */
|
|
202
|
+
function defaultName(offerUrl, geo, payout) {
|
|
203
|
+
const parts = [offerUrl.hostname.replace(/^www\./, "")];
|
|
204
|
+
if (geo.length)
|
|
205
|
+
parts.push(geo.join("+"));
|
|
206
|
+
if (payout !== undefined)
|
|
207
|
+
parts.push(moneyPrecise(payout).replace(/\.?0+$/, ""));
|
|
208
|
+
const name = parts.join(" ");
|
|
209
|
+
return name.length <= NAME_MAX ? name : `${name.slice(0, NAME_MAX - 1)}…`;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=createCampaign.js.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mdCell } from "../lib/format.js";
|
|
3
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
4
|
+
import { httpUrlError } from "../lib/urls.js";
|
|
5
|
+
export const CREATE_ZONE_DESCRIPTION = "Create a traffic-source zone in the current namespace. Requires a name; optional " +
|
|
6
|
+
"postback_url (where conversions are reported back — include {source_click_id}), " +
|
|
7
|
+
"site_url, traffic_back_url, and user_email (publisher owner). Status is always " +
|
|
8
|
+
"active on create. Counts against the plan's zone limit (402 if exceeded). " +
|
|
9
|
+
"DRY-RUN by default; pass confirm=true to apply.";
|
|
10
|
+
export const createZoneInputSchema = {
|
|
11
|
+
name: z.string().min(1).max(200).describe("Zone display name (traffic source / placement)."),
|
|
12
|
+
postback_url: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("S2S postback URL for the traffic source. Should include {source_click_id} so " +
|
|
17
|
+
"conversions can be attributed back to the source click."),
|
|
18
|
+
site_url: z.string().min(1).optional().describe("Optional site / inventory URL."),
|
|
19
|
+
traffic_back_url: z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Optional traffic-back / fallback URL when no campaign can serve."),
|
|
24
|
+
user_email: z
|
|
25
|
+
.string()
|
|
26
|
+
.email()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Optional publisher email to own this zone (owner/manager only)."),
|
|
29
|
+
confirm: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.default(false)
|
|
32
|
+
.describe("false = dry-run preview (default). true = create the zone."),
|
|
33
|
+
};
|
|
34
|
+
export async function createZone(client, args) {
|
|
35
|
+
try {
|
|
36
|
+
const name = args.name.trim();
|
|
37
|
+
if (!name)
|
|
38
|
+
return textError("name is required.");
|
|
39
|
+
for (const [label, value] of [
|
|
40
|
+
["postback_url", args.postback_url],
|
|
41
|
+
["site_url", args.site_url],
|
|
42
|
+
["traffic_back_url", args.traffic_back_url],
|
|
43
|
+
]) {
|
|
44
|
+
if (value !== undefined) {
|
|
45
|
+
const err = httpUrlError(value, label);
|
|
46
|
+
if (err)
|
|
47
|
+
return textError(err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const postback = args.postback_url;
|
|
51
|
+
const postbackNote = !postback
|
|
52
|
+
? "⚠️ none — set postback_url (with {source_click_id}) or the source will not see conversions"
|
|
53
|
+
: !postback.includes("{source_click_id}")
|
|
54
|
+
? `${mdCell(postback)} — ⚠️ missing \`{source_click_id}\``
|
|
55
|
+
: mdCell(postback);
|
|
56
|
+
const summaryTable = [
|
|
57
|
+
"| Field | Value |",
|
|
58
|
+
"|---|---|",
|
|
59
|
+
`| Name | ${mdCell(name)} |`,
|
|
60
|
+
`| Status | active (forced on create) |`,
|
|
61
|
+
`| Postback | ${postbackNote} |`,
|
|
62
|
+
`| Site | ${mdCell(args.site_url ?? "—")} |`,
|
|
63
|
+
`| Traffic back | ${mdCell(args.traffic_back_url ?? "—")} |`,
|
|
64
|
+
`| Publisher | ${mdCell(args.user_email ?? "—")} |`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
if (!args.confirm) {
|
|
67
|
+
return textResult([
|
|
68
|
+
"**Dry run** — would create a zone with:",
|
|
69
|
+
"",
|
|
70
|
+
summaryTable,
|
|
71
|
+
"",
|
|
72
|
+
"Call again with `confirm: true` to create it. Counts against the plan's zone limit.",
|
|
73
|
+
].join("\n"));
|
|
74
|
+
}
|
|
75
|
+
const body = { name };
|
|
76
|
+
if (args.postback_url !== undefined)
|
|
77
|
+
body.postback_url = args.postback_url;
|
|
78
|
+
if (args.site_url !== undefined)
|
|
79
|
+
body.site_url = args.site_url;
|
|
80
|
+
if (args.traffic_back_url !== undefined)
|
|
81
|
+
body.traffic_back_url = args.traffic_back_url;
|
|
82
|
+
if (args.user_email !== undefined)
|
|
83
|
+
body.user_email = args.user_email;
|
|
84
|
+
const created = await client.post("/api/zones", body);
|
|
85
|
+
// Create response is sparse — fetch full row for the echo.
|
|
86
|
+
let zone = null;
|
|
87
|
+
try {
|
|
88
|
+
zone = await client.get(`/api/zones/${encodeURIComponent(created.id)}`);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Non-fatal; fall back to the create payload.
|
|
92
|
+
}
|
|
93
|
+
const createdPostback = zone?.postback_url ?? args.postback_url;
|
|
94
|
+
const createdPostbackNote = !createdPostback
|
|
95
|
+
? "⚠️ none — set postback_url (with {source_click_id}) or the source will not see conversions"
|
|
96
|
+
: !createdPostback.includes("{source_click_id}")
|
|
97
|
+
? `${mdCell(createdPostback)} — ⚠️ missing \`{source_click_id}\``
|
|
98
|
+
: mdCell(createdPostback);
|
|
99
|
+
return textResult([
|
|
100
|
+
`✅ Zone **${mdCell(zone?.name ?? name)}** created (id \`${created.id}\`).`,
|
|
101
|
+
"",
|
|
102
|
+
"| Field | Value |",
|
|
103
|
+
"|---|---|",
|
|
104
|
+
`| Status | ${zone?.status ?? created.status} (forced active on create) |`,
|
|
105
|
+
`| Postback | ${createdPostbackNote} |`,
|
|
106
|
+
`| Site | ${mdCell(zone?.site_url ?? args.site_url ?? "—")} |`,
|
|
107
|
+
`| Traffic back | ${mdCell(zone?.traffic_back_url ?? args.traffic_back_url ?? "—")} |`,
|
|
108
|
+
`| Publisher | ${mdCell(zone?.user_email ?? args.user_email ?? "—")} |`,
|
|
109
|
+
"",
|
|
110
|
+
"Use this zone id with `create_campaign` (or pass it as zone_id) for tracking links.",
|
|
111
|
+
].join("\n"));
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return errorResult(err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=createZone.js.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolveRange, RANGE_PRESETS } from "../lib/time.js";
|
|
3
|
+
import { conversionRate, money, mdCell, pct, roi as fmtRoi } from "../lib/format.js";
|
|
4
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
5
|
+
/** Fallback zone targeting-rule-type id (matches the core migration seed). */
|
|
6
|
+
const ZONE_RULE_TYPE_FALLBACK = 4;
|
|
7
|
+
export const CUT_ZONES_DESCRIPTION = "Blacklist underperforming zones on a campaign based on thresholds (CR, spend, ROI). " +
|
|
8
|
+
"Evaluates zone stats over the given window and adds matching zones to the campaign's " +
|
|
9
|
+
"zone blacklist. DRY-RUN by default: it shows which zones would be cut and how the " +
|
|
10
|
+
"blacklist changes. Pass confirm=true to actually apply. `spend` means media_cost " +
|
|
11
|
+
"(your traffic cost). A zone is cut only if it matches ALL provided thresholds.";
|
|
12
|
+
export const cutZonesInputSchema = {
|
|
13
|
+
campaign_id: z
|
|
14
|
+
.union([z.string().min(1), z.number().int()])
|
|
15
|
+
.describe("Campaign whose zone blacklist to edit."),
|
|
16
|
+
cr_max: z
|
|
17
|
+
.number()
|
|
18
|
+
.min(0)
|
|
19
|
+
.max(1)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Cut zones with conversion rate BELOW this fraction (e.g. 0.002 = 0.2%)."),
|
|
22
|
+
spend_min: z
|
|
23
|
+
.number()
|
|
24
|
+
.min(0)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Cut zones with media_cost ABOVE this many dollars (e.g. 5 = $5)."),
|
|
27
|
+
roi_max: z
|
|
28
|
+
.number()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Cut zones with ROI BELOW this fraction (e.g. -0.3 = -30%). Needs cost data."),
|
|
31
|
+
min_clicks: z
|
|
32
|
+
.number()
|
|
33
|
+
.int()
|
|
34
|
+
.min(0)
|
|
35
|
+
.default(10)
|
|
36
|
+
.describe("Ignore zones with fewer clicks than this (significance guard). Default 10."),
|
|
37
|
+
range: z
|
|
38
|
+
.enum(RANGE_PRESETS)
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Evaluation window. Ignored if from/to are given. Defaults to last 7 days."),
|
|
41
|
+
from: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Explicit start bound: YYYY-MM-DD (tenant-local start of day), ISO timestamp with Z/UTC offset, or epoch ms."),
|
|
45
|
+
to: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Explicit end bound: YYYY-MM-DD (tenant-local end of day), ISO timestamp with Z/UTC offset, or epoch ms."),
|
|
49
|
+
confirm: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.default(false)
|
|
52
|
+
.describe("false = dry-run preview (default). true = apply the blacklist changes."),
|
|
53
|
+
};
|
|
54
|
+
export async function cutZones(client, args) {
|
|
55
|
+
try {
|
|
56
|
+
const campaignId = String(args.campaign_id).trim();
|
|
57
|
+
if (!campaignId)
|
|
58
|
+
return textError("campaign_id is required.");
|
|
59
|
+
if (args.cr_max === undefined && args.spend_min === undefined && args.roi_max === undefined) {
|
|
60
|
+
return textError("Provide at least one threshold: cr_max, spend_min, or roi_max.");
|
|
61
|
+
}
|
|
62
|
+
const timeZone = await client.getTenantTimezone();
|
|
63
|
+
const { from, to, label } = resolveRange(args.range ?? "last_7_days", args.from, args.to, timeZone);
|
|
64
|
+
// 1. Zone-level stats for this campaign over the window.
|
|
65
|
+
const stats = await client.get("/api/stats", {
|
|
66
|
+
from,
|
|
67
|
+
to,
|
|
68
|
+
group_by: "zone_id",
|
|
69
|
+
campaign_ids: campaignId,
|
|
70
|
+
});
|
|
71
|
+
const rows = (stats.stats ?? []).filter((r) => (r.zone_id ?? "").trim() !== "");
|
|
72
|
+
// 2. Select candidates: enough clicks AND matches every provided threshold.
|
|
73
|
+
const candidates = rows.filter((r) => matchesThresholds(r, args));
|
|
74
|
+
// 3. Current targeting rules + the zone rule type id.
|
|
75
|
+
const [zoneTypeId, currentRules] = await Promise.all([
|
|
76
|
+
resolveZoneRuleTypeId(client),
|
|
77
|
+
fetchRules(client, campaignId),
|
|
78
|
+
]);
|
|
79
|
+
const alreadyBlacklisted = collectBlacklistedZones(currentRules, zoneTypeId);
|
|
80
|
+
const zonesToAdd = candidates
|
|
81
|
+
.map((r) => r.zone_id)
|
|
82
|
+
.filter((z) => !alreadyBlacklisted.has(z));
|
|
83
|
+
const criteria = describeCriteria(args);
|
|
84
|
+
const preview = renderCandidates(candidates, alreadyBlacklisted);
|
|
85
|
+
const beforeCount = alreadyBlacklisted.size;
|
|
86
|
+
const afterCount = beforeCount + zonesToAdd.length;
|
|
87
|
+
const costNote = costDataNote(args, rows);
|
|
88
|
+
// 4a. Dry-run (default).
|
|
89
|
+
if (!args.confirm) {
|
|
90
|
+
const head = zonesToAdd.length > 0
|
|
91
|
+
? `**Dry run** — ${zonesToAdd.length} zone(s) would be blacklisted on campaign \`${campaignId}\`.`
|
|
92
|
+
: `**Dry run** — no new zones to blacklist on campaign \`${campaignId}\`.`;
|
|
93
|
+
return textResult(`${head}\n_Window: ${label}. Criteria: ${criteria}._\n\n${preview}\n\n` +
|
|
94
|
+
`Zone blacklist: ${beforeCount} → ${afterCount}.` +
|
|
95
|
+
(zonesToAdd.length > 0 ? "\n\nCall again with `confirm: true` to apply." : "") +
|
|
96
|
+
costNote);
|
|
97
|
+
}
|
|
98
|
+
// 4b. Apply — re-fetch rules immediately before write to shrink the TOCTOU window
|
|
99
|
+
// (POST sync deletes any rule whose id is omitted).
|
|
100
|
+
if (zonesToAdd.length === 0) {
|
|
101
|
+
return textResult(`Nothing to apply — no new zones matched on campaign \`${campaignId}\`.\n_Criteria: ${criteria}._${costNote}`);
|
|
102
|
+
}
|
|
103
|
+
const freshRules = await fetchRules(client, campaignId);
|
|
104
|
+
const freshBlacklisted = collectBlacklistedZones(freshRules, zoneTypeId);
|
|
105
|
+
const freshToAdd = zonesToAdd.filter((z) => !freshBlacklisted.has(z));
|
|
106
|
+
if (freshToAdd.length === 0) {
|
|
107
|
+
return textResult(`Nothing to apply — matching zones are already blacklisted on campaign \`${campaignId}\`.`);
|
|
108
|
+
}
|
|
109
|
+
const nextRules = mergeBlacklist(freshRules, zoneTypeId, freshToAdd);
|
|
110
|
+
await client.post(`/api/campaigns/${encodeURIComponent(campaignId)}/targeting_rules`, nextRules);
|
|
111
|
+
const appliedBefore = freshBlacklisted.size;
|
|
112
|
+
const appliedAfter = appliedBefore + freshToAdd.length;
|
|
113
|
+
return textResult(`✅ Applied. Added ${freshToAdd.length} zone(s) to campaign \`${campaignId}\` blacklist ` +
|
|
114
|
+
`(${appliedBefore} → ${appliedAfter}).\n_Criteria: ${criteria}. Window: ${label}._\n\n${preview}`);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return errorResult(err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function matchesThresholds(r, args) {
|
|
121
|
+
if (r.clicks < args.min_clicks)
|
|
122
|
+
return false;
|
|
123
|
+
if (args.cr_max !== undefined && !(conversionRate(r) < args.cr_max))
|
|
124
|
+
return false;
|
|
125
|
+
if (args.spend_min !== undefined && !((r.media_cost ?? 0) > args.spend_min))
|
|
126
|
+
return false;
|
|
127
|
+
if (args.roi_max !== undefined &&
|
|
128
|
+
!(r.roi !== null && r.roi !== undefined && r.roi < args.roi_max)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function costDataNote(args, rows) {
|
|
134
|
+
const usedCostThreshold = args.spend_min !== undefined || args.roi_max !== undefined;
|
|
135
|
+
const hasAnyCost = rows.some((r) => (r.media_cost ?? 0) > 0);
|
|
136
|
+
if (usedCostThreshold && !hasAnyCost) {
|
|
137
|
+
return "\n\n⚠️ A cost/ROI threshold was set but no media_cost is present for these zones — nothing can match it yet.";
|
|
138
|
+
}
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
async function fetchRules(client, campaignId) {
|
|
142
|
+
const res = await client.get(`/api/campaigns/${encodeURIComponent(campaignId)}/targeting_rules`);
|
|
143
|
+
return res.targeting_rules ?? [];
|
|
144
|
+
}
|
|
145
|
+
/** Look up the zone_id targeting-rule-type id, falling back to the seed value. */
|
|
146
|
+
async function resolveZoneRuleTypeId(client) {
|
|
147
|
+
try {
|
|
148
|
+
const res = await client.get("/api/targeting-rule-types");
|
|
149
|
+
const match = (res.targeting_rule_types ?? []).find((t) => t.name === "zone_id");
|
|
150
|
+
return match?.id ?? ZONE_RULE_TYPE_FALLBACK;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return ZONE_RULE_TYPE_FALLBACK;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function parseZoneList(rule) {
|
|
157
|
+
return rule
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((z) => z.trim())
|
|
160
|
+
.filter((z) => z.length > 0);
|
|
161
|
+
}
|
|
162
|
+
/** Union of all zones across the campaign's zone-type blacklist rules. */
|
|
163
|
+
function collectBlacklistedZones(rules, zoneTypeId) {
|
|
164
|
+
const zones = new Set();
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
if (rule.targeting_rule_type_id === zoneTypeId && rule.targeting_method === "blacklist") {
|
|
167
|
+
for (const z of parseZoneList(rule.rule))
|
|
168
|
+
zones.add(z);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return zones;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Build the full rule array to POST. All existing rules are echoed back with
|
|
175
|
+
* their ids (the API deletes any it doesn't receive), and the new zones are
|
|
176
|
+
* merged into the first zone-type blacklist rule — or a new one is appended.
|
|
177
|
+
*/
|
|
178
|
+
function mergeBlacklist(rules, zoneTypeId, zonesToAdd) {
|
|
179
|
+
const next = rules.map((r) => ({
|
|
180
|
+
id: r.id,
|
|
181
|
+
targeting_rule_type_id: r.targeting_rule_type_id,
|
|
182
|
+
targeting_method: r.targeting_method,
|
|
183
|
+
rule: r.rule,
|
|
184
|
+
}));
|
|
185
|
+
const target = next.find((r) => r.targeting_rule_type_id === zoneTypeId && r.targeting_method === "blacklist");
|
|
186
|
+
if (target) {
|
|
187
|
+
const merged = new Set([...parseZoneList(target.rule), ...zonesToAdd]);
|
|
188
|
+
target.rule = [...merged].join(",");
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
next.push({
|
|
192
|
+
targeting_rule_type_id: zoneTypeId,
|
|
193
|
+
targeting_method: "blacklist",
|
|
194
|
+
rule: zonesToAdd.join(","),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
function describeCriteria(args) {
|
|
200
|
+
const parts = [];
|
|
201
|
+
if (args.cr_max !== undefined)
|
|
202
|
+
parts.push(`CR < ${pct(args.cr_max)}`);
|
|
203
|
+
if (args.spend_min !== undefined)
|
|
204
|
+
parts.push(`spend > ${money(args.spend_min)}`);
|
|
205
|
+
if (args.roi_max !== undefined)
|
|
206
|
+
parts.push(`ROI < ${fmtRoi(args.roi_max)}`);
|
|
207
|
+
parts.push(`min ${args.min_clicks} clicks`);
|
|
208
|
+
return parts.join(", ");
|
|
209
|
+
}
|
|
210
|
+
function renderCandidates(candidates, alreadyBlacklisted) {
|
|
211
|
+
if (candidates.length === 0)
|
|
212
|
+
return "_No zones matched._";
|
|
213
|
+
const lines = [
|
|
214
|
+
"| Zone | Clicks | Conv | CR | Cost | ROI | Status |",
|
|
215
|
+
"|---|--:|--:|--:|--:|--:|---|",
|
|
216
|
+
];
|
|
217
|
+
for (const r of candidates) {
|
|
218
|
+
const zoneId = r.zone_id;
|
|
219
|
+
const status = alreadyBlacklisted.has(zoneId) ? "already blacklisted" : "will add";
|
|
220
|
+
lines.push(`| ${mdCell(r.zone_name ?? zoneId)} | ${r.clicks} | ${r.conversions} | ${pct(conversionRate(r))} | ${money(r.media_cost)} | ${fmtRoi(r.roi)} | ${status} |`);
|
|
221
|
+
}
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=cutZones.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AffsetApiError } from "../client.js";
|
|
3
|
+
import { moneyPrecise } from "../lib/format.js";
|
|
4
|
+
import { deletePayoutScope, fetchPayoutRules, findPayoutRule } from "../lib/payoutRules.js";
|
|
5
|
+
import { errorResult, textError, textResult } from "../lib/toolResult.js";
|
|
6
|
+
export const DELETE_PAYOUT_RULE_DESCRIPTION = "Delete a campaign payout rule (global or per-zone). Omit zone_id to delete the " +
|
|
7
|
+
"global rule; pass a zone UUID to delete that zone's override. DRY-RUN by default; " +
|
|
8
|
+
"pass confirm=true to apply. Without a global rule, conversions resolve to $0.";
|
|
9
|
+
export const deletePayoutRuleInputSchema = {
|
|
10
|
+
campaign_id: z
|
|
11
|
+
.union([z.string().min(1), z.number().int()])
|
|
12
|
+
.describe("Campaign whose payout rule to delete."),
|
|
13
|
+
zone_id: z
|
|
14
|
+
.string()
|
|
15
|
+
.trim()
|
|
16
|
+
.min(1)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Zone UUID of the zone-specific rule. Omit to delete the global rule."),
|
|
19
|
+
confirm: z.boolean().default(false).describe("false = dry-run preview (default). true = apply."),
|
|
20
|
+
};
|
|
21
|
+
export async function deletePayoutRule(client, args) {
|
|
22
|
+
try {
|
|
23
|
+
const campaignId = String(args.campaign_id).trim();
|
|
24
|
+
if (!campaignId)
|
|
25
|
+
return textError("campaign_id is required.");
|
|
26
|
+
const zoneId = args.zone_id?.trim() || null;
|
|
27
|
+
const scope = zoneId == null ? "global" : `zone \`${zoneId}\``;
|
|
28
|
+
const rules = await fetchPayoutRules(client, campaignId);
|
|
29
|
+
const existing = findPayoutRule(rules, zoneId);
|
|
30
|
+
if (!existing) {
|
|
31
|
+
return textError(`No ${scope} payout rule on campaign \`${campaignId}\`.`);
|
|
32
|
+
}
|
|
33
|
+
// Deleting the global rule while zone overrides remain leaves every other
|
|
34
|
+
// zone at $0 — worth saying before it is confirmed, not after.
|
|
35
|
+
const fallbackWarning = zoneId == null
|
|
36
|
+
? "⚠️ Conversions on zones without their own override will resolve to **$0**."
|
|
37
|
+
: findPayoutRule(rules, null)
|
|
38
|
+
? "Conversions on this zone fall back to the global payout."
|
|
39
|
+
: "⚠️ There is no global rule, so conversions on this zone will resolve to **$0**.";
|
|
40
|
+
if (!args.confirm) {
|
|
41
|
+
return textResult([
|
|
42
|
+
`**Dry run** — would delete ${scope} payout on campaign \`${campaignId}\`.`,
|
|
43
|
+
"",
|
|
44
|
+
`| Field | Value |`,
|
|
45
|
+
`|---|---|`,
|
|
46
|
+
`| Rule id | \`${existing.id}\` |`,
|
|
47
|
+
`| Payout | ${moneyPrecise(existing.payout)} |`,
|
|
48
|
+
`| Scope | ${scope} |`,
|
|
49
|
+
"",
|
|
50
|
+
fallbackWarning,
|
|
51
|
+
"",
|
|
52
|
+
"Call again with `confirm: true` to apply.",
|
|
53
|
+
].join("\n"));
|
|
54
|
+
}
|
|
55
|
+
await deletePayoutScope(client, campaignId, zoneId);
|
|
56
|
+
return textResult([
|
|
57
|
+
`✅ Deleted ${scope} payout on campaign \`${campaignId}\` ` +
|
|
58
|
+
`(was ${moneyPrecise(existing.payout)}, rule id \`${existing.id}\`).`,
|
|
59
|
+
"",
|
|
60
|
+
fallbackWarning,
|
|
61
|
+
].join("\n"));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof AffsetApiError && err.status === 404) {
|
|
65
|
+
return textError(`Campaign \`${String(args.campaign_id).trim()}\` not found, or no matching payout rule.`);
|
|
66
|
+
}
|
|
67
|
+
return errorResult(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=deletePayoutRule.js.map
|