@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.
Files changed (56) hide show
  1. package/dist/__tests__/linkedInTargeting.test.d.ts +2 -0
  2. package/dist/__tests__/linkedInTargeting.test.d.ts.map +1 -0
  3. package/dist/__tests__/linkedInTargeting.test.js +65 -0
  4. package/dist/__tests__/linkedInTargeting.test.js.map +1 -0
  5. package/dist/agents/MarketingCreativeAgent.d.ts +16 -0
  6. package/dist/agents/MarketingCreativeAgent.d.ts.map +1 -0
  7. package/dist/agents/MarketingCreativeAgent.js +28 -0
  8. package/dist/agents/MarketingCreativeAgent.js.map +1 -0
  9. package/dist/agents/MarketingExperimentationAgent.d.ts +39 -0
  10. package/dist/agents/MarketingExperimentationAgent.d.ts.map +1 -1
  11. package/dist/agents/MarketingExperimentationAgent.js +73 -3
  12. package/dist/agents/MarketingExperimentationAgent.js.map +1 -1
  13. package/dist/agents/index.d.ts +1 -0
  14. package/dist/agents/index.d.ts.map +1 -1
  15. package/dist/agents/index.js +3 -0
  16. package/dist/agents/index.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -1
  22. package/dist/programs/google/GoogleAdsProgramDriver.js +14 -3
  23. package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -1
  24. package/dist/programs/google/googleBrandAssets.d.ts +5 -0
  25. package/dist/programs/google/googleBrandAssets.d.ts.map +1 -1
  26. package/dist/programs/google/googleBrandAssets.js +20 -15
  27. package/dist/programs/google/googleBrandAssets.js.map +1 -1
  28. package/dist/programs/index.d.ts +4 -0
  29. package/dist/programs/index.d.ts.map +1 -1
  30. package/dist/programs/index.js +5 -0
  31. package/dist/programs/index.js.map +1 -1
  32. package/dist/programs/linkedin/LinkedInAdsClient.d.ts +123 -2
  33. package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -1
  34. package/dist/programs/linkedin/LinkedInAdsClient.js +183 -26
  35. package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -1
  36. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +38 -7
  37. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -1
  38. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +364 -70
  39. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -1
  40. package/dist/programs/linkedin/linkedInExecutionContext.d.ts +6 -0
  41. package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -1
  42. package/dist/programs/linkedin/linkedInExecutionContext.js +2 -1
  43. package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -1
  44. package/dist/programs/meta/MetaAdsClient.d.ts +101 -0
  45. package/dist/programs/meta/MetaAdsClient.d.ts.map +1 -0
  46. package/dist/programs/meta/MetaAdsClient.js +239 -0
  47. package/dist/programs/meta/MetaAdsClient.js.map +1 -0
  48. package/dist/programs/meta/MetaAdsProgramDriver.d.ts +76 -0
  49. package/dist/programs/meta/MetaAdsProgramDriver.d.ts.map +1 -0
  50. package/dist/programs/meta/MetaAdsProgramDriver.js +431 -0
  51. package/dist/programs/meta/MetaAdsProgramDriver.js.map +1 -0
  52. package/dist/programs/meta/metaAdsExecutionContext.d.ts +39 -0
  53. package/dist/programs/meta/metaAdsExecutionContext.d.ts.map +1 -0
  54. package/dist/programs/meta/metaAdsExecutionContext.js +99 -0
  55. package/dist/programs/meta/metaAdsExecutionContext.js.map +1 -0
  56. 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 | lookalike), dailyBudgetUsd, landingPageUrl, optional creativeHeadline/creativeBody/audienceWindowDays.' },
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
- const adNote = await this.attachCreative(program, ctx, code, config, campaignUrn, organizationUrn, stampedUrl, campaignStatus, existing, user);
140
- LogStatus(`LinkedInAdsProgramDriver.onLock: ${code} ${campaignStatus === 'ACTIVE' ? 'launched' : 'created PAUSED (Settings → Connections decides when it serves)'} — ${config.audience} audience, `
141
- + `$${config.dailyBudgetUsd}/day, campaign ${campaignUrn}; ${adNote}. Stamped landing URL recorded on the campaign ref.`);
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 ad itself: the agent's creative, posted as Direct Sponsored Content from the brand's
145
- * page (a dark post — it exists only inside the campaign, never on the page's feed) and
146
- * attached to the campaign as a creative. Mirrors the Google driver's contract: no headline
147
- * means the campaign launches ad-less and SAYS so; a missing or unusable brand image degrades
148
- * to an article ad (link card) rather than to no ad; a second lock finds the ref and skips.
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 attachCreative(program, ctx, code, config, campaignUrn, organizationUrn, stampedUrl, campaignStatus, existing, user) {
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
- const headline = creative.headlines[0];
155
- if (!headline)
283
+ if (creative.headlines.length === 0)
156
284
  return 'no creative in the levers — campaign launches ad-less';
157
- // ADOPT BEFORE CREATE: an earlier lock may have created the ad and died before recording
158
- // it (the first live run did exactly that). Anything already on this campaign under our
159
- // name is ours — record it and stop.
160
- const adName = platformObjectName(code, 'campaign', 'ad');
161
- const orphan = (await ctx.client.creativesForCampaign(campaignUrn)).find(c => c.name === adName && c.intendedStatus !== 'ARCHIVED');
162
- if (orphan) {
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
- try {
175
- imageUrn = await this.uploadBrandImage(program.brandId, organizationUrn, ctx, user);
176
- if (imageUrn)
177
- imageNote = 'single-image ad with the brand marketing image';
178
- }
179
- catch (e) {
180
- imageNote = `article ad — brand image not used (${e instanceof Error ? e.message : String(e)})`;
181
- LogStatus(`LinkedInAdsProgramDriver.onLock: ${code} ${imageNote}`);
182
- }
183
- const creativeUrn = await ctx.client.createInlineCreative({
184
- campaignUrn, organizationUrn,
185
- name: adName,
186
- commentary, headline, description,
187
- landingPageUrl: stampedUrl,
188
- imageUrn,
189
- // The campaign's status governs serving; the creative itself is ready.
190
- intendedStatus: 'ACTIVE',
191
- });
192
- const postUrn = await ctx.client.creativePostUrn(creativeUrn).catch(() => null);
193
- await this.saveRef(program.id, ctx.brandConnectionId, 'Creative', creativeUrn, {
194
- postUrn, imageUrn, headline, description, commentary, callToAction: 'LEARN_MORE',
195
- format: imageUrn ? 'single-image' : 'article', campaignStatus,
196
- }, user);
197
- return `${imageNote} attached as ${creativeUrn}`;
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
- const url = brand.MarketingImageURL;
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(`marketing image URL refused: ${safety.reason}`);
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(`marketing image URL returned ${res.status}`);
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(`marketing image is ${type || 'unknown type'} — PNG or JPEG required`);
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('marketing image exceeds 5MB');
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 campaigns = refs.filter(r => r.ExternalKind === 'Campaign').map(r => r.ExternalURN);
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
- await ctx.client.setCreativeStatus(ref.ExternalURN, 'ARCHIVED');
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: 'ARCHIVED', postDeleted: typeof postUrn === 'string', retiredFor: reason }, user);
312
- archived++;
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 and their posts deleted (${reason})`
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
- const start = Number.isFinite(startMs) ? Math.max(startMs, now) : now;
379
- return Number.isFinite(endMs) && endMs > start ? { start, end: endMs } : { start };
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