Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

182 linesCodeBlameRaw
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
13use g1t_contracts::identity::*;
14use g1t_contracts::time::SQL_NOW;
15use g1t_contracts::{FailureCode, Outcome};
16use serde::Deserialize;
17use worker::Result;
18use worker::wasm_bindgen::JsValue;
19
20use crate::Identity;
21use 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.
25pub 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.
42pub 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)]
51struct 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.
60fn 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
66impl 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)]
141mod 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}