Skip to main content

headless_lms_chatbot/chatbot_tools/action_tools/
edit_user_account.rs

1use 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
22/// Corrects a user's email and/or its verification state, after the admin confirms.
23pub struct EditUserAccountTool;
24
25enum VerificationChange {
26    NoChange,
27    Verify,
28    Unverify,
29}
30
31/// `current_email` is the display/consistency field: re-checked against the account in [execute]
32/// before anything is changed, so a stale or wrong value refuses rather than mutates.
33pub 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        // Must run after the email update: set_email_verified stamps the row's *current* email,
238        // so verifying before an email change would verify the address being replaced.
239        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}