fopost 0.2.0 → 0.3.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.
@@ -2,11 +2,22 @@
2
2
 
3
3
  module Fopost
4
4
  module Resources
5
- # `client.ads` — Meta ads, audiences and lead forms.
5
+ # `client.ads` — ads, catalogs, audiences, the ad archive and lead forms.
6
+ #
7
+ # The connection decides which network a call reaches. Meta is what this
8
+ # resource documents; the Google-only surface is `client.ads.google`, and
9
+ # `#providers` reports what each network supports.
6
10
  #
7
11
  # Every method needs the `ads` scope. {#boost}, {#create}, {#set_status}
8
- # and {#delete} spend money and also need `publish`.
12
+ # and {#delete} spend money and also need `publish`, as do the create,
13
+ # update, delete and duplicate methods for campaigns, ad sets and network
14
+ # ads, and {#bulk_set_status}.
9
15
  class Ads < Base
16
+ # The Search surface no other network has: keywords, assets, conversions, GAQL.
17
+ def google
18
+ @google ||= GoogleAds.new(http)
19
+ end
20
+
10
21
  # Boosts and ads created through FoPost, with insights from their last refresh.
11
22
  def list(workspace_id: nil)
12
23
  parse_list(Ad, unwrap(http.get('/ads', { 'workspace_id' => workspace_id })))
@@ -30,11 +41,29 @@ module Fopost
30
41
  parse_list(AdSource, unwrap(http.get('/ads/sources', { 'workspace_id' => workspace_id })))
31
42
  end
32
43
 
33
- # The Meta login URL; the caller finishes it in their own browser.
34
- # `method` is "business" (default) or "user".
35
- def authorize_meta(workspace_id:, method: nil, return_to: nil)
44
+ # The ad networks this deployment knows, with what each one supports.
45
+ def providers
46
+ parse_list(AdProvider, unwrap(http.get('/ads/providers')))
47
+ end
48
+
49
+ # The network's login URL; the caller finishes it in their own browser.
50
+ # `method` is one of the network's own `connect_methods`.
51
+ def authorize(provider, workspace_id:, method: nil, return_to: nil)
36
52
  body = compact_nil('workspaceId' => workspace_id, 'method' => method, 'returnTo' => return_to)
37
- result = unwrap(http.post('/ads/connections/meta/authorize', body))
53
+ result = unwrap(http.post("/ads/connections/#{provider}/authorize", body))
54
+ url = result.is_a?(Hash) ? result['url'] : nil
55
+ url.nil? ? '' : url.to_s
56
+ end
57
+
58
+ # Deprecated: use `authorize("meta", ...)`.
59
+ def authorize_meta(workspace_id:, method: nil, return_to: nil)
60
+ authorize('meta', workspace_id: workspace_id, method: method, return_to: return_to)
61
+ end
62
+
63
+ # The Google login URL; the caller finishes it in their own browser.
64
+ def authorize_google(workspace_id:, return_to: nil)
65
+ body = compact_nil('workspaceId' => workspace_id, 'returnTo' => return_to)
66
+ result = unwrap(http.post('/ads/connections/google/authorize', body))
38
67
  url = result.is_a?(Hash) ? result['url'] : nil
39
68
  url.nil? ? '' : url.to_s
40
69
  end
@@ -67,7 +96,8 @@ module Fopost
67
96
  # Create a standalone ad from a creative. Needs `ads` and `publish`.
68
97
  # The ad starts paused unless `paused: false`.
69
98
  def create(workspace_id:, connection_id:, ad_account_id:, page_id:, name:, goal:, budget:, targeting:,
70
- text:, headline: nil, destination_url: nil, media_url: nil, paused: nil)
99
+ text:, headline: nil, destination_url: nil, media_url: nil, url_tags: nil,
100
+ spark_post_id: nil, paused: nil)
71
101
  body = {
72
102
  'workspaceId' => workspace_id,
73
103
  'connectionId' => connection_id,
@@ -84,6 +114,8 @@ module Fopost
84
114
  'headline' => headline,
85
115
  'destinationUrl' => destination_url,
86
116
  'mediaUrl' => media_url,
117
+ 'urlTags' => url_tags,
118
+ 'sparkPostId' => spark_post_id,
87
119
  'paused' => paused
88
120
  )
89
121
  )
@@ -184,8 +216,879 @@ module Fopost
184
216
  )
185
217
  end
186
218
 
