| 1 | //! Getting started: each person's own state of a workspace's setup guide, |
| 2 | //! the dialog that opens on its Home for its owners until they finish or |
| 3 | //! dismiss it. |
| 4 | //! |
| 5 | //! A row per person and workspace (migration 0050), keyed by the |
| 6 | //! workspace's id so a rename keeps it: when they dismissed it, and the |
| 7 | //! steps they skipped. A person with no row has never dismissed it. Whether |
| 8 | //! a step is done is the workspace's own state (its AI providers, |
| 9 | //! repositories, members, runners), which the web app reads; nothing here |
| 10 | //! says a step is done. Only the person reads or changes their own, and |
| 11 | //! only in a workspace they belong to. |
| 12 | |
| 13 | use g1t_contracts::identity::*; |
| 14 | use g1t_contracts::time::SQL_NOW; |
| 15 | use g1t_contracts::{FailureCode, Outcome}; |
| 16 | use serde::Deserialize; |
| 17 | use worker::Result; |
| 18 | use worker::wasm_bindgen::JsValue; |
| 19 | |
| 20 | use crate::Identity; |
| 21 | use crate::security::is_person; |
| 22 | |
| 23 | /// `skipped` as it is kept: each a step the guide has, in the guide's |
| 24 | /// order, without repeats. Refused whole when one is not a step. |
| 25 | pub fn check_skipped(steps: &[String]) -> std::result::Result<Vec<String>, String> { |
| 26 | for step in steps { |
| 27 | if !ONBOARDING_STEPS.contains(&step.trim()) { |
| 28 | return Err(format!("{} is not a step of Getting started. The steps are {}.", step.trim(), ONBOARDING_STEPS.join(", "))); |
| 29 | } |
| 30 | } |
| 31 | Ok(ONBOARDING_STEPS |
| 32 | .iter() |
| 33 | .filter(|step| steps.iter().any(|given| given.trim() == **step)) |
| 34 | .map(|step| (*step).to_owned()) |
| 35 | .collect()) |
| 36 | } |
| 37 | |
| 38 | /// What a change does to `dismissed_at`, as the statement in |
| 39 | /// `set_onboarding` reads it: 0 leaves it as it is, 1 dismisses the guide |
| 40 | /// now (a second dismissal keeps the first time), 2 brings it back to |
| 41 | /// opening by itself. |
| 42 | pub fn dismiss_mode(dismissed: Option<bool>) -> i32 { |
| 43 | match dismissed { |
| 44 | None => 0, |
| 45 | Some(true) => 1, |
| 46 | Some(false) => 2, |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | #[derive(Deserialize)] |
| 51 | struct Row { |
| 52 | dismissed_at: Option<String>, |
| 53 | skipped: String, |
| 54 | updated_at: Option<String>, |
| 55 | } |
| 56 | |
| 57 | /// A row as the guide's state. A `skipped` that cannot be read is nothing |
| 58 | /// skipped, and a step the guide no longer has is dropped, rather than |
| 59 | /// failing the page. |
| 60 | fn onboarding_from(row: Row) -> Onboarding { |
| 61 | let saved: Vec<String> = serde_json::from_str(&row.skipped).unwrap_or_default(); |
| 62 | let skipped = ONBOARDING_STEPS.iter().filter(|step| saved.iter().any(|s| s == *step)).map(|step| (*step).to_owned()).collect(); |
| 63 | Onboarding { dismissed_at: row.dismissed_at, skipped, updated_at: row.updated_at } |
| 64 | } |
| 65 | |
| 66 | impl Identity { |
| 67 | async fn read_onboarding(&self, user_id: &str, slug: &str) -> Result<Option<Onboarding>> { |
| 68 | // One query: the workspace by slug, and only while they belong to it. |
| 69 | let row = self |
| 70 | .db |
| 71 | .prepare( |
| 72 | "SELECT o.dismissed_at, o.skipped, o.updated_at FROM onboarding o |
| 73 | JOIN workspaces w ON w.id = o.workspace_id AND w.deleted_at IS NULL |
| 74 | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = o.user_id |
| 75 | WHERE o.user_id = ? AND w.slug = ?", |
| 76 | ) |
| 77 | .bind(&[user_id.into(), slug.into()])? |
| 78 | .first::<Row>(None) |
| 79 | .await?; |
| 80 | Ok(row.map(onboarding_from)) |
| 81 | } |
| 82 | |
| 83 | pub async fn onboarding(&self, a: OnboardingArgs) -> Result<Outcome<Onboarding>> { |
| 84 | let slug = a.workspace.trim().to_lowercase(); |
| 85 | if !is_person(&a.user) { |
| 86 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has Getting started.")); |
| 87 | } |
| 88 | if !a.user.is_member(&slug) { |
| 89 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 90 | } |
| 91 | Ok(Outcome::Ok(self.read_onboarding(&a.user.id, &slug).await?.unwrap_or_default())) |
| 92 | } |
| 93 | |
| 94 | pub async fn set_onboarding(&self, a: SetOnboardingArgs) -> Result<Outcome<Onboarding>> { |
| 95 | let slug = a.workspace.trim().to_lowercase(); |
| 96 | if !is_person(&a.user) { |
| 97 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has Getting started.")); |
| 98 | } |
| 99 | if !a.user.is_member(&slug) { |
| 100 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 101 | } |
| 102 | let skipped = match &a.change.skipped { |
| 103 | None => None, |
| 104 | Some(steps) => match check_skipped(steps) { |
| 105 | Ok(steps) => Some(steps), |
| 106 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 107 | }, |
| 108 | }; |
| 109 | let skipped = skipped.map(|steps| serde_json::to_string(&steps)).transpose()?; |
| 110 | // Written only where they are still a member, in one statement: a |
| 111 | // first dismissal is now and a second keeps the first time, and |
| 112 | // skipped steps left out of the change stay as they were. |
| 113 | let row = self |
| 114 | .db |
| 115 | .prepare(format!( |
| 116 | "INSERT INTO onboarding (user_id, workspace_id, dismissed_at, skipped, updated_at) |
| 117 | SELECT ?1, w.id, CASE ?2 WHEN 1 THEN {SQL_NOW} ELSE NULL END, COALESCE(?3, '[]'), {SQL_NOW} FROM workspaces w |
| 118 | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?1 |
| 119 | WHERE w.slug = ?4 AND w.deleted_at IS NULL |
| 120 | ON CONFLICT (user_id, workspace_id) DO UPDATE SET |
| 121 | dismissed_at = CASE ?2 WHEN 0 THEN onboarding.dismissed_at WHEN 1 THEN COALESCE(onboarding.dismissed_at, {SQL_NOW}) ELSE NULL END, |
| 122 | skipped = COALESCE(?3, onboarding.skipped), updated_at = excluded.updated_at |
| 123 | RETURNING dismissed_at, skipped, updated_at" |
| 124 | )) |
| 125 | .bind(&[ |
| 126 | a.user.id.as_str().into(), |
| 127 | dismiss_mode(a.change.dismissed).into(), |
| 128 | skipped.as_deref().map_or(JsValue::NULL, Into::into), |
| 129 | slug.into(), |
| 130 | ])? |
| 131 | .first::<Row>(None) |
| 132 | .await?; |
| 133 | Ok(match row { |
| 134 | Some(row) => Outcome::Ok(onboarding_from(row)), |
| 135 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), |
| 136 | }) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | #[cfg(test)] |
| 141 | mod tests { |
| 142 | use super::*; |
| 143 | |
| 144 | fn steps(list: &[&str]) -> Vec<String> { |
| 145 | list.iter().map(|step| (*step).to_owned()).collect() |
| 146 | } |
| 147 | |
| 148 | #[test] |
| 149 | fn skipped_steps_keep_the_guides_order_without_repeats() { |
| 150 | assert_eq!(check_skipped(&steps(&["runner", " people ", "runner"])).unwrap(), steps(&["people", "runner"])); |
| 151 | assert_eq!(check_skipped(&[]).unwrap(), Vec::<String>::new()); |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn a_step_the_guide_lacks_is_refused() { |
| 156 | let refused = check_skipped(&steps(&["ai", "billing"])).unwrap_err(); |
| 157 | assert!(refused.contains("billing") && refused.contains("ai, ask, code"), "{refused}"); |
| 158 | assert!(check_skipped(&steps(&[""])).is_err()); |
| 159 | } |
| 160 | |
| 161 | #[test] |
| 162 | fn a_change_says_what_happens_to_the_dismissal() { |
| 163 | assert_eq!(dismiss_mode(None), 0); |
| 164 | assert_eq!(dismiss_mode(Some(true)), 1); |
| 165 | assert_eq!(dismiss_mode(Some(false)), 2); |
| 166 | } |
| 167 | |
| 168 | #[test] |
| 169 | fn a_row_reads_with_only_the_steps_the_guide_has() { |
| 170 | let row = |skipped: &str| Row { dismissed_at: None, skipped: skipped.to_owned(), updated_at: None }; |
| 171 | assert_eq!(onboarding_from(row(r#"["runner","ask","gone"]"#)).skipped, steps(&["ask", "runner"])); |
| 172 | assert_eq!(onboarding_from(row("not json")).skipped, Vec::<String>::new()); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn a_person_who_never_chose_has_the_guide_open() { |
| 177 | let fresh = Onboarding::default(); |
| 178 | assert!(fresh.dismissed_at.is_none() && fresh.skipped.is_empty()); |
| 179 | let wire = serde_json::to_value(&fresh).unwrap(); |
| 180 | assert_eq!(wire, serde_json::json!({ "dismissed_at": null, "skipped": [], "updated_at": null })); |
| 181 | } |
| 182 | } |