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.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Fopost
6
+ module Resources
7
+ # `client.analytics` — deeper posting analytics.
8
+ #
9
+ # Derived from the repeated readings the platform collector takes of every
10
+ # post as it ages. Every call needs the `analytics` scope.
11
+ class Analytics < Base
12
+ # How engagement accumulates with a post's age, and where the half-life
13
+ # falls. `days` selects posts by publish time, not reading time.
14
+ def decay(days: nil, workspace_id: nil, account_id: nil)
15
+ query = compact_nil('days' => days, 'workspace_id' => workspace_id, 'accountId' => account_id)
16
+ ContentDecay.new(unwrap(http.get('/analytics/decay', query)))
17
+ end
18
+
19
+ # Weekly posting cadence set against what each cadence earned per post.
20
+ def frequency(days: nil, workspace_id: nil, account_id: nil)
21
+ query = compact_nil('days' => days, 'workspace_id' => workspace_id, 'accountId' => account_id)
22
+ PostingFrequency.new(unwrap(http.get('/analytics/frequency', query)))
23
+ end
24
+
25
+ # Every reading held for one post, oldest first, one timeline per
26
+ # delivery. `id_or_permalink` is a FoPost post id or the permalink of a
27
+ # post made natively on the network.
28
+ def timeline(id_or_permalink)
29
+ path = "/analytics/posts/#{CGI.escape(id_or_permalink.to_s)}/timeline"
30
+ PostTimeline.new(unwrap(http.get(path)))
31
+ end
32
+
33
+ # Readings recorded after `since`, oldest first, with a cursor to
34
+ # continue. Poll this to mirror the metrics into your own store; omitting
35
+ # `since` gives the last seven days.
36
+ def changes(since: nil, limit: nil, workspace_id: nil, account_id: nil)
37
+ query = compact_nil(
38
+ 'since' => iso8601(since),
39
+ 'limit' => limit,
40
+ 'workspace_id' => workspace_id,
41
+ 'accountId' => account_id
42
+ )
43
+ MetricChangePage.new(unwrap(http.get('/analytics/changes', query)))
44
+ end
45
+
46
+ # Re-read one post from the network now. Spends the same per-user budget
47
+ # as a full collection run, so a burst answers 429 with `retryAfter`.
48
+ def collect_post(id_or_permalink)
49
+ path = "/posts/#{CGI.escape(id_or_permalink.to_s)}/analytics/collect"
50
+ CollectPostResult.new(unwrap(http.post(path)))
51
+ end
52
+
53
+ # Posts on the account that never went out through FoPost, newest first.
54
+ def native_posts(account_id, page: 1, per_page: 20, days: nil)
55
+ query = compact_nil('page' => page, 'per_page' => per_page, 'days' => days)
56
+ body = http.get("/accounts/#{account_id}/native-posts", query)
57
+ items = parse_list(NativePost, body.is_a?(Hash) ? body['data'] : body)
58
+ raw_meta = body.is_a?(Hash) ? body['meta'] : nil
59
+ Page.new(items: items, meta: InboxPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {}))
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.blog` — articles written for a website, as opposed to posts
6
+ # that go to a social network.
7
+ #
8
+ # Reads, settings and keyword edits need the `posts` scope. Anything that
9
+ # spends credits ({#suggest_seeds}, {#plan_keywords}, {#request_article},
10
+ # {#regenerate_article}) needs `ai` and answers 402 on an empty balance;
11
+ # {#publish_article} needs `publish` and answers 409 `no_target` when the
12
+ # settings name nowhere to publish.
13
+ class Blog < Base
14
+ # ── Settings ──────────────────────────────────────────────────
15
+
16
+ def get_settings(workspace_id)
17
+ BlogSettings.new(unwrap(http.get('/blog/settings', { 'workspace_id' => workspace_id })))
18
+ end
19
+
20
+ # Partial update: only what you pass is sent.
21
+ #
22
+ # The answer carries `website_secret` only on the update that first sets
23
+ # `website_url`; keep it, it is never shown again. Pass `nil` to clear a
24
+ # nullable field.
25
+ def update_settings(workspace_id, site_url: UNSET, topic: UNSET, audience: UNSET, goal: UNSET,
26
+ focus_topics: UNSET, avoid_topics: UNSET, language: UNSET, location_code: UNSET,
27
+ cadence_per_week: UNSET, review_hold_days: UNSET, image_tier: UNSET,
28
+ in_body_images: UNSET, min_words: UNSET, max_words: UNSET, brand_voice_id: UNSET,
29
+ target_kind: UNSET, target_account_id: UNSET, website_url: UNSET)
30
+ body = compact_unset(
31
+ 'siteUrl' => site_url,
32
+ 'topic' => topic,
33
+ 'audience' => audience,
34
+ 'goal' => goal,
35
+ 'focusTopics' => focus_topics,
36
+ 'avoidTopics' => avoid_topics,
37
+ 'language' => language,
38
+ 'locationCode' => location_code,
39
+ 'cadencePerWeek' => cadence_per_week,
40
+ 'reviewHoldDays' => review_hold_days,
41
+ 'imageTier' => image_tier,
42
+ 'inBodyImages' => in_body_images,
43
+ 'minWords' => min_words,
44
+ 'maxWords' => max_words,
45
+ 'brandVoiceId' => brand_voice_id,
46
+ 'targetKind' => target_kind,
47
+ 'targetAccountId' => target_account_id,
48
+ 'websiteUrl' => website_url
49
+ )
50
+ response = as_hash(unwrap(http.patch('/blog/settings', { 'workspaceId' => workspace_id }.merge(body))))
51
+ BlogSettingsUpdate.new('settings' => response, 'website_secret' => response['website_secret'])
52
+ end
53
+
54
+ # Issue a new website secret; the old one stops verifying at once.
55
+ # Returns the secret, shown this once.
56
+ def rotate_website_secret(workspace_id)
57
+ result = as_hash(unwrap(http.post('/blog/settings/website-secret', { 'workspaceId' => workspace_id })))
58
+ result['website_secret'].to_s
59
+ end
60
+
61
+ # Send a signed `article.test` delivery to `website_url`. A site that
62
+ # does not answer 2xx comes back as a 502 `Fopost::Error` whose body
63
+ # carries the site's `http_status`.
64
+ def test_website(workspace_id)
65
+ BlogWebsiteTest.new(unwrap(http.post('/blog/settings/website-test', { 'workspaceId' => workspace_id })))
66
+ end
67
+
68
+ # ── Keywords ──────────────────────────────────────────────────
69
+
70
+ # `status` takes one status or an array of them.
71
+ def list_keywords(workspace_id, status: nil, limit: nil)
72
+ params = { 'workspace_id' => workspace_id, 'status' => csv(status), 'limit' => limit }
73
+ parse_list(BlogKeyword, unwrap(http.get('/blog/keywords', params)))
74
+ end
75
+
76
+ # About 10 search terms from the blog goal and topics, to edit before
77
+ # {#plan_keywords}. Pass the current `seeds` to refine them and `prompt`
78
+ # (up to 500 characters) to steer. Needs `ai`.
79
+ def suggest_seeds(workspace_id, seeds: nil, prompt: nil)
80
+ body = compact_nil('workspaceId' => workspace_id, 'seeds' => seeds, 'prompt' => prompt)
81
+ as_hash(unwrap(http.post('/blog/keywords/suggest', body)))['seeds'] || []
82
+ end
83
+
84
+ # Queue a research run; it answers before the run finishes. Results land
85
+ # as `suggested` keywords and never fill the calendar; set one to
86
+ # `planned` with {#update_keyword} to pick it. Follow the run in
87
+ # `plan_run` on {#get_settings}. Needs `ai`.
88
+ def plan_keywords(workspace_id, seeds: nil)
89
+ body = compact_nil('workspaceId' => workspace_id, 'seeds' => seeds)
90
+ as_hash(unwrap(http.post('/blog/keywords/plan', body)))
91
+ end
92
+
93
+ # `planned_for` is a date, YYYY-MM-DD.
94
+ def add_keyword(workspace_id, keyword:, planned_for: nil)
95
+ body = compact_nil('workspaceId' => workspace_id, 'keyword' => keyword, 'plannedFor' => date(planned_for))
96
+ BlogKeyword.new(unwrap(http.post('/blog/keywords', body)))
97
+ end
98
+
99
+ # Partial update. `status: 'planned'` picks the keyword and gives it the
100
+ # next free date; `planned_for: nil` takes it off the calendar.
101
+ def update_keyword(workspace_id, keyword_id, status: UNSET, planned_for: UNSET)
102
+ body = compact_unset('status' => status, 'plannedFor' => UNSET.equal?(planned_for) ? UNSET : date(planned_for))
103
+ BlogKeyword.new(unwrap(patch("/blog/keywords/#{keyword_id}", workspace_id, body)))
104
+ end
105
+
106
+ def delete_keyword(workspace_id, keyword_id)
107
+ http.request(:delete, "/blog/keywords/#{keyword_id}", params: workspace(workspace_id))
108
+ nil
109
+ end
110
+
111
+ # ── Articles ──────────────────────────────────────────────────
112
+
113
+ # `status` takes one status or an array of them.
114
+ def list_articles(workspace_id, status: nil, limit: nil)
115
+ params = { 'workspace_id' => workspace_id, 'status' => csv(status), 'limit' => limit }
116
+ parse_list(BlogArticle, unwrap(http.get('/blog/articles', params)))
117
+ end
118
+
119
+ # Queue an article. Give one of `title`, `keyword` or `keyword_id` (a
120
+ # keyword from the plan). The article comes back `queued`; poll
121
+ # {#get_article} or wait for the `article.published` webhook. Needs `ai`.
122
+ def request_article(workspace_id, title: nil, keyword: nil, keyword_id: nil, instructions: nil)
123
+ body = compact_nil(
124
+ 'workspaceId' => workspace_id,
125
+ 'title' => title,
126
+ 'keyword' => keyword,
127
+ 'keywordId' => keyword_id,
128
+ 'instructions' => instructions
129
+ )
130
+ BlogArticle.new(unwrap(http.post('/blog/articles', body)))
131
+ end
132
+
133
+ def get_article(workspace_id, article_id)
134
+ BlogArticleFull.new(unwrap(http.get("/blog/articles/#{article_id}", workspace(workspace_id))))
135
+ end
136
+
137
+ # Partial update. An article still being written or already publishing
138
+ # answers 409. `publish_at` puts an article in review on the calendar (a
139
+ # draft answers 409 until submitted with {#submit_article});
140
+ # `publish_at: nil` returns it to draft.
141
+ def update_article(workspace_id, article_id, title: UNSET, slug: UNSET, excerpt: UNSET, meta_title: UNSET,
142
+ meta_description: UNSET, body_markdown: UNSET, tags: UNSET, publish_at: UNSET)
143
+ body = compact_unset(
144
+ 'title' => title,
145
+ 'slug' => slug,
146
+ 'excerpt' => excerpt,
147
+ 'metaTitle' => meta_title,
148
+ 'metaDescription' => meta_description,
149
+ 'bodyMarkdown' => body_markdown,
150
+ 'tags' => tags,
151
+ 'publishAt' => UNSET.equal?(publish_at) ? UNSET : iso8601(publish_at)
152
+ )
153
+ BlogArticleFull.new(unwrap(patch("/blog/articles/#{article_id}", workspace_id, body)))
154
+ end
155
+
156
+ # Write it again from the same brief. Needs `ai`.
157
+ def regenerate_article(workspace_id, article_id)
158
+ BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/regenerate",
159
+ params: workspace(workspace_id))))
160
+ end
161
+
162
+ # Publish an article in review to the configured target. Needs
163
+ # `publish`; a draft answers 409 until submitted with {#submit_article},
164
+ # and 409 `no_target` means the settings name nowhere to publish.
165
+ def publish_article(workspace_id, article_id)
166
+ BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/publish",
167
+ params: workspace(workspace_id))))
168
+ end
169
+
170
+ # Move a draft, failed or rejected article to `in_review`; any other
171
+ # state answers 409.
172
+ def submit_article(workspace_id, article_id)
173
+ BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/submit",
174
+ params: workspace(workspace_id))))
175
+ end
176
+
177
+ def reject_article(workspace_id, article_id)
178
+ BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/reject",
179
+ params: workspace(workspace_id))))
180
+ end
181
+
182
+ def delete_article(workspace_id, article_id)
183
+ http.request(:delete, "/blog/articles/#{article_id}", params: workspace(workspace_id))
184
+ nil
185
+ end
186
+
187
+ private
188
+
189
+ def workspace(workspace_id)
190
+ { 'workspace_id' => workspace_id }
191
+ end
192
+
193
+ def patch(path, workspace_id, body)
194
+ http.request(:patch, path, json: body, params: workspace(workspace_id))
195
+ end
196
+
197
+ def csv(value)
198
+ value.is_a?(Array) ? value.join(',') : value
199
+ end
200
+
201
+ def date(value)
202
+ value.respond_to?(:strftime) ? value.strftime('%Y-%m-%d') : value
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.blogs` — content a connected site already owns.
6
+ #
7
+ # Most of this SDK creates content. These calls reach what is already
8
+ # there: the articles on a WordPress site or a Shopify store's blog, and a
9
+ # Shopify store's products. Every id here is the platform's own, never a
10
+ # FoPost id.
11
+ #
12
+ # Reads need the `posts` scope. Anything that changes the site needs
13
+ # `posts` and `publish`, because a change here is visible to the site's own
14
+ # readers.
15
+ class Blogs < Base
16
+ # Blogs the account can write to. Shopify reports every blog on the
17
+ # store; WordPress reports its one implicit blog, under the id `default`.
18
+ def list_blogs(account_id)
19
+ parse_list(RemoteBlog, unwrap(http.get("/accounts/#{account_id}/blogs")))
20
+ end
21
+
22
+ # Articles on the blog, newest first, drafts included.
23
+ #
24
+ # `status` is published, draft, pending or scheduled; `q` matches the
25
+ # title; `limit` is 1 to 50.
26
+ def list_articles(account_id, blog_id, limit: nil, status: nil, q: nil)
27
+ params = compact_nil('limit' => limit, 'status' => status, 'q' => q)
28
+ parse_list(
29
+ RemoteArticle,
30
+ unwrap(http.get("/accounts/#{account_id}/blogs/#{blog_id}/articles", params))
31
+ )
32
+ end
33
+
34
+ def get_article(account_id, blog_id, article_id)
35
+ RemoteArticle.new(
36
+ unwrap(http.get("/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}"))
37
+ )
38
+ end
39
+
40
+ # Write a new article to the blog. Needs the `publish` scope.
41
+ #
42
+ # `body` is FoPost body markup; the site's own format is rendered from it.
43
+ def create_article(account_id, blog_id, title:, body:, excerpt: nil, status: nil,
44
+ tags: nil, author_name: nil, image_url: nil)
45
+ payload = compact_nil(
46
+ 'title' => title,
47
+ 'body' => body,
48
+ 'excerpt' => excerpt,
49
+ 'status' => status,
50
+ 'tags' => tags&.to_a,
51
+ 'author_name' => author_name,
52
+ 'image_url' => image_url
53
+ )
54
+ RemoteArticle.new(
55
+ unwrap(http.post("/accounts/#{account_id}/blogs/#{blog_id}/articles", payload))
56
+ )
57
+ end
58
+
59
+ # Change the live article in place. Needs the `publish` scope.
60
+ #
61
+ # Only the keywords you pass are touched, and the article is addressed by
62
+ # its own id, so an edit never creates a second post on the site. Pass at
63
+ # least one field.
64
+ def update_article(account_id, blog_id, article_id, title: UNSET, body: UNSET,
65
+ excerpt: UNSET, status: UNSET, tags: UNSET, author_name: UNSET,
66
+ image_url: UNSET)
67
+ payload = compact_unset(
68
+ 'title' => title,
69
+ 'body' => body,
70
+ 'excerpt' => excerpt,
71
+ 'status' => status,
72
+ 'tags' => UNSET.equal?(tags) ? UNSET : tags&.to_a,
73
+ 'author_name' => author_name,
74
+ 'image_url' => image_url
75
+ )
76
+ RemoteArticle.new(
77
+ unwrap(http.request(
78
+ :patch,
79
+ "/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}",
80
+ json: payload
81
+ ))
82
+ )
83
+ end
84
+
85
+ # Remove the article from the site. Needs `publish`; cannot be undone.
86
+ def delete_article(account_id, blog_id, article_id)
87
+ http.delete("/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}")
88
+ nil
89
+ end
90
+
91
+ # The store's products. `status` is active, draft or archived.
92
+ def list_products(account_id, limit: nil, status: nil, q: nil)
93
+ params = compact_nil('limit' => limit, 'status' => status, 'q' => q)
94
+ parse_list(RemoteProduct, unwrap(http.get("/accounts/#{account_id}/products", params)))
95
+ end
96
+
97
+ # Change a product on the store. Needs `publish`; only what you pass changes.
98
+ def update_product(account_id, product_id, title: UNSET, description: UNSET,
99
+ status: UNSET, tags: UNSET, product_type: UNSET, vendor: UNSET)
100
+ payload = compact_unset(
101
+ 'title' => title,
102
+ 'description' => description,
103
+ 'status' => status,
104
+ 'tags' => UNSET.equal?(tags) ? UNSET : tags&.to_a,
105
+ 'product_type' => product_type,
106
+ 'vendor' => vendor
107
+ )
108
+ RemoteProduct.new(
109
+ unwrap(http.request(:patch, "/accounts/#{account_id}/products/#{product_id}",
110
+ json: payload))
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ module Resources
5
+ # `client.broadcasts` — one message into every conversation the workspace
6
+ # already has with a segment of its contacts.
7
+ #
8
+ # Nothing is sent into a closed messaging window. Messenger and Instagram
9
+ # take a business-initiated message only within 24 hours of the contact's
10
+ # last one, so recipients outside it come back skipped with
11
+ # `window_closed` rather than attempted — which is why the number sent is
12
+ # often lower than the audience. Telegram, Slack, Bluesky and Reddit have
13
+ # no window.
14
+ #
15
+ # Reading needs the `inbox` scope; {#send} and {#cancel} also need
16
+ # `publish`.
17
+ class Broadcasts < Base
18
+ # One page of broadcasts, newest first. The result is Enumerable over
19
+ # its items.
20
+ #
21
+ # Omit `workspace_id` to span every workspace the key can reach; each
22
+ # broadcast then carries `workspace_id`.
23
+ def list(workspace_id: nil, status: nil, page: 1, per_page: 25)
24
+ body = http.get(
25
+ '/broadcasts',
26
+ { 'workspace_id' => workspace_id, 'status' => status, 'page' => page, 'per_page' => per_page }
27
+ )
28
+ paged(Broadcast, body)
29
+ end
30
+
31
+ def get(broadcast_id)
32
+ Broadcast.new(unwrap(http.get("/broadcasts/#{broadcast_id}")))
33
+ end
34
+
35
+ # Create it without sending.
36
+ #
37
+ # Give `scheduled_at` to have it go out on its own at that time;
38
+ # otherwise call {#send}. An omitted `audience` means every contact in
39
+ # the workspace.
40
+ def create(workspace_id:, account_id:, name:, text:, media_id: nil, audience: nil, scheduled_at: nil)
41
+ body = compact_nil(
42
+ 'workspace_id' => workspace_id,
43
+ 'account_id' => account_id,
44
+ 'name' => name,
45
+ 'text' => text,
46
+ 'media_id' => media_id,
47
+ 'audience' => audience,
48
+ 'scheduled_at' => scheduled_at
49
+ )
50
+ Broadcast.new(unwrap(http.post('/broadcasts', body)))
51
+ end
52
+
53
+ # Partial update: only what you pass is sent. Only a draft or scheduled
54
+ # broadcast can be edited.
55
+ def update(broadcast_id, name: UNSET, text: UNSET, media_id: UNSET, audience: UNSET, scheduled_at: UNSET)
56
+ body = compact_unset(
57
+ 'name' => name,
58
+ 'text' => text,
59
+ 'media_id' => media_id,
60
+ 'audience' => audience,
61
+ 'scheduled_at' => scheduled_at
62
+ )
63
+ Broadcast.new(unwrap(http.request(:patch, "/broadcasts/#{broadcast_id}", json: body)))
64
+ end
65
+
66
+ # Freeze the audience into a recipient list and start sending.
67
+ #
68
+ # The returned `recipients` count is how many contacts matched, not how
69
+ # many will be messaged — the messaging window decides that. Needs the
70
+ # `publish` scope as well as `inbox`.
71
+ def send(broadcast_id)
72
+ as_hash(unwrap(http.post("/broadcasts/#{broadcast_id}/send", {})))
73
+ end
74
+
75
+ # Stop it where it stands. Anyone not yet written to stays unsent;
76
+ # messages already delivered are not recalled. Needs `publish`.
77
+ def cancel(broadcast_id)
78
+ as_hash(unwrap(http.post("/broadcasts/#{broadcast_id}/cancel", {})))
79
+ end
80
+
81
+ # One row per contact, with what became of their message. A skipped row
82
+ # carries `skip_reason`.
83
+ def recipients(broadcast_id, status: nil, page: 1, per_page: 50)
84
+ body = http.get(
85
+ "/broadcasts/#{broadcast_id}/recipients",
86
+ { 'status' => status, 'page' => page, 'per_page' => per_page }
87
+ )
88
+ paged(BroadcastRecipient, body)
89
+ end
90
+
91
+ # Removes the broadcast and its recipient records. Messages already sent
92
+ # stay in the conversations they went to.
93
+ def delete(broadcast_id)
94
+ http.delete("/broadcasts/#{broadcast_id}")
95
+ nil
96
+ end
97
+
98
+ private
99
+
100
+ # Broadcast lists answer `{data, pagination}`, not `{data, meta}`.
101
+ def paged(model, body)
102
+ hash = as_hash(body)
103
+ raw_meta = hash['pagination']
104
+ Page.new(
105
+ items: parse_list(model, hash['data']),
106
+ meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
107
+ )
108
+ end
109
+ end
110
+
111
+ # `client.sequences` — a series of messages, each a delay after the one
112
+ # before, walked per enrolled contact.
113
+ #
114
+ # The messaging window applies to every step. A step that comes due
115
+ # outside it is skipped rather than sent, and the enrollment carries on —
116
+ # so someone can complete a sequence having received only some of its
117
+ # messages.
118
+ class Sequences < Base
119
+ def list(workspace_id: nil, page: 1, per_page: 25)
120
+ body = http.get('/sequences', { 'workspace_id' => workspace_id, 'page' => page, 'per_page' => per_page })
121
+ paged(Sequence, body)
122
+ end
123
+
124
+ def get(sequence_id)
125
+ Sequence.new(unwrap(http.get("/sequences/#{sequence_id}")))
126
+ end
127
+
128
+ # Creating a sequence enrolls nobody.
129
+ #
130
+ # Each step is `{ 'delay_hours' => n, 'text' => '...' }`, with
131
+ # `delay_hours` counted from the previous step.
132
+ def create(workspace_id:, account_id:, name:, steps:, status: nil)
133
+ body = compact_nil(
134
+ 'workspace_id' => workspace_id,
135
+ 'account_id' => account_id,
136
+ 'name' => name,
137
+ 'steps' => steps,
138
+ 'status' => status
139
+ )
140
+ Sequence.new(unwrap(http.post('/sequences', body)))
141
+ end
142
+
143
+ # Partial update. Pausing stops every enrollment from firing without
144
+ # ending any of them; resuming picks them up where they stood.
145
+ def update(sequence_id, name: UNSET, steps: UNSET, status: UNSET)
146
+ body = compact_unset('name' => name, 'steps' => steps, 'status' => status)
147
+ Sequence.new(unwrap(http.request(:patch, "/sequences/#{sequence_id}", json: body)))
148
+ end
149
+
150
+ # Put contacts on the sequence, by id or by the same audience filter a
151
+ # broadcast takes.
152
+ #
153
+ # Re-enrolling someone restarts their walk from the first step rather
154
+ # than running two in parallel. Needs `publish` as well as `inbox`.
155
+ def enroll(sequence_id, contact_ids: nil, audience: nil)
156
+ body = compact_nil('contact_ids' => contact_ids, 'audience' => audience)
157
+ as_hash(unwrap(http.post("/sequences/#{sequence_id}/enroll", body)))
158
+ end
159
+
160
+ # Nothing further fires for them. Needs `publish`.
161
+ def unenroll(sequence_id, contact_ids)
162
+ as_hash(unwrap(http.post("/sequences/#{sequence_id}/unenroll", { 'contact_ids' => contact_ids })))
163
+ end
164
+
165
+ # Who is on it, what step they are at, and when the next one is due.
166
+ def enrollments(sequence_id, page: 1, per_page: 50)
167
+ body = http.get("/sequences/#{sequence_id}/enrollments", { 'page' => page, 'per_page' => per_page })
168
+ paged(Enrollment, body)
169
+ end
170
+
171
+ # Removes the sequence and every enrollment on it.
172
+ def delete(sequence_id)
173
+ http.delete("/sequences/#{sequence_id}")
174
+ nil
175
+ end
176
+
177
+ private
178
+
179
+ def paged(model, body)
180
+ hash = as_hash(body)
181
+ raw_meta = hash['pagination']
182
+ Page.new(
183
+ items: parse_list(model, hash['data']),
184
+ meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end