219
+ # Campaigns, ad sets and ads on one ad account, read live from Meta.
220
+ def account_tree(ad_account_id, connection_id:, workspace_id: nil)
221
+ AdAccountTree.new(
222
+ unwrap(http.get("/ads/accounts/#{ad_account_id}/tree", meta_query(workspace_id, connection_id)))
223
+ )
224
+ end
225
+
226
+ # `goal` is engagement, traffic, awareness or video_views. Starts paused
227
+ # unless `paused: false`. Needs `ads` and `publish`.
228
+ # TikTok's Business Centers, the one network-named read in this resource.
229
+ def tiktok_business_centers(connection_id:, workspace_id: nil)
230
+ parse_list(
231
+ AdBusinessCenter,
232
+ unwrap(
233
+ http.get(
234
+ '/ads/tiktok/business-centers',
235
+ { 'workspace_id' => workspace_id, 'connection_id' => connection_id }
236
+ )
237
+ )
238
+ )
239
+ end
240
+
241
+ # The accounts an ad can run as; an identity id is a page_id.
242
+ def tiktok_identities(connection_id:, ad_account_id:, workspace_id: nil)
243
+ parse_list(
244
+ AdIdentity,
245
+ unwrap(
246
+ http.get(
247
+ '/ads/tiktok/identities',
248
+ { 'workspace_id' => workspace_id, 'connection_id' => connection_id,
249
+ 'ad_account_id' => ad_account_id }
250
+ )
251
+ )
252
+ )
253
+ end
254
+
255
+ # Posts already live under an identity, each a candidate Spark ad.
256
+ def spark_posts(connection_id:, ad_account_id:, identity_id:, workspace_id: nil)
257
+ parse_list(
258
+ SparkPost,
259
+ unwrap(
260
+ http.get(
261
+ '/ads/spark-posts',
262
+ { 'workspace_id' => workspace_id, 'connection_id' => connection_id,
263
+ 'ad_account_id' => ad_account_id, 'identity_id' => identity_id }
264
+ )
265
+ )
266
+ )
267
+ end
268
+
269
+ # Offline conversions. Identifiers are hashed before they leave FoPost.
270
+ def upload_conversions(workspace_id:, connection_id:, ad_account_id:, pixel_id:, events:)
271
+ unwrap(
272
+ http.post(
273
+ '/ads/conversions',
274
+ { 'workspaceId' => workspace_id, 'connectionId' => connection_id,
275
+ 'adAccountId' => ad_account_id, 'pixelId' => pixel_id,
276
+ 'events' => events.map { |e| stringify(e) } }
277
+ )
278
+ )
279
+ end
280
+
281
+ # One page of an ad's comments; pass `next_cursor` back as `after`.
282
+ def comments(connection_id:, ad_id:, after: nil, workspace_id: nil)
283
+ AdCommentsPage.new(
284
+ unwrap(
285
+ http.get(
286
+ '/ads/comments',
287
+ { 'workspace_id' => workspace_id, 'connection_id' => connection_id,
288
+ 'ad_id' => ad_id, 'after' => after }
289
+ )
290
+ )
291
+ )
292
+ end
293
+
294
+ # Needs the `publish` scope as well as `ads`.
295
+ def reply_to_comment(comment_id, workspace_id:, connection_id:, ad_id:, text:)
296
+ unwrap(
297
+ http.post(
298
+ "/ads/comments/#{comment_id}/reply",
299
+ { 'workspaceId' => workspace_id, 'connectionId' => connection_id,
300
+ 'adId' => ad_id, 'text' => text }
301
+ )
302
+ )
303
+ end
304
+
305
+ # Needs the `publish` scope as well as `ads`.
306
+ def set_comment_hidden(comment_id, workspace_id:, connection_id:, ad_id:, hidden:)
307
+ http.post(
308
+ "/ads/comments/#{comment_id}/hide",
309
+ { 'workspaceId' => workspace_id, 'connectionId' => connection_id,
310
+ 'adId' => ad_id, 'hidden' => hidden }
311
+ )
312
+ nil
313
+ end
314
+
315
+ # One already gone on the network succeeds. Needs `publish` as well as `ads`.
316
+ def delete_comment(comment_id, workspace_id:, connection_id:, ad_id:)
317
+ http.request(
318
+ :delete,
319
+ "/ads/comments/#{comment_id}",
320
+ json: { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adId' => ad_id }
321
+ )
322
+ nil
323
+ end
324
+
325
+ def create_campaign(workspace_id:, connection_id:, ad_account_id:, name:, goal:, paused: nil,
326
+ smart_plus: nil)
327
+ body = {
328
+ 'workspaceId' => workspace_id,
329
+ 'connectionId' => connection_id,
330
+ 'adAccountId' => ad_account_id,
331
+ 'name' => name,
332
+ 'goal' => goal
333
+ }
334
+ body['paused'] = paused unless paused.nil?
335
+ body['smartPlus'] = smart_plus unless smart_plus.nil?
336
+ AdCampaign.new(unwrap(http.post('/ads/campaigns', body)))
337
+ end
338
+
339
+ def get_campaign(campaign_id, connection_id:, workspace_id: nil)
340
+ AdCampaign.new(unwrap(http.get("/ads/campaigns/#{campaign_id}", meta_query(workspace_id, connection_id))))
341
+ end
342
+
343
+ # `status` is "active" or "paused". Needs `ads` and `publish`.
344
+ def update_campaign(campaign_id, workspace_id:, connection_id:, name: nil, status: nil)
345
+ AdCampaign.new(
346
+ patch_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id,
347
+ 'name' => name, 'status' => status)
348
+ )
349
+ end
350
+
351
+ # Needs `ads` and `publish`.
352
+ def delete_campaign(campaign_id, workspace_id:, connection_id:)
353
+ delete_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id)
354
+ end
355
+
356
+ # Copy the campaign on Meta and return the copy's id. Needs `ads` and `publish`.
357
+ def duplicate_campaign(campaign_id, workspace_id:, connection_id:, paused: nil)
358
+ duplicate_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id, paused)
359
+ end
360
+
361
+ # `budget` is `{ minor:, type:, endAt: }`; `targeting` uses the API's camelCase keys.
362
+ # Starts paused unless `paused: false`. Needs `ads` and `publish`.
363
+ def create_ad_set(workspace_id:, connection_id:, campaign_id:, page_id:, name:, goal:, budget:, targeting:,
364
+ paused: nil)
365
+ body = {
366
+ 'workspaceId' => workspace_id,
367
+ 'connectionId' => connection_id,
368
+ 'campaignId' => campaign_id,
369
+ 'pageId' => page_id,
370
+ 'name' => name,
371
+ 'goal' => goal,
372
+ 'budget' => stringify(budget),
373
+ 'targeting' => stringify(targeting)
374
+ }
375
+ body['paused'] = paused unless paused.nil?
376
+ AdSet.new(unwrap(http.post('/ads/ad-sets', body)))
377
+ end
378
+
379
+ def get_ad_set(ad_set_id, connection_id:, workspace_id: nil)
380
+ AdSet.new(unwrap(http.get("/ads/ad-sets/#{ad_set_id}", meta_query(workspace_id, connection_id))))
381
+ end
382
+
383
+ # Needs `ads` and `publish`.
384
+ def update_ad_set(ad_set_id, workspace_id:, connection_id:, name: nil, status: nil, budget_minor: nil,
385
+ end_at: nil, targeting: nil)
386
+ AdSet.new(
387
+ patch_object(
388
+ "/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id,
389
+ 'name' => name, 'status' => status, 'budgetMinor' => budget_minor, 'endAt' => iso8601(end_at),
390
+ 'targeting' => targeting.nil? ? nil : stringify(targeting)
391
+ )
392
+ )
393
+ end
394
+
395
+ # Needs `ads` and `publish`.
396
+ def delete_ad_set(ad_set_id, workspace_id:, connection_id:)
397
+ delete_object("/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id)
398
+ end
399
+
400
+ # Needs `ads` and `publish`.
401
+ def duplicate_ad_set(ad_set_id, workspace_id:, connection_id:, paused: nil)
402
+ duplicate_object("/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id, paused)
403
+ end
404
+
405
+ # An ad inside an ad set, from an existing creative (unlike {#create}).
406
+ # Starts paused unless `paused: false`. Needs `ads` and `publish`.
407
+ def create_network_ad(workspace_id:, connection_id:, ad_set_id:, creative_id:, name:, paused: nil)
408
+ body = {
409
+ 'workspaceId' => workspace_id,
410
+ 'connectionId' => connection_id,
411
+ 'adSetId' => ad_set_id,
412
+ 'creativeId' => creative_id,
413
+ 'name' => name
414
+ }
415
+ body['paused'] = paused unless paused.nil?
416
+ NetworkAd.new(unwrap(http.post('/ads/ads', body)))
417
+ end
418
+
419
+ def get_network_ad(ad_id, connection_id:, workspace_id: nil)
420
+ NetworkAd.new(unwrap(http.get("/ads/ads/#{ad_id}", meta_query(workspace_id, connection_id))))
421
+ end
422
+
423
+ # Needs `ads` and `publish`.
424
+ def update_network_ad(ad_id, workspace_id:, connection_id:, name: nil, status: nil, creative_id: nil)
425
+ NetworkAd.new(
426
+ patch_object("/ads/ads/#{ad_id}", workspace_id, connection_id,
427
+ 'name' => name, 'status' => status, 'creativeId' => creative_id)
428
+ )
429
+ end
430
+
431
+ # Needs `ads` and `publish`.
432
+ def delete_network_ad(ad_id, workspace_id:, connection_id:)
433
+ delete_object("/ads/ads/#{ad_id}", workspace_id, connection_id)
434
+ end
435
+
436
+ # Needs `ads` and `publish`.
437
+ def duplicate_network_ad(ad_id, workspace_id:, connection_id:, paused: nil)
438
+ duplicate_object("/ads/ads/#{ad_id}", workspace_id, connection_id, paused)
439
+ end
440
+
441
+ # Pause or activate many objects at once. Each of `objects` is `{ id:, level: }`
442
+ # with level campaign, ad_set or ad. Needs `ads` and `publish`.
443
+ def bulk_set_status(workspace_id:, connection_id:, status:, objects:)
444
+ body = {
445
+ 'workspaceId' => workspace_id,
446
+ 'connectionId' => connection_id,
447
+ 'status' => status,
448
+ 'objects' => objects.to_a.map { |object| stringify(object) }
449
+ }
450
+ parse_list(BulkAdStatusResult, unwrap(http.post('/ads/status', body)))
451
+ end
452
+
453
+ def creatives(connection_id:, ad_account_id:, workspace_id: nil)
454
+ result = unwrap(
455
+ http.get('/ads/creatives', meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id))
456
+ )
457
+ parse_list(AdCreative, result.is_a?(Hash) ? result['creatives'] : nil)
458
+ end
459
+
460
+ # `format` is image, video or carousel; a carousel takes `cards`
461
+ # (`{ mediaUrl:, destinationUrl:, headline:, description: }`).
462
+ def create_creative(workspace_id:, connection_id:, ad_account_id:, page_id:, name:, format:, text:,
463
+ headline: nil, destination_url: nil, call_to_action: nil, url_tags: nil, media_url: nil,
464
+ thumbnail_media_url: nil, cards: nil)
465
+ body = {
466
+ 'workspaceId' => workspace_id,
467
+ 'connectionId' => connection_id,
468
+ 'adAccountId' => ad_account_id,
469
+ 'pageId' => page_id,
470
+ 'name' => name,
471
+ 'format' => format,
472
+ 'text' => text
473
+ }
474
+ body.merge!(
475
+ compact_nil(
476
+ 'headline' => headline,
477
+ 'destinationUrl' => destination_url,
478
+ 'callToAction' => call_to_action,
479
+ 'urlTags' => url_tags,
480
+ 'mediaUrl' => media_url,
481
+ 'thumbnailMediaUrl' => thumbnail_media_url,
482
+ 'cards' => cards&.map { |card| stringify(card) }
483
+ )
484
+ )
485
+ AdCreative.new(unwrap(http.post('/ads/creatives', body)))
486
+ end
487
+
488
+ def get_creative(creative_id, connection_id:, workspace_id: nil)
489
+ AdCreative.new(unwrap(http.get("/ads/creatives/#{creative_id}", meta_query(workspace_id, connection_id))))
490
+ end
491
+
492
+ def delete_creative(creative_id, workspace_id:, connection_id:)
493
+ delete_object("/ads/creatives/#{creative_id}", workspace_id, connection_id)
494
+ end
495
+
496
+ def get_audience(audience_id, connection_id:, workspace_id: nil)
497
+ Audience.new(unwrap(http.get("/ads/audiences/#{audience_id}", meta_query(workspace_id, connection_id))))
498
+ end
499
+
500
+ def update_audience(audience_id, workspace_id:, connection_id:, name: nil, description: nil)
501
+ Audience.new(
502
+ patch_object("/ads/audiences/#{audience_id}", workspace_id, connection_id,
503
+ 'name' => name, 'description' => description)
504
+ )
505
+ end
506
+
507
+ def delete_audience(audience_id, workspace_id:, connection_id:)
508
+ delete_object("/ads/audiences/#{audience_id}", workspace_id, connection_id)
509
+ end
510
+
511
+ # Add customers to a custom audience by email. Returns how many were sent to Meta.
512
+ def add_audience_users(audience_id, workspace_id:, connection_id:, emails:)
513
+ result = unwrap(
514
+ http.request(:post, "/ads/audiences/#{audience_id}/users",
515
+ json: { 'emails' => emails.to_a }, params: meta_query(workspace_id, connection_id))
516
+ )
517
+ result.is_a?(Hash) ? result['added'].to_i : 0
518
+ end
519
+
520
+ # Add companies to a company-list audience. Answers the count the network took.
521
+ # Each row needs a name, domain, pageUrl or ticker; the rows are never stored.
522
+ def add_audience_companies(audience_id, workspace_id:, connection_id:, companies:)
523
+ result = unwrap(
524
+ http.request(:post, "/ads/audiences/#{audience_id}/companies",
525
+ json: { 'companies' => companies.map { |c| stringify(c) } },
526
+ params: meta_query(workspace_id, connection_id))
527
+ )
528
+ result.is_a?(Hash) ? result['added'].to_i : 0
529
+ end
530
+
531
+ # What the auction currently costs for that audience.
532
+ def bid_pricing(workspace_id:, connection_id:, ad_account_id:, goal:, targeting:,
533
+ placements: nil, bid_type: nil)
534
+ body = forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
535
+ body['bidType'] = bid_type unless bid_type.nil?
536
+ BidPricing.new(unwrap(http.post('/ads/linkedin/bid-pricing', body)))
537
+ end
538
+
539
+ # What that audience would deliver at that budget.
540
+ def supply_forecast(workspace_id:, connection_id:, ad_account_id:, goal:, targeting:,
541
+ placements: nil, budget_minor: nil)
542
+ body = forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
543
+ body['budgetMinor'] = budget_minor unless budget_minor.nil?
544
+ SupplyForecast.new(unwrap(http.post('/ads/linkedin/supply-forecast', body)))
545
+ end
546
+
547
+ def conversion_rules(connection_id:, ad_account_id:, workspace_id: nil)
548
+ query = meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id)
549
+ parse_list(ConversionRule, unwrap(http.get('/ads/linkedin/conversion-rules', query)))
550
+ end
551
+
552
+ # Answers the new rule's id.
553
+ def create_conversion_rule(workspace_id:, connection_id:, ad_account_id:, name:, type:, attribution:,
554
+ post_click_window_days: nil, view_through_window_days: nil,
555
+ value_minor: nil, currency: nil)
556
+ body = compact_nil(
557
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
558
+ 'name' => name, 'type' => type, 'attribution' => attribution,
559
+ 'postClickWindowDays' => post_click_window_days,
560
+ 'viewThroughWindowDays' => view_through_window_days,
561
+ 'valueMinor' => value_minor, 'currency' => currency
562
+ )
563
+ result = unwrap(http.post('/ads/linkedin/conversion-rules', body))
564
+ id = result.is_a?(Hash) ? result['id'] : nil
565
+ id.nil? ? '' : id.to_s
566
+ end
567
+
568
+ def get_conversion_rule(rule_id, connection_id:, workspace_id: nil)
569
+ ConversionRule.new(
570
+ unwrap(http.get(rule_path(rule_id), meta_query(workspace_id, connection_id)))
571
+ )
572
+ end
573
+
574
+ # Keys are the API's own: name, type, attribution, postClickWindowDays,
575
+ # viewThroughWindowDays, valueMinor, currency, enabled.
576
+ def update_conversion_rule(rule_id, workspace_id:, connection_id:, **changes)
577
+ ConversionRule.new(patch_object(rule_path(rule_id), workspace_id, connection_id, stringify(changes)))
578
+ end
579
+
580
+ # Turns the rule off; the network keeps the history.
581
+ def delete_conversion_rule(rule_id, workspace_id:, connection_id:)
582
+ delete_object(rule_path(rule_id), workspace_id, connection_id)
583
+ end
584
+
585
+ def attach_conversion_rule(rule_id, workspace_id:, connection_id:, campaign_id:)
586
+ association(:post, rule_id, workspace_id, connection_id, campaign_id)
587
+ end
588
+
589
+ def detach_conversion_rule(rule_id, workspace_id:, connection_id:, campaign_id:)
590
+ association(:delete, rule_id, workspace_id, connection_id, campaign_id)
591
+ end
592
+
593
+ # What the rule recorded between two YYYY-MM-DD days, inclusive.
594
+ def conversion_metrics(rule_id, connection_id:, since:, until:, workspace_id: nil)
595
+ until_date = binding.local_variable_get(:until)
596
+ query = meta_query(workspace_id, connection_id)
597
+ .merge('since' => since.to_s, 'until' => until_date.to_s)
598
+ ConversionMetrics.new(unwrap(http.get("#{rule_path(rule_id)}/metrics", query)))
599
+ end
600
+
601
+ # Send conversions back to the network. Answers how many it took. Each event
602
+ # needs happenedAt in epoch milliseconds and an email or a clickId; the address
603
+ # is hashed inside the API and nothing about an event is stored.
604
+ def send_conversion_events(rule_id, workspace_id:, connection_id:, events:)
605
+ result = unwrap(
606
+ http.request(:post, "#{rule_path(rule_id)}/events",
607
+ json: { 'events' => events.map { |e| stringify(e) } },
608
+ params: meta_query(workspace_id, connection_id))
609
+ )
610
+ result.is_a?(Hash) ? result['accepted'].to_i : 0
611
+ end
612
+
613
+ def estimate_reach(workspace_id:, connection_id:, ad_account_id:, page_id:, targeting:)
614
+ body = {
615
+ 'workspaceId' => workspace_id,
616
+ 'connectionId' => connection_id,
617
+ 'adAccountId' => ad_account_id,
618
+ 'pageId' => page_id,
619
+ 'targeting' => stringify(targeting)
620
+ }
621
+ ReachEstimate.new(unwrap(http.post('/ads/reach-estimate', body)))
622
+ end
623
+
624
+ # Insights for any campaign, ad set or ad id on Meta. `breakdown` is age,
625
+ # gender, placement or country; `daily: true` adds a per-day timeline.
626
+ def insights(connection_id:, object_id:, since:, until:, breakdown: nil, daily: nil, workspace_id: nil)
627
+ until_date = binding.local_variable_get(:until)
628
+ params = meta_query(workspace_id, connection_id).merge(
629
+ 'object_id' => object_id, **insights_query(since, until_date, breakdown, daily)
630
+ )
631
+ AdInsightsReport.new(unwrap(http.get('/ads/insights', params)))
632
+ end
633
+
634
+ # Insights for an ad created through FoPost, by its FoPost id.
635
+ def ad_insights(ad_id, workspace_id:, since:, until:, breakdown: nil, daily: nil)
636
+ until_date = binding.local_variable_get(:until)
637
+ params = { 'workspace_id' => workspace_id, **insights_query(since, until_date, breakdown, daily) }
638
+ AdInsightsReport.new(unwrap(http.get("/ads/#{ad_id}/insights", params)))
639
+ end
640
+
641
+ def get_lead_form(form_id, connection_id:, page_id:, workspace_id: nil)
642
+ LeadFormDetail.new(
643
+ unwrap(
644
+ http.get("/ads/lead-forms/#{form_id}",
645
+ meta_query(workspace_id, connection_id).merge('page_id' => page_id))
646
+ )
647
+ )
648
+ end
649
+
650
+ def archive_lead_form(form_id, workspace_id:, connection_id:, page_id:)
651
+ body = { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'pageId' => page_id }
652
+ LeadFormDetail.new(unwrap(http.post("/ads/lead-forms/#{form_id}/archive", body)))
653
+ end
654
+
655
+ # Stored leads from subscribed Pages, newest first. Pass `next_cursor`
656
+ # back as `cursor:` for the next page.
657
+ def leads_feed(form_id: nil, page_id: nil, cursor: nil, limit: nil, workspace_id: nil)
658
+ LeadsFeedPage.new(
659
+ unwrap(
660
+ http.get(
661
+ '/ads/leads',
662
+ { 'workspace_id' => workspace_id, 'form_id' => form_id, 'page_id' => page_id, 'cursor' => cursor,
663
+ 'limit' => limit }
664
+ )
665
+ )
666
+ )
667
+ end
668
+
669
+ # Pages subscribed to lead delivery.
670
+ def lead_pages(workspace_id: nil)
671
+ parse_list(LeadPage, unwrap(http.get('/ads/lead-pages', { 'workspace_id' => workspace_id })))
672
+ end
673
+
674
+ # Subscribe a Page to lead delivery. Answers `{ "pageId", "backfilled" }`.
675
+ def subscribe_lead_page(workspace_id:, connection_id:, page_id:)
676
+ body = { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'pageId' => page_id }
677
+ as_hash(unwrap(http.post('/ads/lead-pages', body)))
678
+ end
679
+
680
+ def unsubscribe_lead_page(page_id, workspace_id:, connection_id:)
681
+ delete_object("/ads/lead-pages/#{page_id}", workspace_id, connection_id)
682
+ end
683
+
684
+ # ─── Goals ──────────────────────────────────────────────────
685
+
686
+ # The goals this connection's network can run right now. Ask rather than
687
+ # assume: a goal the deployment is not set up for is absent here and is
688
+ # refused if you send it anyway.
689
+ def goals(connection_id:, workspace_id: nil)
690
+ result = unwrap(http.get('/ads/goals', meta_query(workspace_id, connection_id)))
691
+ result.is_a?(Array) ? result.map(&:to_s) : []
692
+ end
693
+
694
+ # ─── Product catalogs ───────────────────────────────────────
695
+
696
+ # Catalogs the connection's business portfolios reach. Read live, never stored.
697
+ def catalogs(connection_id:, workspace_id: nil)
698
+ ProductCatalogsResult.from(unwrap(http.get('/ads/catalogs', meta_query(workspace_id, connection_id))))
699
+ end
700
+
701
+ # Created on the connection's business portfolio. Also needs `publish`.
702
+ def create_catalog(workspace_id:, connection_id:, name:, vertical: nil)
703
+ body = compact_nil(
704
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
705
+ 'name' => name, 'vertical' => vertical
706
+ )
707
+ ProductCatalog.from(unwrap(http.post('/ads/catalogs', body)))
708
+ end
709
+
710
+ def catalog(catalog_id, connection_id:, workspace_id: nil)
711
+ ProductCatalog.from(
712
+ unwrap(http.get("/ads/catalogs/#{catalog_id}", meta_query(workspace_id, connection_id)))
713
+ )
714
+ end
715
+
716
+ # Also needs `publish`.
717
+ def update_catalog(catalog_id, workspace_id:, connection_id:, name:)
718
+ ProductCatalog.from(patch_object(
719
+ "/ads/catalogs/#{catalog_id}", workspace_id, connection_id,
720
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'name' => name
721
+ ))
722
+ end
723
+
724
+ # Deletes every product, feed and set in it. Also needs `publish`.
725
+ def delete_catalog(catalog_id, workspace_id:, connection_id:)
726
+ delete_object("/ads/catalogs/#{catalog_id}", workspace_id, connection_id)
727
+ end
728
+
729
+ # One page of products; pass `next_cursor` back as `after`.
730
+ def catalog_products(catalog_id, connection_id:, workspace_id: nil, after: nil)
731
+ query = meta_query(workspace_id, connection_id).merge('after' => after)
732
+ CatalogProductsPage.from(unwrap(http.get("/ads/catalogs/#{catalog_id}/products", query)))
733
+ end
734
+
735
+ # Up to 500 upserts and deletes in one batch, keyed by your own `retailerId`.
736
+ # Also needs `publish`.
737
+ def write_catalog_products(catalog_id, workspace_id:, connection_id:, products:)
738
+ body = {
739
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
740
+ 'products' => products.map { |p| stringify(p) }
741
+ }
742
+ CatalogBatchResult.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/products", body)))
743
+ end
744
+
745
+ def product_feeds(catalog_id, connection_id:, workspace_id: nil)
746
+ parse_list(
747
+ ProductFeed,
748
+ unwrap(http.get("/ads/catalogs/#{catalog_id}/feeds", meta_query(workspace_id, connection_id)))
749
+ )
750
+ end
751
+
752
+ # `schedule` is "HOURLY", "DAILY" or "WEEKLY" and needs `url`. Also needs `publish`.
753
+ def create_product_feed(catalog_id, workspace_id:, connection_id:, name:, url: nil, schedule: nil)
754
+ body = compact_nil(
755
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
756
+ 'name' => name, 'url' => url, 'schedule' => schedule
757
+ )
758
+ ProductFeed.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/feeds", body)))
759
+ end
760
+
761
+ # Also needs `publish`.
762
+ def delete_product_feed(catalog_id, feed_id, workspace_id:, connection_id:)
763
+ delete_object("/ads/catalogs/#{catalog_id}/feeds/#{feed_id}", workspace_id, connection_id)
764
+ end
765
+
766
+ # Each run the network made of the feed.
767
+ def feed_uploads(catalog_id, feed_id, connection_id:, workspace_id: nil)
768
+ path = "/ads/catalogs/#{catalog_id}/feeds/#{feed_id}/uploads"
769
+ parse_list(ProductFeedUpload, unwrap(http.get(path, meta_query(workspace_id, connection_id))))
770
+ end
771
+
772
+ # Fetches the feed now; the id of the run. Also needs `publish`.
773
+ def start_feed_upload(catalog_id, feed_id, workspace_id:, connection_id:, url: nil)
774
+ body = compact_nil('workspaceId' => workspace_id, 'connectionId' => connection_id, 'url' => url)
775
+ result = unwrap(http.post("/ads/catalogs/#{catalog_id}/feeds/#{feed_id}/uploads", body))
776
+ id = result.is_a?(Hash) ? result['id'] : nil
777
+ id.nil? ? '' : id.to_s
778
+ end
779
+
780
+ # A catalog ad runs from a product set, not the whole catalog.
781
+ def product_sets(catalog_id, connection_id:, workspace_id: nil)
782
+ parse_list(ProductSet, unwrap(http.get(
783
+ "/ads/catalogs/#{catalog_id}/product-sets", meta_query(workspace_id,
784
+ connection_id)
785
+ )))
786
+ end
787
+
788
+ # Without a `filter` the set is the whole catalog. Also needs `publish`.
789
+ def create_product_set(catalog_id, workspace_id:, connection_id:, name:, filter: nil)
790
+ body = compact_nil(
791
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
792
+ 'name' => name, 'filter' => filter.nil? ? nil : stringify(filter)
793
+ )
794
+ ProductSet.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/product-sets", body)))
795
+ end
796
+
797
+ # Also needs `publish`.
798
+ def update_product_set(catalog_id, set_id, workspace_id:, connection_id:, name:, filter: nil)
799
+ ProductSet.from(patch_object(
800
+ "/ads/catalogs/#{catalog_id}/product-sets/#{set_id}", workspace_id, connection_id,
801
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
802
+ 'name' => name, 'filter' => filter.nil? ? nil : stringify(filter)
803
+ ))
804
+ end
805
+
806
+ # Also needs `publish`.
807
+ def delete_product_set(catalog_id, set_id, workspace_id:, connection_id:)
808
+ delete_object("/ads/catalogs/#{catalog_id}/product-sets/#{set_id}", workspace_id, connection_id)
809
+ end
810
+
811
+ # ─── Reach and frequency ────────────────────────────────────
812
+
813
+ def reach_frequency(connection_id:, ad_account_id:, workspace_id: nil)
814
+ query = account_query(workspace_id, connection_id, ad_account_id)
815
+ ReachFrequencyResult.from(unwrap(http.get('/ads/reach-frequency', query)))
816
+ end
817
+
818
+ # Prices a flight. Nothing is bought until you reserve it.
819
+ def create_reach_frequency(workspace_id:, connection_id:, ad_account_id:, name:, targeting:,
820
+ placements:, budget_minor:, start_at:, end_at:, frequency_cap: nil)
821
+ body = compact_nil(
822
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
823
+ 'name' => name, 'targeting' => stringify(targeting), 'placements' => placements.map(&:to_s),
824
+ 'budgetMinor' => budget_minor, 'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
825
+ 'frequencyCap' => frequency_cap
826
+ )
827
+ ReachFrequencyPrediction.from(unwrap(http.post('/ads/reach-frequency', body)))
828
+ end
829
+
830
+ def reach_frequency_prediction(prediction_id, connection_id:, ad_account_id:, workspace_id: nil)
831
+ query = account_query(workspace_id, connection_id, ad_account_id)
832
+ ReachFrequencyPrediction.from(unwrap(http.get("/ads/reach-frequency/#{prediction_id}", query)))
833
+ end
834
+
835
+ # Holds the inventory the prediction priced. Also needs `publish`.
836
+ def reserve_reach_frequency(prediction_id, workspace_id:, connection_id:, ad_account_id:)
837
+ reach_frequency_action(prediction_id, 'reserve', workspace_id, connection_id, ad_account_id)
838
+ end
839
+
840
+ # Also needs `publish`.
841
+ def cancel_reach_frequency(prediction_id, workspace_id:, connection_id:, ad_account_id:)
842
+ reach_frequency_action(prediction_id, 'cancel', workspace_id, connection_id, ad_account_id)
843
+ end
844
+
845
+ # ─── Ad Library ─────────────────────────────────────────────
846
+
847
+ # The public ad archive: ads anyone is running, by keyword or by Page. Read
848
+ # live on every call and stored nowhere, so an ad that stops running is
849
+ # simply absent from the next search.
850
+ def library(connection_id:, countries:, workspace_id: nil, q: nil, page_ids: nil,
851
+ active_status: nil, limit: nil, after: nil)
852
+ query = meta_query(workspace_id, connection_id).merge(
853
+ 'countries' => Array(countries).join(','),
854
+ 'q' => q,
855
+ 'page_ids' => page_ids.nil? ? nil : Array(page_ids).join(','),
856
+ 'active_status' => active_status,
857
+ 'limit' => limit&.to_s,
858
+ 'after' => after
859
+ )
860
+ AdLibraryPage.from(unwrap(http.get('/ads/library', query)))
861
+ end
862
+
863
+ # ─── Partnership ads ────────────────────────────────────────
864
+
865
+ # Creators who allowlisted this Page to run partnership ads on their posts.
866
+ def partnership_creators(connection_id:, page_id:, workspace_id: nil)
867
+ query = meta_query(workspace_id, connection_id).merge('page_id' => page_id)
868
+ parse_list(PartnershipCreator, unwrap(http.get('/ads/partnership/creators', query)))
869
+ end
870
+
871
+ # Asks a creator for permission; the list as it now stands.
872
+ def request_partnership(workspace_id:, connection_id:, page_id:, creator_id:)
873
+ body = {
874
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
875
+ 'pageId' => page_id, 'creatorId' => creator_id
876
+ }
877
+ parse_list(PartnershipCreator, unwrap(http.post('/ads/partnership/creators', body)))
878
+ end
879
+
880
+ def revoke_partnership(creator_id, workspace_id:, connection_id:, page_id:)
881
+ query = meta_query(workspace_id, connection_id).merge('page_id' => page_id)
882
+ http.request(:delete, "/ads/partnership/creators/#{creator_id}", params: query)
883
+ nil
884
+ end
885
+
886
+ # ─── Ad account settings ────────────────────────────────────
887
+
888
+ # Who changed what on the ad account, and when. Dates are "YYYY-MM-DD".
889
+ def account_activity(connection_id:, ad_account_id:, workspace_id: nil, since: nil, until_date: nil)
890
+ query = account_query(workspace_id, connection_id, ad_account_id)
891
+ .merge('since' => since&.to_s, 'until' => until_date&.to_s)
892
+ AdActivityResult.from(unwrap(http.get('/ads/account/activity', query)))
893
+ end
894
+
895
+ def labels(connection_id:, ad_account_id:, workspace_id: nil)
896
+ parse_list(AdLabel, unwrap(http.get(
897
+ '/ads/account/labels', account_query(workspace_id, connection_id, ad_account_id)
898
+ )))
899
+ end
900
+
901
+ def create_label(workspace_id:, connection_id:, ad_account_id:, name:)
902
+ body = {
903
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
904
+ 'adAccountId' => ad_account_id, 'name' => name
905
+ }
906
+ AdLabel.from(unwrap(http.post('/ads/account/labels', body)))
907
+ end
908
+
909
+ def update_label(label_id, workspace_id:, connection_id:, ad_account_id:, name:)
910
+ AdLabel.from(patch_object(
911
+ "/ads/account/labels/#{label_id}", workspace_id, connection_id,
912
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
913
+ 'adAccountId' => ad_account_id, 'name' => name
914
+ ))
915
+ end
916
+
917
+ def delete_label(label_id, workspace_id:, connection_id:, ad_account_id:)
918
+ http.request(
919
+ :delete, "/ads/account/labels/#{label_id}",
920
+ params: account_query(workspace_id, connection_id, ad_account_id)
921
+ )
922
+ nil
923
+ end
924
+
925
+ # Keeps whatever labels the object already carries. `level` is
926
+ # "campaign", "ad_set" or "ad".
927
+ def apply_label(label_id, workspace_id:, connection_id:, ad_account_id:, object_id:, level:)
928
+ http.post("/ads/account/labels/#{label_id}/apply", {
929
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
930
+ 'adAccountId' => ad_account_id, 'objectId' => object_id, 'level' => level
931
+ })
932
+ nil
933
+ end
934
+
935
+ def studies(connection_id:, ad_account_id:, workspace_id: nil)
936
+ parse_list(AdStudy, unwrap(http.get(
937
+ '/ads/account/studies', account_query(workspace_id, connection_id, ad_account_id)
938
+ )))
939
+ end
940
+
941
+ # Splits traffic evenly across two to five `cells` of "name" and "objectIds".
942
+ def create_study(workspace_id:, connection_id:, ad_account_id:, name:, start_at:, end_at:,
943
+ cells:, description: nil)
944
+ body = compact_nil(
945
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
946
+ 'name' => name, 'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
947
+ 'cells' => cells.map { |c| stringify(c) }, 'description' => description
948
+ )
949
+ AdStudy.from(unwrap(http.post('/ads/account/studies', body)))
950
+ end
951
+
952
+ def study(study_id, connection_id:, ad_account_id:, workspace_id: nil)
953
+ AdStudy.from(unwrap(http.get(
954
+ "/ads/account/studies/#{study_id}", account_query(workspace_id, connection_id,
955
+ ad_account_id)
956
+ )))
957
+ end
958
+
959
+ def delete_study(study_id, workspace_id:, connection_id:, ad_account_id:)
960
+ http.request(
961
+ :delete, "/ads/account/studies/#{study_id}",
962
+ params: account_query(workspace_id, connection_id, ad_account_id)
963
+ )
964
+ nil
965
+ end
966
+
967
+ # How many iOS 14 campaigns the account may run at once, per app.
968
+ def ios_campaign_limits(connection_id:, ad_account_id:, workspace_id: nil)
969
+ parse_list(IosCampaignLimits, unwrap(http.get(
970
+ '/ads/account/ios-limits', account_query(workspace_id, connection_id,
971
+ ad_account_id)
972
+ )))
973
+ end
974
+
975
+ def high_demand_periods(connection_id:, ad_account_id:, workspace_id: nil)
976
+ query = account_query(workspace_id, connection_id, ad_account_id)
977
+ parse_list(HighDemandPeriod, unwrap(http.get('/ads/account/high-demand-periods', query)))
978
+ end
979
+
980
+ # Tells the network to expect heavier spend over a window, so pacing allows
981
+ # for it. `budget_value_type` is "ABSOLUTE" or "MULTIPLIER".
982
+ def create_high_demand_period(workspace_id:, connection_id:, ad_account_id:, start_at:, end_at:,
983
+ budget_value:, budget_value_type:)
984
+ body = {
985
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
986
+ 'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
987
+ 'budgetValue' => budget_value, 'budgetValueType' => budget_value_type
988
+ }
989
+ HighDemandPeriod.from(unwrap(http.post('/ads/account/high-demand-periods', body)))
990
+ end
991
+
992
+ def delete_high_demand_period(period_id, workspace_id:, connection_id:, ad_account_id:)
993
+ http.request(
994
+ :delete, "/ads/account/high-demand-periods/#{period_id}",
995
+ params: account_query(workspace_id, connection_id, ad_account_id)
996
+ )
997
+ nil
998
+ end
999
+
1000
+ def value_rule_sets(connection_id:, ad_account_id:, workspace_id: nil)
1001
+ parse_list(ValueRuleSet, unwrap(http.get(
1002
+ '/ads/account/value-rule-sets', account_query(workspace_id, connection_id,
1003
+ ad_account_id)
1004
+ )))
1005
+ end
1006
+
1007
+ # Weights conversions so some audiences count for more than others.
1008
+ def create_value_rule_set(workspace_id:, connection_id:, ad_account_id:, name:, rules:)
1009
+ body = {
1010
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
1011
+ 'name' => name, 'rules' => rules.map { |r| stringify(r) }
1012
+ }
1013
+ ValueRuleSet.from(unwrap(http.post('/ads/account/value-rule-sets', body)))
1014
+ end
1015
+
1016
+ def delete_value_rule_set(rule_set_id, workspace_id:, connection_id:, ad_account_id:)
1017
+ http.request(
1018
+ :delete, "/ads/account/value-rule-sets/#{rule_set_id}",
1019
+ params: account_query(workspace_id, connection_id, ad_account_id)
1020
+ )
1021
+ nil
1022
+ end
1023
+
187
1024
  private
