Skip to main content

hey_sdk/services/
workflows.rs

1//! Workflows — kanban-style boards of threads — on top of the generated workflow routes.
2//!
3//! A workflow has no JSON surface beyond the page that reads one and the autocomplete
4//! endpoint that enumerates them, so every write is a browser form post.
5
6use std::borrow::Cow;
7
8use ego_tree::iter::Edge;
9use scraper::{ElementRef, Html, Node, Selector};
10
11use crate::error::Error;
12use crate::generated::routes;
13use crate::generated::types::WorkflowStage;
14use crate::http::Method;
15use crate::observability::OperationInfo;
16use crate::services::write_info;
17
18pub use crate::generated::services::workflows::*;
19
20/// One thread on a workflow stage, as the stage page renders its card.
21#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
22#[non_exhaustive]
23pub struct WorkflowStageTopic {
24    /// The staging record that puts the thread on this stage, which is what
25    /// [`Workflows::stage_topic`] moves.
26    pub staging_id: i64,
27    /// The thread the card is for.
28    pub topic_id: i64,
29    /// The card's title; empty when the card renders none.
30    pub subject: String,
31    /// How many emails the card says the thread holds; zero when the card does not say.
32    pub entry_count: u64,
33}
34
35/// A workflow stage as HEY renders it — the stage page is the only place a stage's threads
36/// are listed — with the cards it shows.
37#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
38#[non_exhaustive]
39pub struct WorkflowStageView {
40    /// The stage, as the caller asked for it.
41    pub id: i64,
42    /// The stage's name as the page shows it; empty when the page names none.
43    pub name: String,
44    /// The cards on the stage, in the order the page shows them.
45    pub topics: Vec<WorkflowStageTopic>,
46}
47
48impl WorkflowStageView {
49    /// Reads the stage out of the page HEY serves for it, the way Go's `GetStage` does: the
50    /// element whose id names the stage, the first `h2` at or under it for the name, and
51    /// every element under it whose id is `topic_<id>` for a card — the outermost such
52    /// element, since a card inside a card is that card's content. A card is skipped when
53    /// its thread or staging id will not parse as a positive number, or when its detail
54    /// line does not start with a count. Text meant for screen readers is left out of
55    /// names and subjects. Every rule is Go's, so the two SDKs read one page the same way.
56    pub fn parse(html: &str, stage_id: i64) -> Result<WorkflowStageView, Error> {
57        let document = Html::parse_document(html);
58        let stage = document
59            .select(&selector(&format!(
60                "[id=\"container_workflow_stage_{stage_id}\"]"
61            )))
62            .next()
63            .ok_or_else(|| Error::not_found("workflow stage", stage_id))?;
64        let name = first_at_or_under(stage, |element| element.value().name() == "h2")
65            .map(visible_text)
66            .unwrap_or_default();
67        let topics = stage
68            .select(&selector("[id^=\"topic_\"]"))
69            .filter(|card| !inside_another_card(*card, stage))
70            .filter_map(topic)
71            .collect();
72        Ok(WorkflowStageView {
73            id: stage_id,
74            name,
75            topics,
76        })
77    }
78}
79
80fn topic(card: ElementRef<'_>) -> Option<WorkflowStageTopic> {
81    let topic_id = positive(card.attr("id")?.strip_prefix("topic_")?)?;
82    let staging_id = positive(card.attr("data-identifier")?)?;
83    let subject = first_at_or_under(card, |element| element.value().name() == "h3")
84        .map(visible_text)
85        .unwrap_or_default();
86    let entry_count = match first_at_or_under(card, is_detail_line) {
87        None => 0,
88        Some(detail) => visible_text(detail)
89            .split_whitespace()
90            .next()?
91            .parse::<i64>()
92            .ok()
93            .and_then(|count| u64::try_from(count).ok())?,
94    };
95    Some(WorkflowStageTopic {
96        staging_id,
97        topic_id,
98        subject,
99        entry_count,
100    })
101}
102
103/// A `p` whose class mentions `card__detail`, as Go matches it: a substring, so a
104/// modifier class on the element still counts.
105fn is_detail_line(element: ElementRef<'_>) -> bool {
106    element.value().name() == "p"
107        && element
108            .attr("class")
109            .is_some_and(|class| class.contains("card__detail"))
110}
111
112fn positive(value: &str) -> Option<i64> {
113    value.parse::<i64>().ok().filter(|id| *id > 0)
114}
115
116/// The first element at or under `root`, in document order, that `matches` — the element
117/// itself included, as Go's `findNode` includes it.
118fn first_at_or_under<'a>(
119    root: ElementRef<'a>,
120    matches: impl Fn(ElementRef<'a>) -> bool,
121) -> Option<ElementRef<'a>> {
122    root.descendants()
123        .filter_map(ElementRef::wrap)
124        .find(|element| matches(*element))
125}
126
127/// Whether a card sits inside another card of the same stage: the walk that finds cards
128/// stops at each one, so a card rendered inside a card is that card's content, not a card
129/// of its own. Only the stage's own subtree counts; what surrounds the stage is not a card.
130fn inside_another_card(card: ElementRef<'_>, stage: ElementRef<'_>) -> bool {
131    card.ancestors()
132        .take_while(|ancestor| ancestor.id() != stage.id())
133        .filter_map(ElementRef::wrap)
134        .any(|ancestor| {
135            ancestor
136                .attr("id")
137                .is_some_and(|id| id.starts_with("topic_"))
138        })
139}
140
141/// The text a reader sees under an element, whitespace collapsed: text meant for screen
142/// readers only is left out. Walked without recursion, since the page is the server's and
143/// its nesting is not bounded.
144fn visible_text(element: ElementRef<'_>) -> String {
145    let mut text = String::new();
146    let mut hidden_depth = 0usize;
147    for edge in element.traverse() {
148        match edge {
149            Edge::Open(node) => {
150                if hidden_depth > 0 {
151                    hidden_depth += 1;
152                } else {
153                    match node.value() {
154                        Node::Element(element) if is_visually_hidden(element) => {
155                            hidden_depth = 1;
156                        }
157                        Node::Text(content) => text.push_str(content),
158                        _ => {}
159                    }
160                }
161            }
162            Edge::Close(_) => hidden_depth = hidden_depth.saturating_sub(1),
163        }
164    }
165    text.split_whitespace().collect::<Vec<_>>().join(" ")
166}
167
168/// Split on whitespace as Go's `strings.Fields` splits a class attribute: any Unicode
169/// whitespace, not only the ASCII the HTML spec names.
170fn is_visually_hidden(element: &scraper::node::Element) -> bool {
171    element.attr("class").is_some_and(|classes| {
172        classes.split_whitespace().any(|class| {
173            matches!(
174                class,
175                "sr-only" | "screen-reader-only" | "u-for-screen-reader" | "visually-hidden"
176            )
177        })
178    })
179}
180
181/// A selector written here, which is why parsing it cannot fail.
182fn selector(css: &str) -> Selector {
183    Selector::parse(css).unwrap_or_else(|error| unreachable!("selector {css:?}: {error}"))
184}
185
186/// A workflow as the autocomplete endpoint names it.
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188#[non_exhaustive]
189pub struct WorkflowSummary {
190    /// The workflow's id.
191    pub id: i64,
192    /// What the workflow is called.
193    pub name: String,
194    /// The account the workflow belongs to, empty when the row names none.
195    pub account_name: String,
196}
197
198impl Workflows<'_> {
199    /// The workflows on an account.
200    ///
201    /// The autocomplete endpoint answers bare `[id, name, account name]` rows, and answers
202    /// 304 to a conditional request — the SDK sends none here, so this always comes back
203    /// populated.
204    pub async fn list(&self, account_id: i64) -> Result<Vec<WorkflowSummary>, Error> {
205        let mut operation = self.client().request(
206            Method::GET,
207            format!("/autocompletable/accounts/{account_id}/workflows"),
208        );
209        operation
210            .info(OperationInfo {
211                service: Cow::Borrowed("Workflows"),
212                operation: Cow::Borrowed("ListWorkflows"),
213                resource_type: Cow::Borrowed("workflow"),
214                is_mutation: false,
215                resource_id: Some(account_id),
216            })
217            .without_json_suffix();
218
219        let rows: Vec<Vec<String>> = self.client().send(operation).await?;
220        Ok(rows.iter().filter_map(|row| summary(row)).collect())
221    }
222
223    /// A workflow's stages, in position order.
224    pub async fn stages(&self, workflow_id: i64) -> Result<Vec<WorkflowStage>, Error> {
225        Ok(self.get(workflow_id).await?.stages.unwrap_or_default())
226    }
227
228    /// One stage and the threads on it, read out of the page HEY serves for the stage —
229    /// what [`Workflows::get_stage`] answers as HTML, parsed. HEY lists a stage's threads
230    /// nowhere else.
231    pub async fn stage(&self, workflow_id: i64, stage_id: i64) -> Result<WorkflowStageView, Error> {
232        let page = self.get_stage(workflow_id, stage_id).await?;
233        WorkflowStageView::parse(&page, stage_id)
234    }
235
236    /// Adds a workflow. No account — `None` or a zero id — leaves HEY to pick your first.
237    pub async fn create(&self, name: &str, account_id: Option<i64>) -> Result<(), Error> {
238        let account = account_id
239            .filter(|account_id| *account_id != 0)
240            .map(|account_id| account_id.to_string());
241        let mut fields = vec![("workflow[name]", name)];
242        if let Some(account) = &account {
243            fields.push(("account_id", account.as_str()));
244        }
245
246        let mut operation = self.client().form(Method::POST, "/workflows")?;
247        operation.info(write_info("Workflows", "CreateWorkflow", "workflow", None));
248        operation.form(&fields);
249        self.client().send_unit(operation).await
250    }
251
252    /// Renames a workflow.
253    pub async fn update(&self, workflow_id: i64, name: &str) -> Result<(), Error> {
254        let mut operation = self
255            .client()
256            .form(Method::PATCH, &format!("/workflows/{workflow_id}"))?;
257        operation.info(write_info(
258            "Workflows",
259            "UpdateWorkflow",
260            "workflow",
261            Some(workflow_id),
262        ));
263        operation.form(&[("workflow[name]", name)]);
264        self.client().send_unit(operation).await
265    }
266
267    /// Throws a workflow away.
268    pub async fn delete(&self, workflow_id: i64) -> Result<(), Error> {
269        let mut operation = self
270            .client()
271            .form(Method::DELETE, &format!("/workflows/{workflow_id}"))?;
272        operation.info(write_info(
273            "Workflows",
274            "DeleteWorkflow",
275            "workflow",
276            Some(workflow_id),
277        ));
278        self.client().send_unit(operation).await
279    }
280
281    /// Adds a column to a workflow. HEY names it "Untitled"; rename it with
282    /// [`Workflows::update_stage`].
283    pub async fn create_stage(&self, workflow_id: i64) -> Result<(), Error> {
284        let mut operation = self
285            .client()
286            .form(Method::POST, &format!("/workflows/{workflow_id}/stages"))?;
287        operation.info(write_info(
288            "Workflows",
289            "CreateWorkflowStage",
290            "workflow_stage",
291            Some(workflow_id),
292        ));
293        operation.form(&[]);
294        self.client().send_unit(operation).await
295    }
296
297    /// Renames a workflow column.
298    pub async fn update_stage(
299        &self,
300        workflow_id: i64,
301        stage_id: i64,
302        name: &str,
303    ) -> Result<(), Error> {
304        let mut operation = self.client().form(
305            Method::PATCH,
306            &format!("/workflows/{workflow_id}/stages/{stage_id}"),
307        )?;
308        operation.info(write_info(
309            "Workflows",
310            "UpdateWorkflowStage",
311            "workflow_stage",
312            Some(stage_id),
313        ));
314        operation.form(&[("workflow_stage[name]", name)]);
315        self.client().send_unit(operation).await
316    }
317
318    /// Removes a workflow column.
319    pub async fn delete_stage(&self, workflow_id: i64, stage_id: i64) -> Result<(), Error> {
320        let mut operation = self.client().form(
321            Method::DELETE,
322            &format!("/workflows/{workflow_id}/stages/{stage_id}"),
323        )?;
324        operation.info(write_info(
325            "Workflows",
326            "DeleteWorkflowStage",
327            "workflow_stage",
328            Some(stage_id),
329        ));
330        self.client().send_unit(operation).await
331    }
332
333    /// Adds a topic to a workflow in the stage named.
334    ///
335    /// HEY creates the workflow membership before selecting the stage, so a failure to
336    /// select it leaves the topic in the workflow's first stage. The generated
337    /// [`Workflows::create_staging`] is the first of those two requests on its own.
338    ///
339    /// The stage selection is a [quiet](crate::Operation::quiet) send, so the hooks hear
340    /// `Workflows.CreateWorkflowStaging` once and see both requests under it, as they do in
341    /// Go.
342    pub async fn stage_topic(
343        &self,
344        topic_id: i64,
345        workflow_id: i64,
346        stage_id: i64,
347    ) -> Result<(), Error> {
348        let mut operation = self
349            .client()
350            .operation(&routes::CREATE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
351        operation
352            .info(write_info(
353                "Workflows",
354                "CreateWorkflowStaging",
355                "workflow_staging",
356                Some(topic_id),
357            ))
358            .form_representation();
359        // Two requests, one operation: one limit over both.
360        self.client()
361            .within_limit(Box::pin(async {
362                self.client().send_unit(operation).await?;
363                self.move_to_stage(topic_id, workflow_id, stage_id, None)
364                    .await
365            }))
366            .await
367    }
368
369    /// Moves a staged topic to another stage of its workflow. The generated
370    /// [`Workflows::move_staging`] sends the same request as JSON, which HEY's own apps do
371    /// not; this one sends the form they do.
372    pub async fn move_topic_to_stage(
373        &self,
374        topic_id: i64,
375        workflow_id: i64,
376        stage_id: i64,
377    ) -> Result<(), Error> {
378        let info = write_info(
379            "Workflows",
380            "MoveWorkflowStaging",
381            "workflow_staging",
382            Some(topic_id),
383        );
384        self.move_to_stage(topic_id, workflow_id, stage_id, Some(info))
385            .await
386    }
387
388    /// Takes a topic back off a workflow.
389    pub async fn unstage_topic(&self, topic_id: i64, workflow_id: i64) -> Result<(), Error> {
390        let mut operation = self.client().form(
391            Method::DELETE,
392            &format!("/topics/{topic_id}/workflows/{workflow_id}/stagings"),
393        )?;
394        operation.info(write_info(
395            "Workflows",
396            "DeleteWorkflowStaging",
397            "workflow_staging",
398            Some(topic_id),
399        ));
400        self.client().send_unit(operation).await
401    }
402
403    /// The stage selection [`Workflows::stage_topic`] and [`Workflows::move_topic_to_stage`]
404    /// share. What it announces itself as is the only difference, and no announcement at all
405    /// is the staging case: there it is one request inside an operation already running.
406    async fn move_to_stage(
407        &self,
408        topic_id: i64,
409        workflow_id: i64,
410        stage_id: i64,
411        info: Option<OperationInfo>,
412    ) -> Result<(), Error> {
413        let stage = stage_id.to_string();
414        let mut operation = self
415            .client()
416            .operation(&routes::MOVE_WORKFLOW_STAGING, &[&topic_id, &workflow_id]);
417        operation
418            .form_representation()
419            .form(&[("workflow_staging[workflow_stage_id]", stage.as_str())]);
420        match info {
421            Some(info) => operation.info(info),
422            None => operation.quiet(),
423        };
424        self.client().send_unit(operation).await
425    }
426}
427
428/// The workflow a row names. A row too short to carry a name, or whose first column is no
429/// id, is one the autocomplete list has nothing to say about.
430fn summary(row: &[String]) -> Option<WorkflowSummary> {
431    match row {
432        [id, name, rest @ ..] => Some(WorkflowSummary {
433            id: id.parse().ok()?,
434            name: name.clone(),
435            account_name: rest.first().cloned().unwrap_or_default(),
436        }),
437        _ => None,
438    }
439}