cio_api/
core.rs

1use std::collections::HashMap;
2use std::env;
3
4use airtable_api::{Airtable, Record, User as AirtableUser};
5use chrono::naive::NaiveDate;
6use chrono::offset::Utc;
7use chrono::DateTime;
8use chrono_humanize::HumanTime;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12pub static BASE_ID_CUSTOMER_LEADS: &str = "appr7imQLcR3pWaNa";
13static MAILING_LIST_SIGNUPS_TABLE: &str = "Mailing List Signups";
14
15/// The data type for a Journal Club Meeting.
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub struct JournalClubMeeting {
18    pub title: String,
19    pub issue: String,
20    pub papers: Vec<Paper>,
21    pub date: NaiveDate,
22    pub coordinator: String,
23    pub state: String,
24    pub recording: String,
25}
26
27impl JournalClubMeeting {
28    pub fn as_slack_msg(&self) -> Value {
29        let mut color = "#ED64A6";
30        if self.state == "closed" {
31            color = "#ED8936";
32        }
33
34        let mut objects: Vec<Value> = Default::default();
35
36        if !self.recording.is_empty() {
37            objects.push(json!({
38                "elements": [{
39                    "text": format!("<{}|Meeting recording>", self.recording),
40                    "type": "mrkdwn"
41                }],
42                "type": "context"
43            }));
44        }
45
46        for p in self.papers.clone() {
47            let mut title = p.title.to_string();
48            if p.title == self.title {
49                title = "Paper".to_string();
50            }
51            objects.push(json!({
52                "elements": [{
53                    "text": format!("<{}|{}>", p.link, title),
54                    "type": "mrkdwn"
55                }],
56                "type": "context"
57            }));
58        }
59
60        json!({
61            "response_type": "in_channel",
62             "attachments": [{
63                    "blocks": [{
64                    "text": {
65                        "text": format!("<{}|*{}*>", self.issue, self.title),
66                        "type": "mrkdwn"
67                    },
68                    "type": "section"
69                },
70                {
71                    "elements": [{
72                        "text": "<https://github.com/oxidecomputer/papers/blob/master/os/countering-ipc-threats-minix3.pdf|Countering IPC Threats in Multiserver Operating Systems>",
73                        "type": "mrkdwn"
74                    }],
75                    "type": "context"
76                },
77                {
78                    "elements": [{
79                        "text": format!("<https://github.com/{}|@{}> | {} | status: *{}*",self.coordinator,self.coordinator,self.date.format("%m/%d/%Y"),self.state),
80                        "type": "mrkdwn"
81                    }],
82                    "type": "context"
83                }],
84                "color": color
85            }]
86        })
87    }
88}
89
90/// The data type for a paper.
91#[derive(Debug, Default, Clone, Deserialize, Serialize)]
92pub struct Paper {
93    pub title: String,
94    pub link: String,
95}
96
97/// The data type for an RFD.
98#[derive(Debug, Default, Clone, Deserialize, Serialize)]
99pub struct RFD {
100    pub number: String,
101    pub title: String,
102    pub link: String,
103    pub state: String,
104    pub discussion: String,
105}
106
107impl RFD {
108    pub fn as_slack_msg(&self, num: i32) -> String {
109        let mut msg = format!("RFD {} {} (_*{}*_) <https://{}.rfd.oxide.computer|github> <https://rfd.shared.oxide.computer/rfd/{}|rendered>", num, self.title, self.state, num, self.number);
110
111        if !self.discussion.is_empty() {
112            msg += &format!(" <{}|discussion>", self.discussion);
113        }
114
115        msg
116    }
117}
118
119/// The Airtable fields type for RFDs.
120#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct RFDFields {
122    #[serde(rename = "Number")]
123    pub number: i32,
124    #[serde(rename = "State")]
125    pub state: String,
126    #[serde(rename = "Title")]
127    pub title: String,
128    // Never modify this, it is based on a function.
129    #[serde(skip_serializing_if = "Option::is_none", rename = "Name")]
130    pub name: Option<String>,
131    // Never modify this, it is based on a function.
132    #[serde(skip_serializing_if = "Option::is_none", rename = "Link")]
133    pub link: Option<String>,
134}
135
136/// The Airtable fields type for Customer Interactions.
137#[derive(Debug, Clone, Deserialize, Serialize)]
138pub struct CustomerInteractionFields {
139    #[serde(rename = "Name")]
140    pub name: String,
141    #[serde(rename = "Company")]
142    pub company: Vec<String>,
143    #[serde(with = "meeting_date_format", rename = "Date")]
144    pub date: NaiveDate,
145    #[serde(rename = "Type")]
146    pub meeting_type: String,
147    #[serde(rename = "Phase")]
148    pub phase: String,
149    #[serde(rename = "People")]
150    pub people: Vec<String>,
151    #[serde(rename = "Oxide Folks")]
152    pub oxide_folks: Vec<AirtableUser>,
153    #[serde(skip_serializing_if = "Option::is_none", rename = "Link to Notes")]
154    pub notes_link: Option<String>,
155    #[serde(skip_serializing_if = "Option::is_none", rename = "Notes")]
156    pub notes: Option<String>,
157}
158
159/// The Airtable fields type for discussion topics.
160#[derive(Debug, Clone, Deserialize, Serialize)]
161pub struct DiscussionFields {
162    #[serde(rename = "Topic")]
163    pub topic: String,
164    #[serde(rename = "Submitter")]
165    pub submitter: AirtableUser,
166    #[serde(rename = "Priority")]
167    pub priority: String,
168    #[serde(skip_serializing_if = "Option::is_none", rename = "Notes")]
169    pub notes: Option<String>,
170    // Never modify this, it is a linked record.
171    #[serde(rename = "Associated meetings")]
172    pub associated_meetings: Vec<String>,
173}
174
175/// The Airtable fields type for a mailing list signup.
176#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct MailingListSignupFields {
178    #[serde(rename = "Email Address")]
179    pub email: String,
180    #[serde(rename = "First Name")]
181    pub first_name: String,
182    #[serde(rename = "Last Name")]
183    pub last_name: String,
184    #[serde(rename = "Company")]
185    pub company: String,
186    #[serde(rename = "What is your interest in Oxide Computer Company?")]
187    pub interest: String,
188    #[serde(rename = "Interested in On the Metal podcast updates?")]
189    pub wants_podcast_updates: bool,
190    #[serde(rename = "Interested in the Oxide newsletter?")]
191    pub wants_newsletter: bool,
192    #[serde(rename = "Interested in product updates?")]
193    pub wants_product_updates: bool,
194    #[serde(rename = "Date Added")]
195    pub date_added: DateTime<Utc>,
196    #[serde(rename = "Opt-in Date")]
197    pub optin_date: DateTime<Utc>,
198    #[serde(rename = "Last Changed")]
199    pub last_changed: DateTime<Utc>,
200}
201
202impl MailingListSignupFields {
203    pub fn new(params: HashMap<String, String>) -> Self {
204        let email = if let Some(e) = params.get("data[email]") {
205            e.trim().to_string()
206        } else {
207            "".to_string()
208        };
209        let first_name = if let Some(f) = params.get("data[merges][FNAME]") {
210            f.trim().to_string()
211        } else {
212            "".to_string()
213        };
214        let last_name = if let Some(l) = params.get("data[merges][LNAME]") {
215            l.trim().to_string()
216        } else {
217            "".to_string()
218        };
219        let company = if let Some(c) = params.get("data[merges][COMPANY]") {
220            c.trim().to_string()
221        } else {
222            "".to_string()
223        };
224        let interest = if let Some(i) = params.get("data[merges][INTEREST]") {
225            i.trim().to_string()
226        } else {
227            "".to_string()
228        };
229
230        let wants_podcast_updates =
231            params.get("data[merges][GROUPINGS][0][groups]").is_some();
232        let wants_newsletter =
233            params.get("data[merges][GROUPINGS][1][groups]").is_some();
234        let wants_product_updates =
235            params.get("data[merges][GROUPINGS][2][groups]").is_some();
236
237        let time: DateTime<Utc> = if let Some(f) = params.get("fired_at") {
238            DateTime::parse_from_str(
239                &(f.to_owned() + " +00:00"),
240                "%Y-%m-%d %H:%M:%S  %:z",
241            )
242            .unwrap()
243            .with_timezone(&Utc)
244        } else {
245            println!(
246                "could not parse mailchimp date time so defaulting to now"
247            );
248
249            Utc::now()
250        };
251
252        MailingListSignupFields {
253            email,
254            first_name,
255            last_name,
256            company,
257            interest,
258            wants_podcast_updates,
259            wants_newsletter,
260            wants_product_updates,
261            date_added: time,
262            optin_date: time,
263            last_changed: time,
264        }
265    }
266
267    pub async fn push_to_airtable(&self) {
268        let api_key = env::var("AIRTABLE_API_KEY").unwrap();
269        // Initialize the Airtable client.
270        let airtable =
271            Airtable::new(api_key.to_string(), BASE_ID_CUSTOMER_LEADS);
272
273        // Create the record.
274        let record = Record {
275            id: None,
276            created_time: None,
277            fields: serde_json::to_value(self).unwrap(),
278        };
279
280        // Send the new record to the airtable client.
281        // Batch can only handle 10 at a time.
282        airtable
283            .create_records(MAILING_LIST_SIGNUPS_TABLE, vec![record])
284            .await
285            .unwrap();
286
287        println!("created mailing list record in airtable: {:?}", self);
288    }
289
290    pub fn as_slack_msg(&self) -> Value {
291        let dur = self.date_added - Utc::now();
292        let time = HumanTime::from(dur);
293
294        let mut msg = format!(
295            "*{} {}* <mailto:{}|{}>",
296            self.first_name, self.last_name, self.email, self.email
297        );
298        if !self.interest.is_empty() {
299            msg += &format!("\n>{}", self.interest.trim());
300        }
301
302        let updates = format!(
303            "podcast updates: _{}_ | newsletter: _{}_ | product updates: _{}_",
304            self.wants_podcast_updates,
305            self.wants_newsletter,
306            self.wants_product_updates
307        );
308
309        let mut context = "".to_string();
310        if !self.company.is_empty() {
311            context += &format!("works at {} | ", self.company);
312        }
313        context += &format!("subscribed to mailing list {}", time);
314
315        json!({
316            "attachments": [
317                {
318                    "color": "#F6E05E",
319                    "blocks": [
320                        {
321                            "type": "section",
322                            "text": {
323                                "type": "mrkdwn",
324                                "text": msg
325                            }
326                        },
327                        {
328                            "type": "context",
329                            "elements": [{
330                                "type": "mrkdwn",
331                                "text": updates
332                            }]
333                        },
334                        {
335                            "type": "context",
336                            "elements": [{
337                                "type": "mrkdwn",
338                                "text": context
339                            }]
340                        }
341                    ]
342                }
343            ]
344        })
345    }
346}
347
348/// The Airtable fields type for meetings.
349#[derive(Debug, Clone, Deserialize, Serialize)]
350pub struct MeetingFields {
351    #[serde(rename = "Name")]
352    pub name: String,
353    #[serde(with = "meeting_date_format", rename = "Date")]
354    pub date: NaiveDate,
355    #[serde(rename = "Week")]
356    pub week: String,
357    #[serde(skip_serializing_if = "Option::is_none", rename = "Notes")]
358    pub notes: Option<String>,
359    #[serde(skip_serializing_if = "Option::is_none", rename = "Action items")]
360    pub action_items: Option<String>,
361    // Never modify this, it is a linked record.
362    #[serde(
363        skip_serializing_if = "Option::is_none",
364        rename = "Proposed discussion"
365    )]
366    pub proposed_discussion: Option<Vec<String>>,
367    #[serde(skip_serializing_if = "Option::is_none", rename = "Recording")]
368    pub recording: Option<String>,
369    #[serde(skip_serializing_if = "Option::is_none", rename = "Attendees")]
370    pub attendees: Option<Vec<AirtableUser>>,
371}
372
373/// Convert the date format `%Y-%m-%d` to a NaiveDate.
374mod meeting_date_format {
375    use chrono::naive::NaiveDate;
376    use serde::{self, Deserialize, Deserializer, Serializer};
377
378    const FORMAT: &str = "%Y-%m-%d";
379
380    // The signature of a serialize_with function must follow the pattern:
381    //
382    //    fn serialize<S>(&T, S) -> Result<S::Ok, S::Error>
383    //    where
384    //        S: Serializer
385    //
386    // although it may also be generic over the input types T.
387    pub fn serialize<S>(
388        date: &NaiveDate,
389        serializer: S,
390    ) -> Result<S::Ok, S::Error>
391    where
392        S: Serializer,
393    {
394        let s = format!("{}", date.format(FORMAT));
395        serializer.serialize_str(&s)
396    }
397
398    // The signature of a deserialize_with function must follow the pattern:
399    //
400    //    fn deserialize<'de, D>(D) -> Result<T, D::Error>
401    //    where
402    //        D: Deserializer<'de>
403    //
404    // although it may also be generic over the output types T.
405    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
406    where
407        D: Deserializer<'de>,
408    {
409        let s = String::deserialize(deserializer)?;
410
411        Ok(NaiveDate::parse_from_str(&s, FORMAT).unwrap())
412    }
413}
414
415/// The data type for sending reminders for the product huddle meetings.
416#[derive(Debug, Default, Clone, Deserialize, Serialize)]
417pub struct ProductEmailData {
418    pub date: String,
419    pub topics: Vec<DiscussionFields>,
420    pub last_meeting_reports_link: String,
421    pub meeting_id: String,
422    pub should_send: bool,
423}