fopost 0.1.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.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.contacts` — the people behind the inbox, and the fields your
6
+ # workspace keeps about them.
7
+ #
8
+ # A contact is one human however many handles they write from. An inbound
9
+ # inbox item files its author, a reply files whoever you answered, and both
10
+ # fold into whatever is already on file. Every method needs the `inbox`
11
+ # scope, except {#conversation_analytics}, which needs `analytics`.
12
+ class Contacts < Base
13
+ # One page of contacts, most recently active first. The result is
14
+ # Enumerable over its items.
15
+ #
16
+ # Omit `workspace_id` to span every workspace the key can reach; each
17
+ # contact then carries `workspace_id`.
18
+ def list(workspace_id: nil, search: nil, platform: nil, source: nil, page: 1, per_page: 25)
19
+ body = http.get(
20
+ '/contacts',
21
+ {
22
+ 'workspace_id' => workspace_id,
23
+ 'search' => search,
24
+ 'platform' => platform,
25
+ 'source' => source,
26
+ 'page' => page,
27
+ 'per_page' => per_page
28
+ }
29
+ )
30
+ hash = as_hash(body)
31
+ raw_meta = hash['pagination']
32
+ Page.new(
33
+ items: parse_list(Contact, hash['data']),
34
+ meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
35
+ )
36
+ end
37
+
38
+ def get(contact_id)
39
+ Contact.new(unwrap(http.get("/contacts/#{contact_id}")))
40
+ end
41
+
42
+ # Create a contact.
43
+ #
44
+ # Folds into the contact that already holds the first channel, so this
45
+ # cannot duplicate someone the inbox has already met.
46
+ def create(workspace_id:, channels:, display_name: nil, note: nil, fields: nil)
47
+ body = compact_nil(
48
+ 'workspace_id' => workspace_id,
49
+ 'channels' => channels,
50
+ 'display_name' => display_name,
51
+ 'note' => note,
52
+ 'fields' => fields
53
+ )
54
+ Contact.new(unwrap(http.post('/contacts', body)))
55
+ end
56
+
57
+ # Partial update: only what you pass is sent. A custom field set to nil
58
+ # is cleared.
59
+ def update(contact_id, display_name: UNSET, channels: UNSET, note: UNSET, fields: UNSET)
60
+ body = compact_unset(
61
+ 'display_name' => display_name,
62
+ 'channels' => channels,
63
+ 'note' => note,
64
+ 'fields' => fields
65
+ )
66
+ Contact.new(unwrap(http.request(:patch, "/contacts/#{contact_id}", json: body)))
67
+ end
68
+
69
+ # Removes a contact. Their messages stay in the inbox and file them again.
70
+ def delete(contact_id)
71
+ http.delete("/contacts/#{contact_id}")
72
+ nil
73
+ end
74
+
75
+ # The threads this person appears in, newest first.
76
+ def conversations(contact_id, limit: nil)
77
+ parse_list(
78
+ ContactConversation,
79
+ unwrap(http.get("/contacts/#{contact_id}/conversations", { 'limit' => limit }))
80
+ )
81
+ end
82
+
83
+ # Import from CSV text.
84
+ #
85
+ # `platform` and `handle` are required columns. Any other column is read
86
+ # as a custom field key, and one matching no field comes back in
87
+ # `unknown_columns` rather than being stored.
88
+ def import(workspace_id:, csv:)
89
+ ContactImportResult.new(
90
+ unwrap(http.post('/contacts/import', { 'workspace_id' => workspace_id, 'csv' => csv }))
91
+ )
92
+ end
93
+
94
+ # The columns this workspace keeps about its contacts, in display order.
95
+ def list_fields(workspace_id)
96
+ parse_list(ContactField, unwrap(http.get('/contacts/fields', { 'workspace_id' => workspace_id })))
97
+ end
98
+
99
+ def create_field(workspace_id:, key:, name:, type: 'text', options: [])
100
+ ContactField.new(unwrap(http.request(
101
+ :post,
102
+ '/contacts/fields',
103
+ json: { 'key' => key, 'name' => name, 'type' => type, 'options' => options },
104
+ params: { 'workspace_id' => workspace_id }
105
+ )))
106
+ end
107
+
108
+ # The key and the type are fixed once created; the name and options are not.
109
+ def update_field(field_id, name: UNSET, options: UNSET, position: UNSET)
110
+ body = compact_unset('name' => name, 'options' => options, 'position' => position)
111
+ ContactField.new(unwrap(http.request(:patch, "/contacts/fields/#{field_id}", json: body)))
112
+ end
113
+
114
+ # Removes the field and every answer to it.
115
+ def delete_field(field_id)
116
+ http.delete("/contacts/fields/#{field_id}")
117
+ nil
118
+ end
119
+
120
+ # Volume and median reply time per thread. Needs the `analytics` scope.
121
+ def conversation_analytics(workspace_id: nil, account_id: nil, days: nil, sort: nil, page: 1, per_page: 25)
122
+ ConversationAnalytics.new(unwrap(http.get(
123
+ '/analytics/inbox/conversations',
124
+ {
125
+ 'workspace_id' => workspace_id,
126
+ 'accountId' => account_id,
127
+ 'days' => days,
128
+ 'sort' => sort,
129
+ 'page' => page,
130
+ 'per_page' => per_page
131
+ }
132
+ )))
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.ads.google` — the Google Ads surface no other network has.
6
+ #
7
+ # Campaigns, ad groups, ads, audiences and insights are on `client.ads`
8
+ # and dispatch by connection. What is here — keywords, assets, Performance
9
+ # Max asset groups, Local Services leads, conversions and raw GAQL — is
10
+ # Google only, and a connection on another network answers 400.
11
+ #
12
+ # Every method needs the `ads` scope; anything that changes what a live
13
+ # account serves or bids also needs `publish`. `customer_id` is digits only
14
+ # and has to name an account the connection's grant reaches.
15
+ class GoogleAds < Base
16
+ # ── Keywords ──
17
+
18
+ # Keywords on the account, or on one ad group.
19
+ def keywords(connection_id:, customer_id:, workspace_id: nil, ad_group_id: nil)
20
+ parse_list(GoogleKeyword, unwrap(http.get(
21
+ '/ads/google/keywords',
22
+ params(connection_id, customer_id, workspace_id,
23
+ 'ad_group_id' => ad_group_id)
24
+ )))
25
+ end
26
+
27
+ # The new keyword's id. Needs `publish` as well as `ads`.
28
+ def create_keyword(workspace_id:, connection_id:, customer_id:, ad_group_id:, text:, match_type:,
29
+ cpc_bid_minor: nil)
30
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
31
+ 'adGroupId' => ad_group_id,
32
+ 'text' => text,
33
+ 'matchType' => match_type,
34
+ 'cpcBidMinor' => cpc_bid_minor
35
+ ))
36
+ id_of(http.post('/ads/google/keywords', body))
37
+ end
38
+
39
+ # `status` is "active" or "paused". Needs `publish` as well as `ads`.
40
+ def update_keyword(keyword_id, workspace_id:, connection_id:, customer_id:, status: nil, cpc_bid_minor: nil)
41
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
42
+ 'status' => status,
43
+ 'cpcBidMinor' => cpc_bid_minor
44
+ ))
45
+ id_of(http.request(:patch, "/ads/google/keywords/#{keyword_id}", json: body))
46
+ end
47
+
48
+ # Needs `publish` as well as `ads`.
49
+ def delete_keyword(keyword_id, workspace_id:, connection_id:, customer_id:)
50
+ http.request(:delete, "/ads/google/keywords/#{keyword_id}",
51
+ json: scope(workspace_id, connection_id, customer_id))
52
+ nil
53
+ end
54
+
55
+ # Ideas from seed keywords, a landing page, or both.
56
+ def keyword_ideas(workspace_id:, connection_id:, customer_id:, seeds: nil, url: nil, language_id: nil,
57
+ geo_target_ids: nil)
58
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
59
+ 'seeds' => seeds,
60
+ 'url' => url,
61
+ 'languageId' => language_id,
62
+ 'geoTargetIds' => geo_target_ids
63
+ ))
64
+ parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-ideas', body)))
65
+ end
66
+
67
+ def keyword_metrics(workspace_id:, connection_id:, customer_id:, keywords:)
68
+ body = scope(workspace_id, connection_id, customer_id).merge('keywords' => keywords)
69
+ parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-metrics', body)))
70
+ end
71
+
72
+ # What people actually searched, with the metrics each term earned.
73
+ def search_terms(connection_id:, customer_id:, since:, until_date:, workspace_id: nil)
74
+ parse_list(GoogleSearchTerm, unwrap(http.get(
75
+ '/ads/google/search-terms',
76
+ params(connection_id, customer_id, workspace_id,
77
+ 'since' => since, 'until' => until_date)
78
+ )))
79
+ end
80
+
81
+ # ── Bid strategies and ad schedule ──
82
+
83
+ def bid_strategies(connection_id:, customer_id:, workspace_id: nil)
84
+ parse_list(GoogleBidStrategy, unwrap(http.get(
85
+ '/ads/google/bid-strategies',
86
+ params(connection_id, customer_id, workspace_id)
87
+ )))
88
+ end
89
+
90
+ # Needs `publish` as well as `ads`.
91
+ def create_bid_strategy(workspace_id:, connection_id:, customer_id:, name:, type:, target_minor: nil)
92
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
93
+ 'name' => name,
94
+ 'type' => type,
95
+ 'targetMinor' => target_minor
96
+ ))
97
+ id_of(http.post('/ads/google/bid-strategies', body))
98
+ end
99
+
100
+ def ad_schedule(connection_id:, customer_id:, campaign_id:, workspace_id: nil)
101
+ parse_list(GoogleAdScheduleSlot, unwrap(http.get(
102
+ '/ads/google/ad-schedule',
103
+ params(connection_id, customer_id, workspace_id,
104
+ 'campaign_id' => campaign_id)
105
+ )))
106
+ end
107
+
108
+ # Replaces every slot on the campaign: Google has no partial edit for a
109
+ # schedule. Needs `publish` as well as `ads`.
110
+ def set_ad_schedule(workspace_id:, connection_id:, customer_id:, campaign_id:, slots:)
111
+ body = scope(workspace_id, connection_id, customer_id).merge('campaignId' => campaign_id, 'slots' => slots)
112
+ result = unwrap(http.request(:put, '/ads/google/ad-schedule', json: body))
113
+ result.is_a?(Hash) ? result['slots'].to_i : 0
114
+ end
115
+
116
+ # ── Negative keyword lists ──
117
+
118
+ def negative_keyword_lists(connection_id:, customer_id:, workspace_id: nil)
119
+ parse_list(GoogleSharedSet, unwrap(http.get(
120
+ '/ads/google/negative-keywords',
121
+ params(connection_id, customer_id, workspace_id)
122
+ )))
123
+ end
124
+
125
+ # Needs `publish` as well as `ads`.
126
+ def create_negative_keyword_list(workspace_id:, connection_id:, customer_id:, name:)
127
+ body = scope(workspace_id, connection_id, customer_id).merge('name' => name)
128
+ id_of(http.post('/ads/google/negative-keywords', body))
129
+ end
130
+
131
+ # How many were added. Needs `publish` as well as `ads`.
132
+ def add_negative_keywords(workspace_id:, connection_id:, customer_id:, shared_set_id:, keywords:)
133
+ body = scope(workspace_id, connection_id, customer_id).merge(
134
+ 'sharedSetId' => shared_set_id, 'keywords' => keywords
135
+ )
136
+ result = unwrap(http.post('/ads/google/negative-keywords/keywords', body))
137
+ result.is_a?(Hash) ? result['added'].to_i : 0
138
+ end
139
+
140
+ # Needs `publish` as well as `ads`.
141
+ def attach_negative_keyword_list(workspace_id:, connection_id:, customer_id:, shared_set_id:, campaign_id:)
142
+ body = scope(workspace_id, connection_id, customer_id).merge(
143
+ 'sharedSetId' => shared_set_id, 'campaignId' => campaign_id
144
+ )
145
+ http.post('/ads/google/negative-keywords/attach', body)
146
+ nil
147
+ end
148
+
149
+ # ── Assets ──
150
+
151
+ # Returns `{ assets: [...], links: [...] }`; an asset with no links is in
152
+ # the library and serving nowhere.
153
+ def assets(connection_id:, customer_id:, workspace_id: nil)
154
+ result = unwrap(http.get('/ads/google/assets', params(connection_id, customer_id, workspace_id)))
155
+ result = {} unless result.is_a?(Hash)
156
+ {
157
+ assets: parse_list(GoogleAsset, result['assets']),
158
+ links: parse_list(GoogleAssetLink, result['links'])
159
+ }
160
+ end
161
+
162
+ # `spec` is a sitelink, callout or snippet. Needs `publish` as well as `ads`.
163
+ def create_asset(workspace_id:, connection_id:, customer_id:, spec:)
164
+ body = scope(workspace_id, connection_id, customer_id).merge('spec' => spec)
165
+ id_of(http.post('/ads/google/assets', body))
166
+ end
167
+
168
+ # Attaches to the account when `campaign_id` is left out. Needs `publish`.
169
+ def attach_asset(workspace_id:, connection_id:, customer_id:, asset_id:, field_type:, campaign_id: nil)
170
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
171
+ 'assetId' => asset_id,
172
+ 'fieldType' => field_type,
173
+ 'campaignId' => campaign_id
174
+ ))
175
+ http.post('/ads/google/assets/attach', body)
176
+ nil
177
+ end
178
+
179
+ # Removes the links that put it under an ad; on Google the asset itself
180
+ # is permanent. Needs `publish` as well as `ads`.
181
+ def delete_asset(asset_id, workspace_id:, connection_id:, customer_id:)
182
+ http.request(:delete, "/ads/google/assets/#{asset_id}",
183
+ json: scope(workspace_id, connection_id, customer_id))
184
+ nil
185
+ end
186
+
187
+ # ── Performance Max asset groups ──
188
+
189
+ def asset_groups(connection_id:, customer_id:, workspace_id: nil, campaign_id: nil)
190
+ parse_list(GoogleAssetGroup, unwrap(http.get(
191
+ '/ads/google/asset-groups',
192
+ params(connection_id, customer_id, workspace_id,
193
+ 'campaign_id' => campaign_id)
194
+ )))
195
+ end
196
+
197
+ # Starts paused unless `status` says otherwise. Needs `publish`.
198
+ def create_asset_group(workspace_id:, connection_id:, customer_id:, campaign_id:, name:, final_urls:,
199
+ status: nil)
200
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
201
+ 'campaignId' => campaign_id,
202
+ 'name' => name,
203
+ 'finalUrls' => final_urls,
204
+ 'status' => status
205
+ ))
206
+ id_of(http.post('/ads/google/asset-groups', body))
207
+ end
208
+
209
+ # Needs `publish` as well as `ads`.
210
+ def update_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:, name: nil, status: nil)
211
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
212
+ 'name' => name,
213
+ 'status' => status
214
+ ))
215
+ id_of(http.request(:patch, "/ads/google/asset-groups/#{asset_group_id}", json: body))
216
+ end
217
+
218
+ # Needs `publish` as well as `ads`.
219
+ def delete_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:)
220
+ http.request(:delete, "/ads/google/asset-groups/#{asset_group_id}",
221
+ json: scope(workspace_id, connection_id, customer_id))
222
+ nil
223
+ end
224
+
225
+ # ── Local Services leads ──
226
+
227
+ # Read live on every call and never stored by FoPost.
228
+ def local_services_leads(connection_id:, customer_id:, since:, until_date:, workspace_id: nil)
229
+ parse_list(GoogleLocalServicesLead, unwrap(http.get(
230
+ '/ads/google/local-services',
231
+ params(connection_id, customer_id, workspace_id,
232
+ 'since' => since, 'until' => until_date)
233
+ )))
234
+ end
235
+
236
+ # ── Conversions ──
237
+
238
+ def conversion_actions(connection_id:, customer_id:, workspace_id: nil)
239
+ parse_list(GoogleConversionAction, unwrap(http.get(
240
+ '/ads/google/conversions',
241
+ params(connection_id, customer_id, workspace_id)
242
+ )))
243
+ end
244
+
245
+ # Needs `publish` as well as `ads`.
246
+ def create_conversion_action(workspace_id:, connection_id:, customer_id:, name:, category:, value_minor: nil,
247
+ counting_type: nil)
248
+ body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
249
+ 'name' => name,
250
+ 'category' => category,
251
+ 'valueMinor' => value_minor,
252
+ 'countingType' => counting_type
253
+ ))
254
+ id_of(http.post('/ads/google/conversions', body))
255
+ end
256
+
257
+ # Offline conversions, matched to a click. Needs `publish` as well as `ads`.
258
+ def upload_conversions(workspace_id:, connection_id:, customer_id:, conversions:)
259
+ body = scope(workspace_id, connection_id, customer_id).merge('conversions' => conversions)
260
+ uploaded(http.post('/ads/google/conversions/upload', body))
261
+ end
262
+
263
+ # Needs `publish` as well as `ads`.
264
+ def upload_conversion_adjustments(workspace_id:, connection_id:, customer_id:, adjustments:)
265
+ body = scope(workspace_id, connection_id, customer_id).merge('adjustments' => adjustments)
266
+ uploaded(http.post('/ads/google/conversions/adjustments', body))
267
+ end
268
+
269
+ # ── Recommendations ──
270
+
271
+ # Google's own read on what the account should change next. `types`
272
+ # narrows to those recommendation types.
273
+ def recommendations(connection_id:, customer_id:, types: nil, workspace_id: nil)
274
+ extra = types.nil? || types.empty? ? {} : { 'types' => Array(types).join(',') }
275
+ parse_list(GoogleRecommendation, unwrap(http.get(
276
+ '/ads/google/recommendations',
277
+ params(connection_id, customer_id, workspace_id, extra)
278
+ )))
279
+ end
280
+
281
+ # The account's score and weight, and the score of each live campaign.
282
+ def optimization_score(connection_id:, customer_id:, workspace_id: nil)
283
+ GoogleOptimizationScore.new(unwrap(http.get(
284
+ '/ads/google/optimization-score',
285
+ params(connection_id, customer_id, workspace_id)
286
+ )))
287
+ end
288
+
289
+ # Applies each one, which changes what the live account serves or bids.
290
+ # Needs `publish` as well as `ads`.
291
+ def apply_recommendations(workspace_id:, connection_id:, customer_id:, ids:)
292
+ body = scope(workspace_id, connection_id, customer_id).merge('ids' => Array(ids))
293
+ counted(http.post('/ads/google/recommendations/apply', body), 'applied')
294
+ end
295
+
296
+ # Hides each one so Google stops surfacing it. Needs `publish` as well as `ads`.
297
+ def dismiss_recommendations(workspace_id:, connection_id:, customer_id:, ids:)
298
+ body = scope(workspace_id, connection_id, customer_id).merge('ids' => Array(ids))
299
+ counted(http.post('/ads/google/recommendations/dismiss', body), 'dismissed')
300
+ end
301
+
302
+ # ── GAQL ──
303
+
304
+ # A read-only GAQL SELECT; rows come back exactly as Google returns them.
305
+ def query(connection_id:, customer_id:, query:, workspace_id: nil)
306
+ body = compact_nil(
307
+ 'workspaceId' => workspace_id,
308
+ 'connectionId' => connection_id,
309
+ 'customerId' => customer_id,
310
+ 'query' => query
311
+ )
312
+ result = unwrap(http.post('/ads/insights/query', body))
313
+ rows = result.is_a?(Hash) ? result['rows'] : nil
314
+ rows.is_a?(Array) ? rows : []
315
+ end
316
+
317
+ private
318
+
319
+ def scope(workspace_id, connection_id, customer_id)
320
+ { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'customerId' => customer_id }
321
+ end
322
+
323
+ def params(connection_id, customer_id, workspace_id, extra = {})
324
+ compact_nil({
325
+ 'workspace_id' => workspace_id,
326
+ 'connection_id' => connection_id,
327
+ 'customer_id' => customer_id
328
+ }.merge(extra))
329
+ end
330
+
331
+ def id_of(body)
332
+ result = unwrap(body)
333
+ result.is_a?(Hash) && result['id'] ? result['id'].to_s : ''
334
+ end
335
+
336
+ def uploaded(body)
337
+ counted(body, 'uploaded')
338
+ end
339
+
340
+ def counted(body, key)
341
+ result = unwrap(body)
342
+ result.is_a?(Hash) ? result[key].to_i : 0
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fopost/resources/base'
4
+
5
+ module Fopost
6
+ module Resources
7
+ # `client.google_business` — manage a connected Google Business Profile location.
8
+ #
9
+ # Google grants Business Profile API access per project. Until that grant
10
+ # lands on a deployment every call here raises a 503 configuration_error.
11
+ #
12
+ # Responses relay Google's own shape, field for field, so they come back as
13
+ # plain hashes rather than models we would have to keep chasing.
14
+ class GoogleBusiness < Base
15
+ # The set fetched when a caller names no metrics.
16
+ DEFAULT_DAILY_METRICS = %w[
17
+ BUSINESS_IMPRESSIONS_DESKTOP_MAPS
18
+ BUSINESS_IMPRESSIONS_DESKTOP_SEARCH
19
+ BUSINESS_IMPRESSIONS_MOBILE_MAPS
20
+ BUSINESS_IMPRESSIONS_MOBILE_SEARCH
21
+ CALL_CLICKS
22
+ WEBSITE_CLICKS
23
+ BUSINESS_DIRECTION_REQUESTS
24
+ ].freeze
25
+
26
+ def get_location(account_id)
27
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/location")))
28
+ end
29
+
30
+ # Patch the profile; an omitted keyword keeps a field, nil clears it.
31
+ def update_location(account_id, title: UNSET, description: UNSET, website_uri: UNSET,
32
+ primary_phone: UNSET, additional_phones: UNSET, store_code: UNSET,
33
+ regular_hours: UNSET)
34
+ body = compact_unset({
35
+ 'title' => title,
36
+ 'description' => description,
37
+ 'website_uri' => website_uri,
38
+ 'primary_phone' => primary_phone,
39
+ 'additional_phones' => additional_phones,
40
+ 'store_code' => store_code,
41
+ 'regular_hours' => regular_hours
42
+ })
43
+ as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/location", json: body)))
44
+ end
45
+
46
+ # The attribute values set on the location, or what Google offers it.
47
+ def get_attributes(account_id, available: nil, category_name: nil, region_code: nil, language_code: nil)
48
+ params = compact_nil({
49
+ 'available' => available,
50
+ 'category_name' => category_name,
51
+ 'region_code' => region_code,
52
+ 'language_code' => language_code
53
+ })
54
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/attributes", params)))
55
+ end
56
+
57
+ # Only the named attributes change; every other one is left alone.
58
+ def update_attributes(account_id, attributes)
59
+ body = { 'attributes' => attributes }
60
+ as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/attributes", json: body)))
61
+ end
62
+
63
+ def get_menus(account_id)
64
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/menus")))
65
+ end
66
+
67
+ # Google has no per-section patch, so the whole menu set is replaced.
68
+ def replace_menus(account_id, menus)
69
+ as_hash(unwrap(http.put("/accounts/#{account_id}/gbp/menus", { 'menus' => menus })))
70
+ end
71
+
72
+ def get_services(account_id)
73
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/services")))
74
+ end
75
+
76
+ def replace_services(account_id, service_items)
77
+ as_hash(unwrap(http.put("/accounts/#{account_id}/gbp/services", { 'service_items' => service_items })))
78
+ end
79
+
80
+ def list_media(account_id, page_size: nil, page_token: nil)
81
+ params = compact_nil({ 'page_size' => page_size, 'page_token' => page_token })
82
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/media", params)))
83
+ end
84
+
85
+ # Add a photo from the media library; JPEG or PNG, same workspace.
86
+ def add_media(account_id, media_id:, category: 'ADDITIONAL', description: nil)
87
+ body = compact_nil({ 'media_id' => media_id, 'category' => category, 'description' => description })
88
+ as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/media", body)))
89
+ end
90
+
91
+ def delete_media(account_id, media_key)
92
+ as_hash(unwrap(http.delete("/accounts/#{account_id}/gbp/media/#{media_key}")))
93
+ end
94
+
95
+ def list_place_actions(account_id)
96
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/place-actions")))
97
+ end
98
+
99
+ def create_place_action(account_id, uri:, place_action_type:, is_preferred: nil)
100
+ body = compact_nil({ 'uri' => uri, 'place_action_type' => place_action_type,
101
+ 'is_preferred' => is_preferred })
102
+ as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/place-actions", body)))
103
+ end
104
+
105
+ def update_place_action(account_id, link_id, uri: UNSET, is_preferred: UNSET)
106
+ body = compact_unset({ 'uri' => uri, 'is_preferred' => is_preferred })
107
+ as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/place-actions/#{link_id}", json: body)))
108
+ end
109
+
110
+ def delete_place_action(account_id, link_id)
111
+ as_hash(unwrap(http.delete("/accounts/#{account_id}/gbp/place-actions/#{link_id}")))
112
+ end
113
+
114
+ # The ways Google will let this location be verified.
115
+ def get_verification_options(account_id, language_code: nil)
116
+ params = compact_nil({ 'language_code' => language_code })
117
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/verification", params)))
118
+ end
119
+
120
+ # The response names the pending verification to complete with the PIN.
121
+ def start_verification(account_id, method:, language_code: nil, phone_number: nil,
122
+ email_address: nil, mailer_contact_name: nil)
123
+ body = compact_nil({
124
+ 'method' => method,
125
+ 'language_code' => language_code,
126
+ 'phone_number' => phone_number,
127
+ 'email_address' => email_address,
128
+ 'mailer_contact_name' => mailer_contact_name
129
+ })
130
+ as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/verification/start", body)))
131
+ end
132
+
133
+ def complete_verification(account_id, verification_name:, pin:)
134
+ body = { 'verification_name' => verification_name, 'pin' => pin }
135
+ as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/verification/complete", body)))
136
+ end
137
+
138
+ # Daily impressions, calls, direction requests and clicks for the range.
139
+ def get_performance(account_id, start_date:, end_date:, daily_metrics: nil)
140
+ params = compact_nil({
141
+ 'start_date' => start_date,
142
+ 'end_date' => end_date,
143
+ 'daily_metrics' => daily_metrics
144
+ })
145
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/performance", params)))
146
+ end
147
+
148
+ # The search terms people used to find the listing, by month.
149
+ def get_search_keywords(account_id, start_date:, end_date:, page_token: nil)
150
+ params = compact_nil({
151
+ 'keywords' => true,
152
+ 'start_date' => start_date,
153
+ 'end_date' => end_date,
154
+ 'page_token' => page_token
155
+ })
156
+ as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/performance", params)))
157
+ end
158
+
159
+ # Hand the location to another workspace; the caller must own both.
160
+ def assign(account_id, workspace_id:)
161
+ AccountMove.new(unwrap(http.post("/accounts/#{account_id}/gbp/assign",
162
+ { 'workspace_id' => workspace_id })))
163
+ end
164
+ end
165
+ end
166
+ end