@mj-biz-apps/marketing-server 0.3.0 → 0.4.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/dist/__tests__/linkedInTargeting.test.d.ts +2 -0
- package/dist/__tests__/linkedInTargeting.test.d.ts.map +1 -0
- package/dist/__tests__/linkedInTargeting.test.js +65 -0
- package/dist/__tests__/linkedInTargeting.test.js.map +1 -0
- package/dist/agents/MarketingCreativeAgent.d.ts +16 -0
- package/dist/agents/MarketingCreativeAgent.d.ts.map +1 -0
- package/dist/agents/MarketingCreativeAgent.js +28 -0
- package/dist/agents/MarketingCreativeAgent.js.map +1 -0
- package/dist/agents/MarketingExperimentationAgent.d.ts +39 -0
- package/dist/agents/MarketingExperimentationAgent.d.ts.map +1 -1
- package/dist/agents/MarketingExperimentationAgent.js +73 -3
- package/dist/agents/MarketingExperimentationAgent.js.map +1 -1
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +3 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/google/GoogleAdsProgramDriver.js +14 -3
- package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
- package/dist/programs/google/googleBrandAssets.d.ts +5 -0
- package/dist/programs/google/googleBrandAssets.d.ts.map +1 -1
- package/dist/programs/google/googleBrandAssets.js +20 -15
- package/dist/programs/google/googleBrandAssets.js.map +1 -1
- package/dist/programs/index.d.ts +4 -0
- package/dist/programs/index.d.ts.map +1 -1
- package/dist/programs/index.js +5 -0
- package/dist/programs/index.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts +123 -2
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsClient.js +183 -26
- package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +38 -7
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +364 -70
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts +6 -0
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
- package/dist/programs/linkedin/linkedInExecutionContext.js +2 -1
- package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
- package/dist/programs/meta/MetaAdsClient.d.ts +101 -0
- package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -0
- package/dist/programs/meta/MetaAdsClient.js +239 -0
- package/dist/programs/meta/MetaAdsClient.js.map +1 -0
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts +76 -0
- package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -0
- package/dist/programs/meta/MetaAdsProgramDriver.js +431 -0
- package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -0
- package/dist/programs/meta/metaAdsExecutionContext.d.ts +39 -0
- package/dist/programs/meta/metaAdsExecutionContext.d.ts.map +1 -0
- package/dist/programs/meta/metaAdsExecutionContext.js +99 -0
- package/dist/programs/meta/metaAdsExecutionContext.js.map +1 -0
- package/package.json +9 -8
|
@@ -39,7 +39,8 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
39
39
|
import { LogError, LogStatus, Metadata, RunView } from '@memberjunction/core';
|
|
40
40
|
import { EscapeSQLString, RegisterClass } from '@memberjunction/global';
|
|
41
41
|
import { ConvergeObservation } from '@mj-biz-apps/marketing-actions';
|
|
42
|
-
import { BaseProgramTypeDriver, LINKEDIN_ADS_TYPE_NAME, parseAdCampaignConfig, platformObjectName, stampUtm, toMarketingEntityName, assertPublicHttpUrl, effectiveCreative, elapsedReportingRange, } from '@mj-biz-apps/marketing-core';
|
|
42
|
+
import { BaseProgramTypeDriver, LINKEDIN_ADS_TYPE_NAME, parseAdCampaignConfig, platformObjectName, stampUtm, toMarketingEntityName, assertPublicHttpUrl, effectiveCreative, AUDIENCE_MAX_MEMBERS, MATCHED_AUDIENCE_MIN_MEMBERS, INTRO_TEXT_UNCLIPPED_LENGTH, elapsedReportingRange, containerRunSchedule, platformRunSchedule } from '@mj-biz-apps/marketing-core';
|
|
43
|
+
import { CONVERSION_SCOPE, lacksConversionScope } from './LinkedInAdsClient.js';
|
|
43
44
|
import { isLinkedInRefused, resolveLinkedInExecutionContext, LINKEDIN_CREDENTIAL_TYPE } from './linkedInExecutionContext.js';
|
|
44
45
|
const REFS_ENTITY = 'MJ_BizApps_Marketing: Program Platform Refs';
|
|
45
46
|
let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgramTypeDriver {
|
|
@@ -50,13 +51,15 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
50
51
|
this.typeName = LINKEDIN_ADS_TYPE_NAME;
|
|
51
52
|
/** Mirrors AdCampaignConfig — the parser is the enforcement, this is the declaration. */
|
|
52
53
|
this.paramSpecs = [
|
|
53
|
-
{ name: 'tacticConfigJSON', valueType: 'Scalar', isArray: false, isRequired: true, description: 'The levers as JSON (Program.TacticConfigJSON): audience (targeted | website-retargeting
|
|
54
|
+
{ name: 'tacticConfigJSON', valueType: 'Scalar', isArray: false, isRequired: true, description: 'The levers as JSON (Program.TacticConfigJSON): audience (targeted | website-retargeting), dailyBudgetUsd, landingPageUrl, optional creativeHeadline/creativeBody/audienceWindowDays.' },
|
|
54
55
|
{ name: 'campaignCode', valueType: 'Scalar', isArray: false, isRequired: true, description: 'Program.Code — stamped as utm_campaign and prefixed onto every platform object name.' },
|
|
55
56
|
];
|
|
56
57
|
}
|
|
57
58
|
async validateProposal(program) {
|
|
58
59
|
const parsed = parseAdCampaignConfig(this.rawConfig(program));
|
|
59
60
|
const issues = [...parsed.issues];
|
|
61
|
+
if (parsed.config)
|
|
62
|
+
issues.push(...validateLinkedInCreative(parsed.config));
|
|
60
63
|
const user = this.contextUser;
|
|
61
64
|
if (user) {
|
|
62
65
|
const ctx = await this.resolveContext(program.brandId, user);
|
|
@@ -69,6 +72,89 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
69
72
|
message: `${i.message} Lock will refuse until this is resolved.`,
|
|
70
73
|
})));
|
|
71
74
|
}
|
|
75
|
+
else if (parsed.config) {
|
|
76
|
+
issues.push(...await this.validateAudienceAndConversion(parsed.config, ctx));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The audience and the conversion are platform OBJECTS the levers point at by id (R1/R2/R5, D-34): the pre-check
|
|
83
|
+
* looks them up so an id the account does not hold, a country-wide audience or an audience LinkedIn will not serve
|
|
84
|
+
* to is refused while the proposal is still a draft — not discovered at lock, and never, as with CP-02, discovered
|
|
85
|
+
* on the invoice. One GET each; a lookup that fails is a warning (the platform, not the proposal, is the problem).
|
|
86
|
+
*/
|
|
87
|
+
async validateAudienceAndConversion(config, ctx) {
|
|
88
|
+
const issues = [];
|
|
89
|
+
const templateId = config.audienceTemplateId ?? ctx.defaultAudienceTemplateId;
|
|
90
|
+
if (config.audience === 'targeted' && !templateId) {
|
|
91
|
+
issues.push({
|
|
92
|
+
severity: 'error', code: 'LI_AUDIENCE_LOCATION_ONLY',
|
|
93
|
+
message: 'A targeted LinkedIn program needs a saved audience: set one in the levers (audienceTemplateId) or set the connection\'s default saved audience in Settings → Connections. A location alone is not an audience — it is everyone in it.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (templateId) {
|
|
97
|
+
try {
|
|
98
|
+
const template = await ctx.client.findAudienceTemplateById(templateId);
|
|
99
|
+
if (!template) {
|
|
100
|
+
const have = (await ctx.client.audienceTemplates()).map(t => `${t.id} "${t.name}"`).slice(0, 12).join(', ');
|
|
101
|
+
issues.push({
|
|
102
|
+
severity: 'error', code: 'LI_AUDIENCE_TEMPLATE_UNKNOWN',
|
|
103
|
+
message: `No saved audience with id ${templateId}${config.audienceTemplateId ? '' : ' (the connection\'s default)'} on ad account ${ctx.adAccountId}. The account has: ${have || 'none'}.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
issues.push(...audienceSizeIssues(template));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
issues.push({ severity: 'warning', code: 'LI_AUDIENCE_LOOKUP_FAILED', message: `Could not read the account's saved audiences: ${e instanceof Error ? e.message : String(e)}. Lock will look again.` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (config.conversionId) {
|
|
115
|
+
try {
|
|
116
|
+
// KI-148: attaching a conversion is an UPDATE on it and needs the Conversions API scope; CP-02 found this
|
|
117
|
+
// at lock, after the campaign existed. Refuse here, while the proposal is a draft.
|
|
118
|
+
if (lacksConversionScope(await ctx.client.tokenScopes())) {
|
|
119
|
+
issues.push({ severity: 'error', code: 'LI_CONVERSION_SCOPE', message: conversionScopeMessage() });
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
const conversion = await ctx.client.findConversionById(config.conversionId);
|
|
123
|
+
if (!conversion) {
|
|
124
|
+
const have = (await ctx.client.conversions()).map(c => `${c.id} "${c.name}"`).slice(0, 12).join(', ');
|
|
125
|
+
issues.push({
|
|
126
|
+
severity: 'error', code: 'LI_CONVERSION_UNKNOWN',
|
|
127
|
+
message: `No conversion with id ${config.conversionId} on ad account ${ctx.adAccountId}. The account has: ${have || 'none'}.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else if (!conversion.enabled) {
|
|
131
|
+
issues.push({ severity: 'warning', code: 'LI_CONVERSION_DISABLED', message: `Conversion "${conversion.name}" is disabled in Campaign Manager — the campaign will optimise for it but it will not count until it is enabled.` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
issues.push({ severity: 'warning', code: 'LI_CONVERSION_LOOKUP_FAILED', message: `Could not read the account's conversions: ${e instanceof Error ? e.message : String(e)}. Lock will look again.` });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (config.optimizeFor === 'clicks') {
|
|
139
|
+
issues.push({
|
|
140
|
+
severity: 'notice', code: 'LI_OPTIMISES_FOR_CLICKS',
|
|
141
|
+
message: 'No conversion — the campaign optimises for landing-page clicks and the program is measured by them (D-31). The metric must be a click count; a download, sign-up or form fill needs a conversion in Campaign Manager, set by its id in the levers (conversionId).',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// D-31: neither a conversion nor an explicit "clicks" — the program would be measured by a number LinkedIn
|
|
146
|
+
// cannot report (the AAiP draft promised sign-ups; externalWebsiteConversions would have stayed 0 → Kill).
|
|
147
|
+
let names = '';
|
|
148
|
+
try {
|
|
149
|
+
names = (await ctx.client.conversions()).map(c => `"${c.name}"`).slice(0, 12).join(', ');
|
|
150
|
+
}
|
|
151
|
+
catch { /* the list is a courtesy */ }
|
|
152
|
+
issues.push({
|
|
153
|
+
severity: 'error', code: 'LI_NO_CONVERSION',
|
|
154
|
+
message: 'Choose what the program is measured by: name a conversion from the ad account in the levers (conversion) so LinkedIn optimises for it, '
|
|
155
|
+
+ 'or set optimizeFor: "clicks" and make the metric a landing-page click count. '
|
|
156
|
+
+ `Ad account ${ctx.adAccountId} has: ${names || 'no conversions yet — create one in Campaign Manager, or optimise for clicks'}.`,
|
|
157
|
+
});
|
|
72
158
|
}
|
|
73
159
|
return issues;
|
|
74
160
|
}
|
|
@@ -92,6 +178,36 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
92
178
|
// a step existed (CP-02 predates the ad step) — is finished on the next run, not skipped.
|
|
93
179
|
const existing = await this.loadRefs(program.id, user);
|
|
94
180
|
const stampedUrl = stampUtm(config.landingPageUrl, code, 'linkedin');
|
|
181
|
+
// The audience (R1): the levers' saved audience, else the connection's default, by id (D-34); a targeted
|
|
182
|
+
// program with neither is refused here too (the pre-check said so at propose; this is the door spend walks
|
|
183
|
+
// through).
|
|
184
|
+
const templateId = config.audienceTemplateId ?? ctx.defaultAudienceTemplateId;
|
|
185
|
+
let template = null;
|
|
186
|
+
if (templateId) {
|
|
187
|
+
template = await ctx.client.findAudienceTemplateById(templateId);
|
|
188
|
+
if (!template)
|
|
189
|
+
throw new Error(`LinkedIn Ads launch refused: no saved audience with id ${templateId} on ad account ${ctx.adAccountId}.`);
|
|
190
|
+
const tooBig = audienceSizeIssues(template).find(i => i.severity === 'error');
|
|
191
|
+
if (tooBig)
|
|
192
|
+
throw new Error(`LinkedIn Ads launch refused: ${tooBig.message}`);
|
|
193
|
+
}
|
|
194
|
+
else if (config.audience === 'targeted') {
|
|
195
|
+
throw new Error('LinkedIn Ads launch refused: a targeted program needs a saved audience (audienceTemplateId lever, or the connection\'s default in Settings → Connections) — a location alone is everyone in it.');
|
|
196
|
+
}
|
|
197
|
+
// The conversion (R5): set by id → the campaign optimises for it and it is attached below.
|
|
198
|
+
let conversion = null;
|
|
199
|
+
if (config.conversionId) {
|
|
200
|
+
// Before anything is created on LinkedIn (KI-148): the association would fail last, leaving a group and a campaign behind.
|
|
201
|
+
if (lacksConversionScope(await ctx.client.tokenScopes()))
|
|
202
|
+
throw new Error(`LinkedIn Ads launch refused: ${conversionScopeMessage()}`);
|
|
203
|
+
conversion = await ctx.client.findConversionById(config.conversionId);
|
|
204
|
+
if (!conversion)
|
|
205
|
+
throw new Error(`LinkedIn Ads launch refused: no conversion with id ${config.conversionId} on ad account ${ctx.adAccountId}.`);
|
|
206
|
+
}
|
|
207
|
+
if (!conversion && config.optimizeFor !== 'clicks') {
|
|
208
|
+
throw new Error('LinkedIn Ads launch refused: the levers name no conversion and do not choose optimizeFor: "clicks" — the program has nothing to be measured by (D-31).');
|
|
209
|
+
}
|
|
210
|
+
const objective = conversion ? 'WEBSITE_CONVERSION' : 'WEBSITE_VISIT';
|
|
95
211
|
let audienceUrn = null;
|
|
96
212
|
if (config.audience === 'website-retargeting') {
|
|
97
213
|
const days = config.audienceWindowDays ?? 90;
|
|
@@ -112,8 +228,10 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
112
228
|
}
|
|
113
229
|
}
|
|
114
230
|
const runSchedule = await this.runScheduleFor(program, user);
|
|
231
|
+
// KI-143: the group is a container whose start LinkedIn freezes once it is not a draft — it starts at lock
|
|
232
|
+
// time so a sprint pulled forward later (KI-119's hook) still falls inside it. The campaign gets the window.
|
|
115
233
|
const groupUrn = existing.find(r => r.ExternalKind === 'CampaignGroup')?.ExternalURN
|
|
116
|
-
?? await ctx.client.createCampaignGroup(platformObjectName(code, 'campaign-group'), runSchedule);
|
|
234
|
+
?? await ctx.client.createCampaignGroup(platformObjectName(code, 'campaign-group'), containerRunSchedule(runSchedule));
|
|
117
235
|
await this.saveRef(program.id, ctx.brandConnectionId, 'CampaignGroup', groupUrn, null, user);
|
|
118
236
|
// The connection decides whether lock means "running" or "ready": PAUSED by default,
|
|
119
237
|
// so a real account never starts spending because someone scheduled a sprint.
|
|
@@ -124,6 +242,8 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
124
242
|
name: platformObjectName(code, 'campaign'),
|
|
125
243
|
dailyBudgetUsd: config.dailyBudgetUsd,
|
|
126
244
|
audienceUrn,
|
|
245
|
+
targetingCriteria: template?.targetingCriteria ?? {},
|
|
246
|
+
objective,
|
|
127
247
|
status: campaignStatus,
|
|
128
248
|
runSchedule,
|
|
129
249
|
organizationUrn,
|
|
@@ -133,68 +253,102 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
133
253
|
dailyBudgetUsd: config.dailyBudgetUsd,
|
|
134
254
|
landingPageUrl: stampedUrl,
|
|
135
255
|
audience: config.audience,
|
|
256
|
+
audienceTemplate: template ? { id: template.id, name: template.name, approximateMemberCount: template.approximateMemberCount } : null,
|
|
257
|
+
objective,
|
|
258
|
+
conversion: conversion ? { id: conversion.id, name: conversion.name } : null,
|
|
259
|
+
// D-31: what collectMetrics reads back as the program's number — frozen with the campaign, like the levers.
|
|
260
|
+
optimizeFor: conversion ? 'conversions' : 'clicks',
|
|
136
261
|
status: campaignStatus,
|
|
137
262
|
}, user);
|
|
138
263
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
264
|
+
// Idempotent per step: a lock that died between the campaign and this PUT attaches it on the next run.
|
|
265
|
+
if (conversion) {
|
|
266
|
+
await ctx.client.associateConversion(campaignUrn, conversion.urn);
|
|
267
|
+
}
|
|
268
|
+
const adNote = await this.attachCreatives(program, ctx, code, config, campaignUrn, organizationUrn, stampedUrl, campaignStatus, existing, user);
|
|
269
|
+
LogStatus(`LinkedInAdsProgramDriver.onLock: ${code} ${campaignStatus === 'ACTIVE' ? 'launched' : 'created PAUSED (Settings → Connections decides when it serves)'} — ${config.audience} audience`
|
|
270
|
+
+ (template ? ` "${template.name}" (~${template.approximateMemberCount ?? '?'} members)` : '') + `, `
|
|
271
|
+
+ `$${config.dailyBudgetUsd}/day, ${objective}${conversion ? ` optimising for "${conversion.name}"` : ''}, campaign ${campaignUrn}; ${adNote}. Stamped landing URL recorded on the campaign ref.`);
|
|
142
272
|
}
|
|
143
273
|
/**
|
|
144
|
-
* The
|
|
145
|
-
* page (a dark post — it exists only inside the campaign, never on the page's feed) and
|
|
146
|
-
* attached to the campaign
|
|
147
|
-
* means the campaign launches ad-less and SAYS so; a missing or unusable
|
|
148
|
-
* to an article ad (link card) rather than to no ad
|
|
274
|
+
* The ads themselves (R4): ONE creative per headline, up to five, each the agent's copy posted as Direct Sponsored
|
|
275
|
+
* Content from the brand's page (a dark post — it exists only inside the campaign, never on the page's feed) and
|
|
276
|
+
* attached to the campaign, so `creativeSelection: OPTIMIZED` has something to optimise. Mirrors the Google
|
|
277
|
+
* driver's contract: no headline means the campaign launches ad-less and SAYS so; a missing or unusable image
|
|
278
|
+
* degrades to an article ad (link card) rather than to no ad. Idempotent per VARIANT: a second lock finds each
|
|
279
|
+
* variant's ref (or adopts an orphan by name) and creates only what is missing.
|
|
149
280
|
*/
|
|
150
|
-
async
|
|
151
|
-
if (existing.some(r => r.ExternalKind === 'Creative'))
|
|
152
|
-
return 'ad already attached (idempotent)';
|
|
281
|
+
async attachCreatives(program, ctx, code, config, campaignUrn, organizationUrn, stampedUrl, campaignStatus, existing, user) {
|
|
153
282
|
const creative = effectiveCreative(config);
|
|
154
|
-
|
|
155
|
-
if (!headline)
|
|
283
|
+
if (creative.headlines.length === 0)
|
|
156
284
|
return 'no creative in the levers — campaign launches ad-less';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
await this.saveRef(program.id, ctx.brandConnectionId, 'Creative', orphan.urn, {
|
|
164
|
-
postUrn: orphan.postUrn, headline, adopted: true, campaignStatus,
|
|
165
|
-
}, user);
|
|
166
|
-
return `ad adopted from the account (${orphan.urn})`;
|
|
167
|
-
}
|
|
168
|
-
const description = creative.descriptions[0] ?? null;
|
|
169
|
-
// The intro text above the card: the long headline when the agent wrote one, else the
|
|
170
|
-
// description, else the headline again. LinkedIn caps it at 600 characters.
|
|
171
|
-
const commentary = (creative.longHeadline ?? description ?? headline).slice(0, 600);
|
|
285
|
+
const variants = creative.headlines.slice(0, 5);
|
|
286
|
+
const attached = new Set(existing.filter(r => r.ExternalKind === 'Creative').map(r => Number(this.stateOf(r)['variant'] ?? 1)));
|
|
287
|
+
if (variants.every((_, i) => attached.has(i + 1)))
|
|
288
|
+
return `${variants.length} ad(s) already attached (idempotent)`;
|
|
289
|
+
// The image once, shared by every variant: the program's own (creativeImageUrl, KI-117) first; the brand's
|
|
290
|
+
// landscape image is the DEFAULT, not the only option. An unusable program image degrades to the brand image.
|
|
172
291
|
let imageUrn = null;
|
|
173
292
|
let imageNote = 'article ad (no brand marketing image)';
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
imageNote = 'single-image ad with the
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
293
|
+
if (creative.imageUrl) {
|
|
294
|
+
try {
|
|
295
|
+
imageUrn = await this.uploadImageFromUrl(creative.imageUrl, 'program creative image', organizationUrn, ctx);
|
|
296
|
+
imageNote = 'single-image ad with the program\'s own creative image';
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
LogStatus(`LinkedInAdsProgramDriver.onLock: ${code} program creative image not used (${e instanceof Error ? e.message : String(e)}) — falling back to the brand image.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!imageUrn) {
|
|
303
|
+
try {
|
|
304
|
+
imageUrn = await this.uploadBrandImage(program.brandId, organizationUrn, ctx, user);
|
|
305
|
+
if (imageUrn)
|
|
306
|
+
imageNote = creative.imageUrl ? 'single-image ad with the brand marketing image (program image unusable)' : 'single-image ad with the brand marketing image';
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
imageNote = `article ad — brand image not used (${e instanceof Error ? e.message : String(e)})`;
|
|
310
|
+
LogStatus(`LinkedInAdsProgramDriver.onLock: ${code} ${imageNote}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const onAccount = await ctx.client.creativesForCampaign(campaignUrn);
|
|
314
|
+
const notes = [];
|
|
315
|
+
for (let i = 0; i < variants.length; i++) {
|
|
316
|
+
const variant = i + 1;
|
|
317
|
+
if (attached.has(variant))
|
|
318
|
+
continue;
|
|
319
|
+
const headline = variants[i];
|
|
320
|
+
const description = creative.descriptions[i] ?? creative.descriptions[0] ?? null;
|
|
321
|
+
const adName = platformObjectName(code, 'campaign', variants.length > 1 ? `ad ${variant}` : 'ad');
|
|
322
|
+
// ADOPT BEFORE CREATE: an earlier lock may have created this ad and died before recording it (the
|
|
323
|
+
// first live run did exactly that). Anything on this campaign under our name is ours.
|
|
324
|
+
const orphan = onAccount.find(c => c.name === adName && c.intendedStatus !== 'ARCHIVED');
|
|
325
|
+
if (orphan) {
|
|
326
|
+
await this.saveRef(program.id, ctx.brandConnectionId, 'Creative', orphan.urn, {
|
|
327
|
+
variant, postUrn: orphan.postUrn, headline, adopted: true, campaignStatus, callToAction: creative.callToAction,
|
|
328
|
+
}, user);
|
|
329
|
+
notes.push(`ad ${variant} adopted from the account (${orphan.urn})`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const creativeUrn = await ctx.client.createInlineCreative({
|
|
333
|
+
campaignUrn, organizationUrn,
|
|
334
|
+
name: adName,
|
|
335
|
+
commentary: creative.introText ?? headline,
|
|
336
|
+
headline, description,
|
|
337
|
+
landingPageUrl: stampedUrl,
|
|
338
|
+
imageUrn,
|
|
339
|
+
callToAction: creative.callToAction,
|
|
340
|
+
// The campaign's status governs serving; the creative itself is ready.
|
|
341
|
+
intendedStatus: 'ACTIVE',
|
|
342
|
+
});
|
|
343
|
+
const postUrn = await ctx.client.creativePostUrn(creativeUrn).catch(() => null);
|
|
344
|
+
await this.saveRef(program.id, ctx.brandConnectionId, 'Creative', creativeUrn, {
|
|
345
|
+
variant, postUrn, imageUrn, headline, description, commentary: creative.introText ?? headline,
|
|
346
|
+
introTextDerived: creative.introTextDerived, callToAction: creative.callToAction,
|
|
347
|
+
format: imageUrn ? 'single-image' : 'article', campaignStatus,
|
|
348
|
+
}, user);
|
|
349
|
+
notes.push(`ad ${variant} ${creativeUrn}`);
|
|
350
|
+
}
|
|
351
|
+
return `${imageNote}; ${variants.length} variant(s), ${creative.callToAction}: ${notes.join(', ')}`;
|
|
198
352
|
}
|
|
199
353
|
/**
|
|
200
354
|
* The brand's marketing image (Brand.MarketingImageURL — Settings → Brand ad assets), fetched
|
|
@@ -206,19 +360,22 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
206
360
|
const brand = await new Metadata().GetEntityObject('MJ_BizApps_Marketing: Brands', user);
|
|
207
361
|
if (!await brand.Load(brandId) || !brand.MarketingImageURL)
|
|
208
362
|
return null;
|
|
209
|
-
|
|
363
|
+
return this.uploadImageFromUrl(brand.MarketingImageURL, 'marketing image', organizationUrn, ctx);
|
|
364
|
+
}
|
|
365
|
+
/** One fetch-and-upload for every image source (brand or program): public URL only, PNG/JPEG, 5MB. */
|
|
366
|
+
async uploadImageFromUrl(url, label, organizationUrn, ctx) {
|
|
210
367
|
const safety = assertPublicHttpUrl(url);
|
|
211
368
|
if (!safety.ok)
|
|
212
|
-
throw new Error(
|
|
369
|
+
throw new Error(`${label} URL refused: ${safety.reason}`);
|
|
213
370
|
const res = await fetch(url, { redirect: 'follow' });
|
|
214
371
|
if (!res.ok)
|
|
215
|
-
throw new Error(
|
|
372
|
+
throw new Error(`${label} URL returned ${res.status}`);
|
|
216
373
|
const type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
217
374
|
if (type !== 'image/png' && type !== 'image/jpeg')
|
|
218
|
-
throw new Error(
|
|
375
|
+
throw new Error(`${label} is ${type || 'unknown type'} — PNG or JPEG required`);
|
|
219
376
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
220
377
|
if (bytes.byteLength > 5 * 1024 * 1024)
|
|
221
|
-
throw new Error(
|
|
378
|
+
throw new Error(`${label} exceeds 5MB`);
|
|
222
379
|
return ctx.client.uploadImage(organizationUrn, bytes, type);
|
|
223
380
|
}
|
|
224
381
|
async collectMetrics(program, window) {
|
|
@@ -226,9 +383,14 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
226
383
|
if (!user || !program.id)
|
|
227
384
|
return null;
|
|
228
385
|
const refs = await this.loadRefs(program.id, user);
|
|
229
|
-
const
|
|
386
|
+
const campaignRefs = refs.filter(r => r.ExternalKind === 'Campaign');
|
|
387
|
+
const campaigns = campaignRefs.map(r => r.ExternalURN);
|
|
230
388
|
if (campaigns.length === 0)
|
|
231
389
|
return null;
|
|
390
|
+
// D-31: the observed metric is what the campaign was built to optimise for — the conversion when one is
|
|
391
|
+
// attached, landing-page clicks otherwise. Reading the ref, not the levers, keeps the measurement with the
|
|
392
|
+
// campaign that actually ran.
|
|
393
|
+
const measuresConversions = campaignRefs.some(r => this.stateOf(r)['conversion'] != null);
|
|
232
394
|
const ctx = await this.resolveContext(program.brandId, user);
|
|
233
395
|
if (isLinkedInRefused(ctx)) {
|
|
234
396
|
LogStatus(`LinkedInAdsProgramDriver.collectMetrics: connection unavailable (${ctx.issues.map(i => i.code).join(', ')}) — no observation.`);
|
|
@@ -245,7 +407,8 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
245
407
|
// wrote no row at all for a paused or test-account campaign (KI-34, host rehearsal).
|
|
246
408
|
return [{
|
|
247
409
|
segmentLabel: null,
|
|
248
|
-
observedMetric: a.externalWebsiteConversions,
|
|
410
|
+
observedMetric: measuresConversions ? a.externalWebsiteConversions : a.landingPageClicks,
|
|
411
|
+
measures: measuresConversions ? 'conversions' : 'landing-page clicks',
|
|
249
412
|
conversionsCount: a.externalWebsiteConversions,
|
|
250
413
|
sampleSize: a.impressions,
|
|
251
414
|
source: `linkedin:adAnalytics:${ctx.adAccountId}`,
|
|
@@ -272,8 +435,8 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
272
435
|
if (!top)
|
|
273
436
|
return { refreshed: false, sprintsUpdated: 0, observationsWritten: 0, note: `LinkedInAdsProgramDriver: no campaign ref or the connection is unavailable — nothing to observe.` };
|
|
274
437
|
const saved = await ConvergeObservation({ programId: program.id, sprintId, attribution: top.attribution ?? 'Inferred', sourceRefPrefix: 'linkedin:' }, { window, activityCount: top.sampleSize ?? 0, conversionsCount: top.conversionsCount, observedMetric: top.observedMetric, sourceRef: top.source,
|
|
275
|
-
notes: `platform-reported (Inferred): conversions ${top.conversionsCount}, impressions ${top.sampleSize ?? 0}` }, user);
|
|
276
|
-
const note = `LinkedInAdsProgramDriver: ${top.conversionsCount} platform conversion(s) over ${top.sampleSize ?? 0} impressions — ${saved ? 'observation converged' : 'observation NOT saved'}.`;
|
|
438
|
+
notes: `platform-reported (Inferred): measured ${top.observedMetric} ${top.measures ?? 'conversions'}, conversions ${top.conversionsCount}, impressions ${top.sampleSize ?? 0}` }, user);
|
|
439
|
+
const note = `LinkedInAdsProgramDriver: measured ${top.observedMetric} ${top.measures ?? 'conversions'}, ${top.conversionsCount} platform conversion(s) over ${top.sampleSize ?? 0} impressions — ${saved ? 'observation converged' : 'observation NOT saved'}.`;
|
|
277
440
|
LogStatus(`LinkedInAdsProgramDriver.refreshStatus: ${note}`);
|
|
278
441
|
return { refreshed: true, sprintsUpdated: 0, observationsWritten: saved ? 1 : 0, note };
|
|
279
442
|
}
|
|
@@ -301,15 +464,21 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
301
464
|
// is the part that costs money.
|
|
302
465
|
const creatives = refs.filter(r => r.ExternalKind === 'Creative');
|
|
303
466
|
let archived = 0;
|
|
467
|
+
let held = 0;
|
|
304
468
|
const cleanupFailures = [];
|
|
305
469
|
for (const ref of creatives) {
|
|
306
470
|
try {
|
|
307
|
-
|
|
471
|
+
// KI-146: LinkedIn archives only an APPROVED creative; one it has not approved allows no status change
|
|
472
|
+
// at all and is left held — its campaign is paused and its post deleted below, so it cannot serve.
|
|
473
|
+
const status = await ctx.client.retireCreative(ref.ExternalURN);
|
|
308
474
|
const postUrn = this.stateOf(ref)['postUrn'];
|
|
309
475
|
if (typeof postUrn === 'string' && postUrn)
|
|
310
476
|
await ctx.client.deletePost(postUrn);
|
|
311
|
-
await this.updateRefState(ref, { status
|
|
312
|
-
|
|
477
|
+
await this.updateRefState(ref, { status, postDeleted: typeof postUrn === 'string', retiredFor: reason }, user);
|
|
478
|
+
if (status === 'ARCHIVED')
|
|
479
|
+
archived++;
|
|
480
|
+
else
|
|
481
|
+
held++;
|
|
313
482
|
}
|
|
314
483
|
catch (e) {
|
|
315
484
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -320,11 +489,70 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
320
489
|
// The summary counts what actually happened (KI-38: the first cut reported every creative
|
|
321
490
|
// as archived even when LinkedIn refused — a REJECTED creative cannot change status).
|
|
322
491
|
const note = `LinkedIn Ads: ${campaigns.length} campaign(s) paused, `
|
|
323
|
-
+ `${archived} of ${creatives.length} creative(s) archived
|
|
492
|
+
+ `${archived} of ${creatives.length} creative(s) archived`
|
|
493
|
+
+ (held > 0 ? ` and ${held} left held (LinkedIn allows no status change on a creative it has not approved; the paused campaign and the deleted post keep it from serving)` : '')
|
|
494
|
+
+ `, posts deleted (${reason})`
|
|
324
495
|
+ (cleanupFailures.length > 0 ? `; ${cleanupFailures.length} creative cleanup(s) failed and remain on the account.` : '.');
|
|
325
496
|
LogStatus(`LinkedInAdsProgramDriver.onRetire: ${program.name ?? program.id} — ${note}`);
|
|
326
497
|
return { stoodDown: true, note };
|
|
327
498
|
}
|
|
499
|
+
/** Stop serving, keep everything (R6 / KI-120): the campaigns go PAUSED; creatives and posts stay. */
|
|
500
|
+
async onPause(program) {
|
|
501
|
+
return this.setServing(program, 'PAUSED');
|
|
502
|
+
}
|
|
503
|
+
async onResume(program) {
|
|
504
|
+
return this.setServing(program, 'ACTIVE');
|
|
505
|
+
}
|
|
506
|
+
async setServing(program, status) {
|
|
507
|
+
const user = this.requireUser();
|
|
508
|
+
if (!program.id)
|
|
509
|
+
return { changed: false, note: 'LinkedIn Ads: nothing to change — the program has no id.' };
|
|
510
|
+
const refs = await this.loadRefs(program.id, user);
|
|
511
|
+
const campaigns = refs.filter(r => r.ExternalKind === 'Campaign');
|
|
512
|
+
if (campaigns.length === 0)
|
|
513
|
+
return { changed: false, note: 'LinkedIn Ads: nothing serving — no campaign was ever launched for this program.' };
|
|
514
|
+
const ctx = await this.resolveContext(program.brandId, user);
|
|
515
|
+
if (isLinkedInRefused(ctx)) {
|
|
516
|
+
throw new Error(`LinkedIn Ads could not reach the platform to set ${status}: ${ctx.issues.map(i => i.message).join(' ')}`);
|
|
517
|
+
}
|
|
518
|
+
for (const ref of campaigns) {
|
|
519
|
+
await ctx.client.setCampaignStatus(ref.ExternalURN, status);
|
|
520
|
+
await this.updateRefState(ref, { status, servingChangedAt: new Date().toISOString() }, user);
|
|
521
|
+
}
|
|
522
|
+
const note = `LinkedIn Ads: ${campaigns.length} campaign(s) ${status === 'PAUSED' ? 'paused' : 'resumed'}.`;
|
|
523
|
+
LogStatus(`LinkedInAdsProgramDriver.${status === 'PAUSED' ? 'onPause' : 'onResume'}: ${program.name ?? program.id} — ${note}`);
|
|
524
|
+
return { changed: true, note };
|
|
525
|
+
}
|
|
526
|
+
/** The account's saved audiences and conversions (R1/R5): what Settings offers and the agent may name. */
|
|
527
|
+
async listAudienceOptions(brandId) {
|
|
528
|
+
const user = this.contextUser;
|
|
529
|
+
if (!user)
|
|
530
|
+
return null;
|
|
531
|
+
const ctx = await this.resolveContext(brandId, user);
|
|
532
|
+
if (isLinkedInRefused(ctx))
|
|
533
|
+
return null;
|
|
534
|
+
const [templates, conversions] = await Promise.all([ctx.client.audienceTemplates(), ctx.client.conversions()]);
|
|
535
|
+
return {
|
|
536
|
+
audiences: templates.map(t => ({ name: t.name, approximateMemberCount: t.approximateMemberCount, id: t.id })),
|
|
537
|
+
conversions: conversions.map(c => ({ name: c.name, enabled: c.enabled, id: c.id })),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** Live figures since the campaign was created (R6): what the early serving check judges. */
|
|
541
|
+
async servingSnapshot(program) {
|
|
542
|
+
const user = this.contextUser;
|
|
543
|
+
if (!user || !program.id)
|
|
544
|
+
return null;
|
|
545
|
+
const refs = await this.loadRefs(program.id, user);
|
|
546
|
+
const campaigns = refs.filter(r => r.ExternalKind === 'Campaign');
|
|
547
|
+
if (campaigns.length === 0)
|
|
548
|
+
return null;
|
|
549
|
+
const ctx = await this.resolveContext(program.brandId, user);
|
|
550
|
+
if (isLinkedInRefused(ctx))
|
|
551
|
+
return null;
|
|
552
|
+
const launchedAt = campaigns.map(r => new Date(r.__mj_CreatedAt)).sort((a, b) => a.getTime() - b.getTime())[0];
|
|
553
|
+
const a = await ctx.client.campaignAnalytics(campaigns.map(r => r.ExternalURN), launchedAt, new Date());
|
|
554
|
+
return { launchedAt, impressions: a.impressions, clicks: a.clicks, landingPageClicks: a.landingPageClicks, spendUsd: a.spendUsd };
|
|
555
|
+
}
|
|
328
556
|
// ── refs (the driver's memory) ──────────────────────────────────────────
|
|
329
557
|
/**
|
|
330
558
|
* Which company page sponsors the campaign. The ad account's `reference` wins when it is a
|
|
@@ -375,8 +603,38 @@ let LinkedInAdsProgramDriver = class LinkedInAdsProgramDriver extends BaseProgra
|
|
|
375
603
|
if (!Number.isFinite(endMs) && row?.DecisionDate)
|
|
376
604
|
endMs = new Date(row.DecisionDate).getTime();
|
|
377
605
|
}
|
|
378
|
-
|
|
379
|
-
|
|
606
|
+
return platformRunSchedule(Number.isFinite(startMs) ? new Date(startMs) : null, Number.isFinite(endMs) ? new Date(endMs) : null, new Date(now));
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* KI-119: the sprint moved after lock (an early close pulled it forward). The campaign group and campaign were
|
|
610
|
+
* created with the OLD window as their run schedule, so without this the real ad sat waiting for the original
|
|
611
|
+
* date while the app said the sprint was running. Both objects get the window the program now carries.
|
|
612
|
+
*/
|
|
613
|
+
async onWindowMoved(program, window) {
|
|
614
|
+
const user = this.requireUser();
|
|
615
|
+
if (!program.id)
|
|
616
|
+
return { rescheduled: false, note: 'LinkedIn Ads: no saved program (no id).' };
|
|
617
|
+
const refs = await this.loadRefs(program.id, user);
|
|
618
|
+
const campaignUrn = refs.find(r => r.ExternalKind === 'Campaign')?.ExternalURN ?? null;
|
|
619
|
+
if (!campaignUrn)
|
|
620
|
+
return { rescheduled: false, note: 'LinkedIn Ads: no campaign on the platform yet — nothing to move.' };
|
|
621
|
+
const groupUrn = refs.find(r => r.ExternalKind === 'CampaignGroup')?.ExternalURN ?? null;
|
|
622
|
+
const ctx = await this.resolveContext(program.brandId, user);
|
|
623
|
+
if (isLinkedInRefused(ctx)) {
|
|
624
|
+
throw new Error(`LinkedIn Ads reschedule refused: ${ctx.issues.map(i => i.message).join(' ')}`);
|
|
625
|
+
}
|
|
626
|
+
const runSchedule = platformRunSchedule(window.startDate, window.endDate);
|
|
627
|
+
const groupStart = groupUrn ? await ctx.client.campaignGroupStart(groupUrn) : null;
|
|
628
|
+
await ctx.client.setRunSchedule(campaignUrn, groupUrn, runSchedule);
|
|
629
|
+
// A group locked before KI-143 may start AFTER the moved campaign; LinkedIn will not serve it before the
|
|
630
|
+
// group's start and will not let the start move. Say so — the sprint runs, the ad waits.
|
|
631
|
+
const groupNote = groupStart != null && groupStart > runSchedule.start
|
|
632
|
+
? ` — NOTE: its campaign group ${groupUrn} starts ${new Date(groupStart).toISOString().slice(0, 10)} and LinkedIn freezes that; the campaign cannot serve before then (KI-143)`
|
|
633
|
+
: '';
|
|
634
|
+
const fmt = (ms) => ms == null ? 'open' : new Date(ms).toISOString().slice(0, 10);
|
|
635
|
+
const note = `LinkedIn Ads: campaign ${campaignUrn} now runs ${fmt(runSchedule.start)} → ${fmt(runSchedule.end)}${groupNote}`;
|
|
636
|
+
LogStatus(`LinkedInAdsProgramDriver.onWindowMoved: ${program.name ?? program.id} — ${note}`);
|
|
637
|
+
return { rescheduled: true, note };
|
|
380
638
|
}
|
|
381
639
|
async loadRefs(programId, user) {
|
|
382
640
|
const res = await new RunView().RunView({
|
|
@@ -455,4 +713,40 @@ LinkedInAdsProgramDriver = __decorate([
|
|
|
455
713
|
], LinkedInAdsProgramDriver);
|
|
456
714
|
export { LinkedInAdsProgramDriver };
|
|
457
715
|
export function LoadLinkedInAdsProgramDriver() { }
|
|
716
|
+
/**
|
|
717
|
+
* LinkedIn's own copy rules, at propose (DRIVERS.md §11: "the only place a platform's rules live"). Lengths that
|
|
718
|
+
* LinkedIn enforces are errors in the shared parser (600 for the intro); what it merely clips is a notice.
|
|
719
|
+
*/
|
|
720
|
+
export function validateLinkedInCreative(config) {
|
|
721
|
+
const issues = [];
|
|
722
|
+
const creative = effectiveCreative(config);
|
|
723
|
+
if (creative.introText && creative.introText.length > INTRO_TEXT_UNCLIPPED_LENGTH) {
|
|
724
|
+
issues.push({
|
|
725
|
+
severity: 'notice', code: 'LI_INTRO_TEXT_LONG',
|
|
726
|
+
message: `The intro text is ${creative.introText.length} characters; LinkedIn shows about ${INTRO_TEXT_UNCLIPPED_LENGTH} before "…see more". Lead with what they get.`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (creative.headlines.length === 1) {
|
|
730
|
+
issues.push({ severity: 'notice', code: 'LI_SINGLE_VARIANT', message: 'One headline makes one ad; a second gives LinkedIn something to compare (R4).' });
|
|
731
|
+
}
|
|
732
|
+
return issues;
|
|
733
|
+
}
|
|
734
|
+
/** R2: the saved audience's own approximate count against the floor LinkedIn serves to and the ceiling Trent set. */
|
|
735
|
+
export /** The one sentence a founder needs when the authorization cannot attach a conversion (KI-148). */ function conversionScopeMessage() {
|
|
736
|
+
return `the LinkedIn authorization behind this connection has no ${CONVERSION_SCOPE} scope, so a conversion cannot be attached to the campaign. `
|
|
737
|
+
+ 'Request the Conversions API product on the developer app, re-authorize with that scope and Rotate the credential in Settings → Connections — until then propose with optimizeFor: "clicks".';
|
|
738
|
+
}
|
|
739
|
+
function audienceSizeIssues(template) {
|
|
740
|
+
const n = template.approximateMemberCount;
|
|
741
|
+
if (n === null) {
|
|
742
|
+
return [{ severity: 'notice', code: 'LI_AUDIENCE_SIZE_UNKNOWN', message: `LinkedIn reports no member count for saved audience "${template.name}".` }];
|
|
743
|
+
}
|
|
744
|
+
if (n > AUDIENCE_MAX_MEMBERS) {
|
|
745
|
+
return [{ severity: 'error', code: 'LI_AUDIENCE_TOO_LARGE', message: `Saved audience "${template.name}" is ~${n.toLocaleString('en-US')} members — over the ${AUDIENCE_MAX_MEMBERS.toLocaleString('en-US')} ceiling. Pick a narrower saved audience (CP-02 served 300M people on a location alone).` }];
|
|
746
|
+
}
|
|
747
|
+
if (n < MATCHED_AUDIENCE_MIN_MEMBERS) {
|
|
748
|
+
return [{ severity: 'error', code: 'LI_AUDIENCE_TOO_SMALL', message: `Saved audience "${template.name}" is ~${n} members — LinkedIn does not serve to fewer than ${MATCHED_AUDIENCE_MIN_MEMBERS}.` }];
|
|
749
|
+
}
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
458
752
|
//# sourceMappingURL=LinkedInAdsProgramDriver.js.map
|