1use chrono::{NaiveDate, NaiveTime};
2use headless_lms_authorization::Action;
3use indexmap::IndexMap;
4
5use headless_lms_models::chatbot_configurations::ToolCategory;
6use headless_lms_models::{
7 certificate_configuration_to_requirements, course_modules, generated_certificates,
8 generated_certificates::GeneratedCertificate,
9};
10use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
11
12use crate::{
13 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
14 chatbot_tools::{
15 ChatbotToolDeclaration,
16 action_tools::{
17 ActionAuditFields, ConfirmableActionTool, ExecutedAction, verify_display_field,
18 },
19 argument_parsing::parse_required_uuid,
20 certificate_validation_url,
21 tool_authorization::{ToolAuthorization, ToolRequirement},
22 },
23 prelude::*,
24 user_context::ChatbotTurnContext,
25};
26
27pub struct UpdateCertificateTool;
31
32pub struct UpdateCertificateArguments {
36 certificate_id: Uuid,
37 course_id: Uuid,
38 current_name_on_certificate: String,
39 new_name_on_certificate: Option<String>,
40 new_date_issued: Option<DateTime<Utc>>,
41}
42
43pub struct UpdateCertificateFacts {
45 name_changed: bool,
46 date_changed: bool,
47 verification_id: String,
48}
49
50#[derive(Deserialize)]
51struct RawArguments {
52 certificate_id: String,
53 course_id: String,
54 current_name_on_certificate: String,
55 new_name_on_certificate: String,
56 new_date_issued: String,
57}
58
59impl ChatbotToolDeclaration for UpdateCertificateTool {
60 const NAME: &'static str = "update_certificate";
61
62 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
63 vec![ToolRequirement::on_turn(Action::Teach, user_context)]
64 }
65
66 const CATEGORY: ToolCategory = ToolCategory::AdminSupportLearningProgress;
67
68 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
69 AzureLLMFunctionToolDefinition {
70 tool_type: LLMToolType::Function,
71 name: Self::NAME.to_string(),
72 description: "Correct the name printed on an issued certificate, and optionally the date it was issued on, after the admin confirms. Use to fix a misspelled or outdated name. Resolve the certificate with certificate_lookup first; this does not issue, revoke or regenerate certificates.".to_string(),
73 parameters: Schema::strict_object(
74 IndexMap::from([
75 (
76 "certificate_id".to_string(),
77 SchemaPropertyType::Item(JsonItem {
78 type_field: JSONType::String,
79 description: Some("The certificate's id (UUID), as returned by certificate_lookup or user_course_state. Not the verification id.".to_string()),
80 }),
81 ),
82 (
83 "course_id".to_string(),
84 SchemaPropertyType::Item(JsonItem {
85 type_field: JSONType::String,
86 description: Some("The id of the course the certificate was earned on, as returned alongside the certificate. Checked against the certificate before anything changes.".to_string()),
87 }),
88 ),
89 (
90 "current_name_on_certificate".to_string(),
91 SchemaPropertyType::Item(JsonItem {
92 type_field: JSONType::String,
93 description: Some("The name currently printed on the certificate, exactly as it was returned. Shown to the admin and checked against the certificate before anything changes.".to_string()),
94 }),
95 ),
96 (
97 "new_name_on_certificate".to_string(),
98 SchemaPropertyType::Item(JsonItem {
99 type_field: JSONType::String,
100 description: Some("The corrected name to print, or an empty string to leave the name unchanged.".to_string()),
101 }),
102 ),
103 (
104 "new_date_issued".to_string(),
105 SchemaPropertyType::Item(JsonItem {
106 type_field: JSONType::String,
107 description: Some("The corrected issue date as YYYY-MM-DD (or a full RFC 3339 timestamp), or an empty string to leave the issue date unchanged.".to_string()),
108 }),
109 ),
110 ]),
111 None,
112 ),
113 strict: true,
114 }
115 }
116}
117
118impl ConfirmableActionTool for UpdateCertificateTool {
119 type Arguments = UpdateCertificateArguments;
120 type Facts = UpdateCertificateFacts;
121
122 fn call_requirements(
123 arguments: &Self::Arguments,
124 _user_context: &ChatbotTurnContext,
125 ) -> Vec<ToolRequirement> {
126 vec![ToolRequirement::on_course(
127 Action::Teach,
128 arguments.course_id,
129 )]
130 }
131
132 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
133 let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
134 chatbot_err!(
135 InvalidToolArguments,
136 format!("Couldn't parse tool arguments. Arguments: {arguments}"),
137 e
138 )
139 })?;
140
141 let certificate_id = parse_required_uuid("certificate_id", &raw.certificate_id)?;
142 let course_id = parse_required_uuid("course_id", &raw.course_id)?;
143
144 let current_name_on_certificate = raw.current_name_on_certificate.trim().to_string();
145 if current_name_on_certificate.is_empty() {
146 return Err(chatbot_err!(
147 InvalidToolArguments,
148 "current_name_on_certificate must not be empty.".to_string()
149 ));
150 }
151
152 let new_name_trimmed = raw.new_name_on_certificate.trim();
153 let new_name_on_certificate = (!new_name_trimmed.is_empty())
154 .then(|| new_name_trimmed.to_string())
155 .filter(|new_name| new_name != ¤t_name_on_certificate);
156
157 let new_date_issued = parse_date_issued(raw.new_date_issued.trim())?;
158
159 if new_name_on_certificate.is_none() && new_date_issued.is_none() {
160 return Err(chatbot_err!(
161 InvalidToolArguments,
162 "Nothing to do: the name is unchanged and new_date_issued is empty.".to_string()
163 ));
164 }
165
166 Ok(UpdateCertificateArguments {
167 certificate_id,
168 course_id,
169 current_name_on_certificate,
170 new_name_on_certificate,
171 new_date_issued,
172 })
173 }
174
175 async fn execute(
176 conn: &mut PgConnection,
177 _app_config: &ApplicationConfiguration,
178 arguments: &Self::Arguments,
179 _authorization: &ToolAuthorization<Self>,
180 ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
181 let certificate = generated_certificates::get_by_id(conn, arguments.certificate_id)
182 .await
183 .optional()?
184 .ok_or_else(|| {
185 chatbot_err!(
186 InvalidToolArguments,
187 "The certificate no longer exists. Re-run certificate_lookup.".to_string()
188 )
189 })?;
190
191 verify_certificate_belongs_to_course(conn, &certificate, arguments.course_id).await?;
192
193 verify_display_field(
194 "name on the certificate",
195 &certificate.name_on_certificate,
196 &arguments.current_name_on_certificate,
197 "certificate_lookup",
198 )?;
199
200 let date_issued = arguments.new_date_issued.unwrap_or(certificate.created_at);
203 let date_changed = date_issued != certificate.created_at;
204 if arguments.new_name_on_certificate.is_none() && !date_changed {
205 return Err(chatbot_err!(
206 InvalidToolArguments,
207 format!(
208 "Nothing to do: the certificate is already dated {} and the name is unchanged.",
209 date_issued.date_naive()
210 )
211 ));
212 }
213
214 let updated = generated_certificates::update_certificate(
215 conn,
216 arguments.certificate_id,
217 date_issued,
218 arguments.new_name_on_certificate.clone(),
219 Some(certificate.updated_at),
220 )
221 .await?
222 .ok_or_else(|| {
223 chatbot_err!(
224 ToolUseError,
225 "The certificate was changed by someone else while this was waiting for confirmation. Re-run certificate_lookup and ask again.".to_string()
226 )
227 })?;
228
229 let mut changes = Vec::new();
230 if let Some(new_name) = &arguments.new_name_on_certificate {
231 changes.push(format!(
232 "the name was changed from \"{}\" to \"{new_name}\"",
233 arguments.current_name_on_certificate
234 ));
235 }
236 if date_changed {
237 changes.push(format!(
238 "the issue date was changed to {}",
239 date_issued.date_naive()
240 ));
241 }
242
243 Ok((
244 ExecutedAction {
245 output: format!(
246 "Certificate {} was updated: {}.",
247 updated.verification_id,
248 changes.join(" and ")
249 ),
250 client_payload: None,
251 audit: ActionAuditFields {
252 target_user_id: Some(certificate.user_id),
253 course_id: Some(arguments.course_id),
254 summary: format!(
255 "Certificate {} updated: {}",
256 updated.verification_id,
257 changes.join(" and ")
258 ),
259 },
260 },
261 UpdateCertificateFacts {
262 name_changed: arguments.new_name_on_certificate.is_some(),
263 date_changed,
264 verification_id: updated.verification_id,
265 },
266 ))
267 }
268
269 fn output_description_instructions(
270 arguments: &Self::Arguments,
271 facts: Option<&Self::Facts>,
272 app_config: &ApplicationConfiguration,
273 ) -> Option<String> {
274 let base_url = app_config.base_url.trim_end_matches('/');
275
276 let Some(facts) = facts else {
277 return Some(format!(
278 "Nothing was changed, so the certificate still reads \"{}\". Its page at {}/manage/courses/{}/students/certificates is where the admin can check the current name before asking again.",
279 arguments.current_name_on_certificate, base_url, arguments.course_id
280 ));
281 };
282
283 let validation_url = certificate_validation_url(base_url, &facts.verification_id);
284 let mut notes = vec![format!(
285 "The certificate image is rendered from the record on every view, so the change is already visible: \
286 link the certificate as a markdown link to {validation_url} so the admin can see it. The link itself \
287 did not change, and any copy the student saved earlier still shows the old text."
288 )];
289
290 if facts.name_changed {
291 notes.push(
292 "Only the printed name changed. The student's account name is a different field and is untouched, \
293 so mention that if the admin expected both to change."
294 .to_string(),
295 );
296 }
297
298 if facts.date_changed {
299 notes.push(format!(
300 "The issue date is the certificate's own creation timestamp, so moving it also moves where the \
301 certificate sorts in the student's profile and in {base_url}/manage/courses/{}/students/certificates.",
302 arguments.course_id
303 ));
304 }
305
306 Some(notes.join(" "))
307 }
308}
309
310fn parse_date_issued(raw: &str) -> ChatbotResult<Option<DateTime<Utc>>> {
313 if raw.is_empty() {
314 return Ok(None);
315 }
316 if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
317 return Ok(Some(date.and_time(NaiveTime::MIN).and_utc()));
318 }
319 DateTime::parse_from_rfc3339(raw)
320 .map(|parsed| Some(parsed.with_timezone(&Utc)))
321 .map_err(|e| {
322 chatbot_err!(
323 InvalidToolArguments,
324 format!(
325 "'{raw}' is not a date this tool understands. Use YYYY-MM-DD or a full RFC 3339 timestamp."
326 ),
327 e
328 )
329 })
330}
331
332async fn verify_certificate_belongs_to_course(
336 conn: &mut PgConnection,
337 certificate: &GeneratedCertificate,
338 course_id: Uuid,
339) -> ChatbotResult<()> {
340 let requirements = certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
341 conn,
342 certificate.certificate_configuration_id,
343 )
344 .await?;
345
346 let modules = course_modules::get_by_ids(conn, &requirements.course_module_ids).await?;
347 if modules.len() != requirements.course_module_ids.len() {
348 return Err(chatbot_err!(
349 ToolUseError,
350 "The certificate requires a course module that no longer exists, so the course it belongs to cannot be established.".to_string()
351 ));
352 }
353
354 if modules.is_empty() || modules.iter().any(|module| module.course_id != course_id) {
355 return Err(chatbot_err!(
356 InvalidToolArguments,
357 "This certificate was not earned on that course. Re-run certificate_lookup and use the course_id it returns for this certificate.".to_string()
358 ));
359 }
360
361 Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
371 fn a_plain_day_and_a_full_timestamp_are_both_accepted() {
372 assert_eq!(parse_date_issued("").expect("empty is allowed"), None);
373 assert_eq!(
374 parse_date_issued("2026-02-03")
375 .expect("a plain day parses")
376 .map(|date| date.to_rfc3339()),
377 Some("2026-02-03T00:00:00+00:00".to_string())
378 );
379 assert_eq!(
380 parse_date_issued("2026-02-03T10:00:00Z")
381 .expect("an RFC 3339 timestamp parses")
382 .map(|date| date.to_rfc3339()),
383 Some("2026-02-03T10:00:00+00:00".to_string())
384 );
385 assert!(parse_date_issued("3rd of February").is_err());
386 assert!(parse_date_issued("03/02/2026").is_err());
387 }
388
389 fn raw_arguments(new_name: &str, new_date: &str) -> RawArguments {
390 RawArguments {
391 certificate_id: "00000000-0000-0000-0000-000000000001".to_string(),
392 course_id: "00000000-0000-0000-0000-000000000002".to_string(),
393 current_name_on_certificate: "Example Learner".to_string(),
394 new_name_on_certificate: new_name.to_string(),
395 new_date_issued: new_date.to_string(),
396 }
397 }
398
399 #[test]
402 fn a_call_that_changes_nothing_is_refused() {
403 assert!(build_test_arguments(raw_arguments("", "")).is_err());
404 assert!(build_test_arguments(raw_arguments("Example Learner", "")).is_err());
405 assert!(build_test_arguments(raw_arguments("Example learner ", "")).is_ok());
407 assert!(build_test_arguments(raw_arguments("", "2026-02-03")).is_ok());
408 }
409
410 fn build_test_arguments(raw: RawArguments) -> ChatbotResult<UpdateCertificateArguments> {
411 let json = serde_json::json!({
412 "certificate_id": raw.certificate_id,
413 "course_id": raw.course_id,
414 "current_name_on_certificate": raw.current_name_on_certificate,
415 "new_name_on_certificate": raw.new_name_on_certificate,
416 "new_date_issued": raw.new_date_issued,
417 });
418 UpdateCertificateTool::parse_arguments(&json.to_string())
419 }
420}