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#[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#[derive(Debug, Default, Clone, Deserialize, Serialize)]
92pub struct Paper {
93 pub title: String,
94 pub link: String,
95}
96
97#[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#[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 #[serde(skip_serializing_if = "Option::is_none", rename = "Name")]
130 pub name: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none", rename = "Link")]
133 pub link: Option<String>,
134}
135
136#[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#[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 #[serde(rename = "Associated meetings")]
172 pub associated_meetings: Vec<String>,
173}
174
175#[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 let airtable =
271 Airtable::new(api_key.to_string(), BASE_ID_CUSTOMER_LEADS);
272
273 let record = Record {
275 id: None,
276 created_time: None,
277 fields: serde_json::to_value(self).unwrap(),
278 };
279
280 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#[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 #[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
373mod 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 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 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#[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}