188
1025
 
1026
+ def account_query(workspace_id, connection_id, ad_account_id)
1027
+ meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id)
1028
+ end
1029
+
1030
+ def reach_frequency_action(prediction_id, action, workspace_id, connection_id, ad_account_id)
1031
+ body = {
1032
+ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id
1033
+ }
1034
+ ReachFrequencyPrediction.from(
1035
+ unwrap(http.post("/ads/reach-frequency/#{prediction_id}/#{action}", body))
1036
+ )
1037
+ end
1038
+
1039
+ def meta_query(workspace_id, connection_id)
1040
+ { 'workspace_id' => workspace_id, 'connection_id' => connection_id }
1041
+ end
1042
+
1043
+ def rule_path(rule_id)
1044
+ "/ads/linkedin/conversion-rules/#{rule_id}"
1045
+ end
1046
+
1047
+ def forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
1048
+ body = {
1049
+ 'workspaceId' => workspace_id,
1050
+ 'connectionId' => connection_id,
1051
+ 'adAccountId' => ad_account_id,
1052
+ 'goal' => goal,
1053
+ 'targeting' => stringify(targeting)
1054
+ }
1055
+ body['placements'] = placements.to_a unless placements.nil?
1056
+ body
1057
+ end
1058
+
1059
+ def association(method, rule_id, workspace_id, connection_id, campaign_id)
1060
+ ConversionRule.new(unwrap(
1061
+ http.request(method, "#{rule_path(rule_id)}/associations",
1062
+ json: { 'campaignId' => campaign_id },
1063
+ params: meta_query(workspace_id, connection_id))
1064
+ ))
1065
+ end
1066
+
1067
+ def insights_query(since, until_date, breakdown, daily)
1068
+ query = { 'since' => since.to_s, 'until' => until_date.to_s, 'breakdown' => breakdown }
1069
+ query['daily'] = daily.to_s unless daily.nil?
1070
+ query
1071
+ end
1072
+
1073
+ def patch_object(path, workspace_id, connection_id, fields)
1074
+ unwrap(http.request(:patch, path, json: compact_nil(fields), params: meta_query(workspace_id, connection_id)))
1075
+ end
1076
+
1077
+ def delete_object(path, workspace_id, connection_id)
1078
+ http.request(:delete, path, params: meta_query(workspace_id, connection_id))
1079
+ nil
1080
+ end
1081
+
1082
+ # Answers the copy's Meta id.
1083
+ def duplicate_object(path, workspace_id, connection_id, paused)
1084
+ json = paused.nil? ? {} : { 'paused' => paused }
1085
+ result = unwrap(
1086
+ http.request(:post, "#{path}/duplicate", json: json, params: meta_query(workspace_id, connection_id))
1087
+ )
1088
+ id = result.is_a?(Hash) ? result['id'] : nil
1089
+ id.nil? ? '' : id.to_s
1090
+ end
1091
+
189
1092
  def stringify(hash)
190
1093
  hash.to_h.transform_keys(&:to_s)
191
1094
  end