headless_lms_chatbot/chatbot_tools/action_tools/
edit_user_account.rs1use headless_lms_authorization::Action;
2use indexmap::IndexMap;
3
4use headless_lms_models::chatbot_configurations::ToolCategory;
5use headless_lms_models::{user_details, user_details::EmailVerificationMethod, users};
6use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
7
8use crate::{
9 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
10 chatbot_tools::{
11 ChatbotToolDeclaration,
12 action_tools::{
13 ActionAuditFields, ConfirmableActionTool, ExecutedAction, verify_display_field,
14 },
15 argument_parsing::parse_required_uuid,
16 tool_authorization::{ToolAuthorization, ToolRequirement},
17 },
18 prelude::*,
19 user_context::ChatbotTurnContext,
20};
21
22pub struct EditUserAccountTool;
24
25enum VerificationChange {
26 NoChange,
27 Verify,
28 Unverify,
29}
30
31pub struct EditUserAccountArguments {
34 user_id: Uuid,
35 current_email: String,
36 new_email: Option<String>,
37 verification_change: VerificationChange,
38}
39
40#[derive(Deserialize)]
41struct RawArguments {
42 user_id: String,
43 current_email: String,
44 new_email: String,
45 mark_email_verified: String,
46}
47
48impl ChatbotToolDeclaration for EditUserAccountTool {
49 const NAME: &'static str = "edit_user_account";
50
51 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
52 vec![ToolRequirement::global(Action::AdministrateUserAccount)]
53 }
54
55 const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
56
57 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
58 AzureLLMFunctionToolDefinition {
59 tool_type: LLMToolType::Function,
60 name: Self::NAME.to_string(),
61 description: "Correct a user's email address and/or its verification state, after the admin confirms. Use to fix a typo'd email or to mark/clear verification. Does not merge accounts.".to_string(),
62 parameters: Schema::strict_object(
63 IndexMap::from([
64 (
65 "user_id".to_string(),
66 SchemaPropertyType::Item(JsonItem {
67 type_field: JSONType::String,
68 description: Some(
69 "The user_id (UUID) of the account to edit, as returned by find_user.".to_string(),
70 ),
71 }),
72 ),
73 (
74 "current_email".to_string(),
75 SchemaPropertyType::Item(JsonItem {
76 type_field: JSONType::String,
77 description: Some(
78 "The account's current email, exactly as find_user returned it. Shown to the admin and checked against the account before anything is changed.".to_string(),
79 ),
80 }),
81 ),
82 (
83 "new_email".to_string(),
84 SchemaPropertyType::Item(JsonItem {
85 type_field: JSONType::String,
86 description: Some(
87 "The corrected email address, or an empty string to leave the address unchanged.".to_string(),
88 ),
89 }),
90 ),
91 (
92 "mark_email_verified".to_string(),
93 SchemaPropertyType::Item(JsonItem {
94 type_field: JSONType::String,
95 description: Some(
96 "Empty string for no change, 'verify' to mark the email verified, 'unverify' to clear verification.".to_string(),
97 ),
98 }),
99 ),
100 ]),
101 None,
102 ),
103 strict: true,
104 }
105 }
106}
107
108impl ConfirmableActionTool for EditUserAccountTool {
109 type Arguments = EditUserAccountArguments;
110 type Facts = ();
111
112 fn call_requirements(
113 arguments: &Self::Arguments,
114 _user_context: &ChatbotTurnContext,
115 ) -> Vec<ToolRequirement> {
116 vec![ToolRequirement::on_user(
117 Action::AdministrateUserAccount,
118 arguments.user_id,
119 )]
120 }
121
122 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
123 let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
124 chatbot_err!(
125 InvalidToolArguments,
126 format!("Couldn't parse tool arguments. Arguments: {arguments}"),
127 e
128 )
129 })?;
130
131 let user_id = parse_required_uuid("user_id", &raw.user_id)?;
132
133 let current_email = raw.current_email.trim().to_string();
134 if current_email.is_empty() {
135 return Err(chatbot_err!(
136 InvalidToolArguments,
137 "current_email must not be empty.".to_string()
138 ));
139 }
140
141 let new_email_trimmed = raw.new_email.trim();
142 let new_email = if new_email_trimmed.is_empty() {
143 None
144 } else {
145 if !new_email_trimmed.contains('@') || new_email_trimmed.contains(char::is_whitespace) {
146 return Err(chatbot_err!(
147 InvalidToolArguments,
148 format!("'{new_email_trimmed}' does not look like an email address.")
149 ));
150 }
151 Some(new_email_trimmed.to_string())
152 };
153
154 let verification_change = match raw.mark_email_verified.as_str() {
155 "" => VerificationChange::NoChange,
156 "verify" => VerificationChange::Verify,
157 "unverify" => VerificationChange::Unverify,
158 other => {
159 return Err(chatbot_err!(
160 InvalidToolArguments,
161 format!(
162 "Unknown mark_email_verified '{other}'. Valid values: '', 'verify', 'unverify'."
163 )
164 ));
165 }
166 };
167
168 if new_email.is_none() && matches!(verification_change, VerificationChange::NoChange) {
169 return Err(chatbot_err!(
170 InvalidToolArguments,
171 "Nothing to do: new_email is empty and mark_email_verified is empty.".to_string()
172 ));
173 }
174
175 Ok(EditUserAccountArguments {
176 user_id,
177 current_email,
178 new_email,
179 verification_change,
180 })
181 }
182
183 async fn execute(
184 conn: &mut PgConnection,
185 _app_config: &ApplicationConfiguration,
186 arguments: &Self::Arguments,
187 _authorization: &ToolAuthorization<Self>,
188 ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
189 let user = users::get_active_by_id(conn, arguments.user_id)
190 .await
191 .map_err(|e| {
192 chatbot_err!(
193 ToolUseError,
194 format!(
195 "No active account found with user_id {} (or it is deleted). Re-run find_user.",
196 arguments.user_id
197 ),
198 e
199 )
200 })?;
201
202 let details = user_details::get_user_details_by_user_id(conn, user.id)
203 .await
204 .map_err(|e| {
205 chatbot_err!(
206 ToolUseError,
207 format!("No account details found for user_id {}.", user.id),
208 e
209 )
210 })?;
211
212 verify_display_field(
213 "current_email",
214 &details.email,
215 &arguments.current_email,
216 "find_user",
217 )?;
218
219 let old_email = details.email.clone();
220 let mut new_email_applied: Option<String> = None;
221
222 if let Some(new_email) = &arguments.new_email {
223 if let Some(other_user_id) =
224 user_details::get_active_user_id_by_email_case_insensitive(conn, new_email).await?
225 && other_user_id != user.id
226 {
227 return Err(chatbot_err!(
228 ToolUseError,
229 "Another account already uses this email address — likely a duplicate-account case; do not merge by email.".to_string()
230 ));
231 }
232
233 users::update_email_for_user_by_id(conn, user.id, new_email).await?;
234 new_email_applied = Some(new_email.clone());
235 }
236
237 let verification_label = match arguments.verification_change {
240 VerificationChange::NoChange => None,
241 VerificationChange::Verify => {
242 user_details::set_email_verified(
243 conn,
244 user.id,
245 EmailVerificationMethod::AdminAsserted,
246 Utc::now(),
247 )
248 .await?;
249 Some("verified (admin-asserted)")
250 }
251 VerificationChange::Unverify => {
252 user_details::clear_email_verified(conn, user.id).await?;
253 Some("unverified")
254 }
255 };
256
257 let displayed_new_email = new_email_applied.as_deref().unwrap_or(&old_email);
258 let mut summary = if let Some(new_email) = &new_email_applied {
259 format!("Changed email {old_email} → {new_email}")
260 } else {
261 format!("No email change for {old_email}")
262 };
263 if let Some(label) = verification_label {
264 summary.push_str(&format!("; marked {label}"));
265 }
266
267 let mut output = format!(
268 "Account updated for user {}: email {} → {}",
269 user.id, old_email, displayed_new_email
270 );
271 if let Some(label) = verification_label {
272 output.push_str(&format!("; email verification → {label}"));
273 }
274 output.push('.');
275
276 Ok((
277 ExecutedAction {
278 output,
279 client_payload: None,
280 audit: ActionAuditFields {
281 target_user_id: Some(user.id),
282 course_id: None,
283 summary,
284 },
285 },
286 (),
287 ))
288 }
289
290 fn output_description_instructions(
291 arguments: &Self::Arguments,
292 _facts: Option<&Self::Facts>,
293 app_config: &ApplicationConfiguration,
294 ) -> Option<String> {
295 let base_url = app_config.base_url.trim_end_matches('/');
296 let mut notes = vec![
297 "State the old and new values back to the admin. If the change was refused because the address belongs to another account, suggest comparing the two accounts' enrollments (user_overview) instead -- this tool cannot merge accounts and progress does not follow the address.".to_string(),
298 format!(
299 "No admin page can make this change -- this tool is the only way to edit another user's email or verification state -- so tell the admin to open {base_url}/manage/users/{} and confirm the address and the verification badge now read what you asked for.",
300 arguments.user_id
301 ),
302 ];
303
304 let email_changed = arguments.new_email.is_some();
305 let verify_requested = matches!(arguments.verification_change, VerificationChange::Verify);
306 if email_changed && !verify_requested {
307 notes.push("Changing the email clears the account's verification automatically, so unless verify was also requested the account is now unverified and will be asked to re-confirm the address.".to_string());
308 }
309 if verify_requested {
310 notes.push("Marking the email verified this way records the admin's own assertion, not proof the user controls the address -- the weakest of the platform's verification methods, and the user page shows only the verified badge and its timestamp, not the method, so this distinction is invisible there and this reply is the only record of it.".to_string());
311 }
312 if matches!(arguments.verification_change, VerificationChange::Unverify) {
313 notes.push("Clearing verification breaks any flow gated on it (e.g. verification-only emails) -- don't do this casually.".to_string());
314 }
315
316 Some(notes.join(" "))
317 }
318}