| 1 | //! A person's own Getting started in a workspace, over REST: the setup |
| 2 | //! guide its owners see on its Home, at |
| 3 | //! `GET`/`PATCH /workspaces/{workspace}/onboarding`. Identity keeps it |
| 4 | //! (services/identity/src/onboarding.rs): whether the person dismissed it |
| 5 | //! and the steps they skipped. Whether each step is done is read from the |
| 6 | //! workspace itself, so it is not here. People only, each their own; not |
| 7 | //! an action of any MCP tool, since it is about a person at a page. |
| 8 | |
| 9 | use g1t_contracts::identity::{ONBOARDING_STEPS, OnboardingChange}; |
| 10 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 11 | use serde_json::{Value, json}; |
| 12 | use worker::Result; |
| 13 | |
| 14 | use crate::operations::Services; |
| 15 | |
| 16 | /// One operation. |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 18 | pub enum OnboardingOp { |
| 19 | GetOnboarding, |
| 20 | UpdateOnboarding, |
| 21 | } |
| 22 | |
| 23 | impl OnboardingOp { |
| 24 | /// Every one: `Op::ALL` lists each as `Op::Onboarding(…)`, which a test |
| 25 | /// checks against this. |
| 26 | #[cfg(test)] |
| 27 | pub const ALL: [OnboardingOp; 2] = [OnboardingOp::GetOnboarding, OnboardingOp::UpdateOnboarding]; |
| 28 | |
| 29 | pub fn name(self) -> &'static str { |
| 30 | match self { |
| 31 | OnboardingOp::GetOnboarding => "get_onboarding", |
| 32 | OnboardingOp::UpdateOnboarding => "update_onboarding", |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | pub fn title(self) -> &'static str { |
| 37 | match self { |
| 38 | OnboardingOp::GetOnboarding => "Get your Getting started in a workspace", |
| 39 | OnboardingOp::UpdateOnboarding => "Dismiss or skip steps of Getting started", |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | pub fn description(self) -> &'static str { |
| 44 | match self { |
| 45 | OnboardingOp::GetOnboarding => "Your own state of a workspace's Getting started, the setup guide that opens on its Home for its owners: dismissed_at (when you dismissed it, so it no longer opens by itself; null while it does), skipped (the steps you chose to skip, in the guide's order: ai, ask, code, harness, people, runner) and updated_at (null when you never changed it). Whether a step is done is not here: it is the workspace's own state, such as its AI providers, repositories, members and runners. Members only, as people; each person's own.", |
| 46 | OnboardingOp::UpdateOnboarding => "Change your own Getting started in a workspace; fields left out stay as they are. dismissed true dismisses it, so it no longer opens by itself on the workspace's Home (dismissing it again keeps the first time); false makes it open by itself again. skipped replaces the steps you skipped: any of ai, ask, code, harness, people and runner. Opening the guide from Settings or the AI page changes nothing here. Members only, as people; each person's own.", |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// Whether it changes anything: checked against the scope table in tests. |
| 51 | #[cfg(test)] |
| 52 | pub fn writes(self) -> bool { |
| 53 | self == OnboardingOp::UpdateOnboarding |
| 54 | } |
| 55 | |
| 56 | pub fn input(self) -> Value { |
| 57 | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); |
| 58 | let (properties, required): (Value, &[&str]) = match self { |
| 59 | OnboardingOp::GetOnboarding => (json!({ "workspace": workspace }), &["workspace"]), |
| 60 | OnboardingOp::UpdateOnboarding => ( |
| 61 | json!({ |
| 62 | "workspace": workspace, |
| 63 | "dismissed": { "type": "boolean", "description": "True dismisses it, so it no longer opens by itself; false makes it open by itself again." }, |
| 64 | "skipped": { |
| 65 | "type": "array", |
| 66 | "items": { "type": "string", "enum": ONBOARDING_STEPS }, |
| 67 | "description": "The steps you skip, replacing those you skipped before. An empty list skips none.", |
| 68 | }, |
| 69 | }), |
| 70 | &["workspace"], |
| 71 | ), |
| 72 | }; |
| 73 | json!({ "type": "object", "properties": properties, "required": required }) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /// `dismissed` as sent: a boolean, or a word from a form; null when left out. |
| 78 | fn dismissed(input: &Value) -> std::result::Result<Option<bool>, ()> { |
| 79 | match &input["dismissed"] { |
| 80 | Value::Null => Ok(None), |
| 81 | Value::Bool(on) => Ok(Some(*on)), |
| 82 | Value::String(word) => match word.trim() { |
| 83 | "true" | "1" => Ok(Some(true)), |
| 84 | "false" | "0" => Ok(Some(false)), |
| 85 | _ => Err(()), |
| 86 | }, |
| 87 | _ => Err(()), |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// `skipped` as sent: a list of step names; null when left out. |
| 92 | fn skipped(input: &Value) -> std::result::Result<Option<Vec<String>>, ()> { |
| 93 | match &input["skipped"] { |
| 94 | Value::Null => Ok(None), |
| 95 | Value::Array(items) => items.iter().map(|item| item.as_str().map(str::to_owned).ok_or(())).collect::<std::result::Result<Vec<_>, _>>().map(Some), |
| 96 | _ => Err(()), |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub async fn run(op: OnboardingOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 101 | let Some(user) = viewer.clone() else { |
| 102 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); |
| 103 | }; |
| 104 | let workspace = input["workspace"].as_str().map(str::trim).unwrap_or_default().to_lowercase(); |
| 105 | if workspace.is_empty() { |
| 106 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); |
| 107 | } |
| 108 | let identity = &services.identity; |
| 109 | match op { |
| 110 | OnboardingOp::GetOnboarding => g1t_kit::call(identity, "onboarding", &json!({ "user": user, "workspace": workspace })).await, |
| 111 | OnboardingOp::UpdateOnboarding => { |
| 112 | let Ok(dismissed) = dismissed(input) else { |
| 113 | return Ok(Outcome::fail(FailureCode::Invalid, "dismissed is true or false.")); |
| 114 | }; |
| 115 | let Ok(skipped) = skipped(input) else { |
| 116 | return Ok(Outcome::fail(FailureCode::Invalid, "skipped is a list of step names.")); |
| 117 | }; |
| 118 | let change = OnboardingChange { dismissed, skipped }; |
| 119 | g1t_kit::call(identity, "set_onboarding", &json!({ "user": user, "workspace": workspace, "change": change })).await |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | #[cfg(test)] |
| 125 | mod tests { |
| 126 | use super::*; |
| 127 | use g1t_contracts::scopes::{Level, scope_for}; |
| 128 | |
| 129 | #[test] |
| 130 | fn a_change_is_read_from_what_was_sent() { |
| 131 | assert_eq!(dismissed(&json!({})), Ok(None)); |
| 132 | assert_eq!(dismissed(&json!({ "dismissed": true })), Ok(Some(true))); |
| 133 | assert_eq!(dismissed(&json!({ "dismissed": "false" })), Ok(Some(false))); |
| 134 | assert!(dismissed(&json!({ "dismissed": "maybe" })).is_err()); |
| 135 | assert_eq!(skipped(&json!({})), Ok(None)); |
| 136 | assert_eq!(skipped(&json!({ "skipped": ["people"] })), Ok(Some(vec!["people".to_owned()]))); |
| 137 | assert_eq!(skipped(&json!({ "skipped": [] })), Ok(Some(Vec::new()))); |
| 138 | assert!(skipped(&json!({ "skipped": "people" })).is_err()); |
| 139 | assert!(skipped(&json!({ "skipped": [1] })).is_err()); |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn each_operation_is_described_and_scoped() { |
| 144 | for op in OnboardingOp::ALL { |
| 145 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name()); |
| 146 | assert!(crate::tools::REST_ONLY.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name()); |
| 147 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 148 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); |
| 149 | let level = scope_for(op.name()).unwrap().level(); |
| 150 | assert_eq!(level, if op.writes() { Level::Write } else { Level::Read }, "{}", op.name()); |
| 151 | } |
| 152 | } |
| 153 | } |