advent of atproto
at feature/UIChanges 396 lines 14 kB view raw
1pub mod challenges; 2pub mod day; 3 4use crate::advent::day::Day; 5use crate::assets::ChallengesMarkdown; 6use crate::models::db_models::ChallengeProgress; 7use async_trait::async_trait; 8use handlebars::{Handlebars, RenderError}; 9use markdown::{CompileOptions, Options}; 10use rand::distr::{Alphanumeric, SampleString}; 11use rust_embed::EmbeddedFile; 12use serde_json::json; 13use sqlx::PgPool; 14use std::str::Utf8Error; 15use thiserror::Error; 16 17#[derive(Debug, Error)] 18pub enum AdventError { 19 #[error("Database error: {0}")] 20 Database(#[from] sqlx::Error), 21 #[error("Io error: {0}")] 22 Io(#[from] std::io::Error), 23 #[error("Invalid day: {0}. Day must be between 1 and 25")] 24 InvalidDay(i32), 25 #[error("This challenge only has a single challenge")] 26 NoPartTwo, 27 #[error("UTF-8 error: {0}")] 28 Utf8Error(#[from] Utf8Error), 29 #[error("Render error: {0}")] 30 RenderError(#[from] RenderError), 31 #[error("This was not designed to happen: {0}")] 32 ShouldNotHappen(String), 33} 34 35pub enum AdventAction { 36 //If Part one is done it shows both, if not it only shows part one 37 // the Option<String> here is the did of the user. If none only show part one no matter what 38 ViewChallenge(Option<String>), 39 40 //The strings here are the users did's 41 StartPartOne(String), 42 StartPartTwo(String), 43 44 //TODO should I have like SubmitPartOne(String) and it's the code? 45 //These actions will be locked behind logged in 46 SubmitPartOne, 47 SubmitPartTwo, 48} 49 50pub enum AdventPart { 51 One, 52 Two, 53} 54 55#[derive(Clone, Copy, Debug)] 56pub enum CompletionStatus { 57 ///None of the day's challenges have been completed 58 None, 59 ///PartOne of the day's challenges has been completed 60 PartOne, 61 ///PartTwo of the day's challenges has been completed 62 //i dont think this was needed 63 // PartTwo, 64 ///Both of the day's challenges have been completed 65 Both, 66} 67 68pub enum ChallengeCheckResponse { 69 Correct, 70 ///Error message on why it was incorrect 71 Incorrect(String), 72} 73 74pub enum AdventActionResult { 75 ShowPartOne, 76 // If partone is completed, this will be shown 77 ShowPartTwo, 78 79 CorrectSubmission(AdventPart), 80 IncorrectSubmission, 81 82 Completed, 83 84 Error(String), 85} 86 87#[async_trait] 88pub trait AdventChallenge { 89 /// The db pool in case the challenge needs extra access 90 fn pool(&self) -> &PgPool; 91 92 /// The day of the challenge 1-25 93 fn day(&self) -> Day; 94 95 fn get_day_markdown_file(&self, part: AdventPart) -> Result<Option<EmbeddedFile>, AdventError> { 96 let day = self.day().to_string().to_ascii_lowercase(); 97 let path = match part { 98 AdventPart::One => format!("{day}/part_one.md"), 99 AdventPart::Two => format!("{day}/part_two.md"), 100 }; 101 match ChallengesMarkdown::get(path.as_str()) { 102 None => { 103 log::error!("Missing the part one challenge file for day: {}", day); 104 Ok(None) 105 } 106 Some(day_one_file) => Ok(Some(day_one_file)), 107 } 108 } 109 110 /// Does the day have a part two challenge? 111 fn has_part_two(&self) -> bool; 112 //Commenting this out and just going leave it to who makes the impl. This is less code to write, 113 // but really it's a faster response to just have the author put true or false 114 // 115 // { 116 // match self.get_day_markdown_file(AdventPart::Two) { 117 // Ok(_) => true, 118 // Err(_) => false, 119 // } 120 // } 121 122 /// Does part one require manual verification code input from the user? 123 /// Default is false (verification happens automatically via backend) 124 fn requires_manual_verification_part_one(&self) -> bool { 125 false 126 } 127 128 /// Does part two require manual verification code input from the user? 129 /// Default is false (verification happens automatically via backend) 130 fn requires_manual_verification_part_two(&self) -> bool { 131 false 132 } 133 134 /// The text Markdown for challenge 1 135 fn markdown_text_part_one( 136 &self, 137 verification_code: Option<String>, 138 ) -> Result<String, AdventError> { 139 let day = self.day(); 140 141 //May acutally leave this unwrap or put a panic in case it doesn't exist since it is needed to have a part one 142 match self.get_day_markdown_file(AdventPart::One)? { 143 None => { 144 log::error!("Missing the part one challenge file for day: {}", day); 145 Ok("Someone let the admins know this page is missing. No, this is not an Easter egg, it's an actual bug".to_string()) 146 } 147 Some(day_one_file) => { 148 //TODO probably should be a shared variable, but prototyping 149 let reg = Handlebars::new(); 150 151 let day_one_text = std::str::from_utf8(day_one_file.data.as_ref())?; 152 let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string()); 153 let handlebar_rendered = 154 reg.render_template(day_one_text, &json!({"code": code}))?; 155 156 Ok( 157 markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options()) 158 .unwrap(), 159 ) 160 } 161 } 162 } 163 164 /// The text Markdown for challenge 2, could be None 165 fn markdown_text_part_two( 166 &self, 167 verification_code: Option<String>, 168 ) -> Result<Option<String>, AdventError> { 169 match self.get_day_markdown_file(AdventPart::Two)? { 170 None => Ok(None), 171 Some(day_two_file) => { 172 //TODO probably should be a shared variable, but prototyping 173 let reg = Handlebars::new(); 174 175 let day_two_text = std::str::from_utf8(day_two_file.data.as_ref())?; 176 let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string()); 177 let handlebar_rendered = 178 reg.render_template(day_two_text, &json!({"code": code}))?; 179 180 Ok(Some( 181 markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options()) 182 .unwrap(), 183 )) 184 } 185 } 186 } 187 188 /// Checks to see if the day's challenge had been started for the user 189 async fn day_started(&self, did: &str) -> Result<bool, AdventError> { 190 let exists = sqlx::query_scalar::<_, i64>( 191 "SELECT id FROM challenges WHERE user_did = $1 AND day = $2 LIMIT 1", 192 ) 193 .bind(did) 194 .bind(self.day() as i16) 195 .fetch_optional(self.pool()) 196 .await?; 197 Ok(exists.is_some()) 198 } 199 200 async fn get_days_challenge( 201 &self, 202 did: &str, 203 ) -> Result<Option<ChallengeProgress>, AdventError> { 204 Ok(sqlx::query_as::<_, ChallengeProgress>( 205 "SELECT * FROM challenges WHERE user_did = $1 AND day = $2", 206 ) 207 .bind(did) 208 .bind(self.day() as i16) 209 .fetch_optional(self.pool()) 210 .await?) 211 } 212 213 async fn start_challenge(&self, did: String, part: AdventPart) -> Result<String, AdventError> { 214 let code = get_random_token(); 215 match part { 216 AdventPart::One => sqlx::query( 217 "INSERT INTO challenges (user_did, day, time_started, verification_code_one) 218 VALUES ($1, $2, NOW(), $3) 219 ON CONFLICT (user_did, day) 220 DO UPDATE SET verification_code_one = $3 221 WHERE challenges.user_did = $1 AND challenges.day = $2", 222 ), 223 //TODO just going leave these as an update. It should never ideally be an insert 224 AdventPart::Two => sqlx::query( 225 "UPDATE challenges 226 SET verification_code_two = $3 227 WHERE challenges.user_did = $1 AND challenges.day = $2", 228 ), 229 } 230 .bind(did) 231 .bind(self.day() as i16) 232 .bind(code.clone()) 233 .execute(self.pool()) 234 .await?; 235 Ok(code) 236 } 237 238 /// Marks the challenge as completed. 239 async fn complete_part_one(&self, did: String) -> Result<(), AdventError> { 240 sqlx::query( 241 "UPDATE challenges 242 SET time_challenge_one_completed = COALESCE(time_challenge_one_completed, NOW()) 243 WHERE user_did = $1 AND day = $2", 244 ) 245 .bind(did) 246 .bind(self.day() as i16) 247 .execute(self.pool()) 248 .await?; 249 Ok(()) 250 } 251 252 /// Marks the challenge as completed. 253 async fn complete_part_two(&self, did: String) -> Result<(), AdventError> { 254 sqlx::query( 255 "UPDATE challenges 256 SET time_challenge_two_completed = COALESCE(time_challenge_two_completed, NOW()) 257 WHERE user_did = $1 AND day = $2", 258 ) 259 .bind(did) 260 .bind(self.day() as i16) 261 .execute(self.pool()) 262 .await?; 263 Ok(()) 264 } 265 266 async fn get_completed_status( 267 &self, 268 did: Option<String>, 269 ) -> Result<CompletionStatus, AdventError> { 270 match did { 271 None => Ok(CompletionStatus::None), 272 Some(did) => { 273 let day = self.day() as i32; 274 let result = sqlx::query!( 275 "SELECT time_challenge_one_completed, time_challenge_two_completed 276 FROM challenges 277 WHERE user_did = $1 AND day = $2", 278 did, 279 day 280 ) 281 .fetch_optional(self.pool()) 282 .await?; 283 284 Ok(match result { 285 None => CompletionStatus::None, 286 Some(row) => match ( 287 row.time_challenge_one_completed, 288 row.time_challenge_two_completed, 289 ) { 290 (None, None) => CompletionStatus::None, 291 (Some(_), None) => CompletionStatus::PartOne, 292 (Some(_), Some(_)) => CompletionStatus::Both, 293 _ => panic!( 294 "This should never happen as in part one shouldn't be not done but 2 is" 295 ), 296 }, 297 }) 298 } 299 } 300 } 301 302 ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does 303 async fn check_part_one( 304 &self, 305 did: String, 306 verification_code: Option<String>, 307 ) -> Result<ChallengeCheckResponse, AdventError>; 308 309 ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does 310 /// part two does have a hard error if its called and there is not a part 2 311 async fn check_part_two( 312 &self, 313 _did: String, 314 _verification_code: Option<String>, 315 ) -> Result<ChallengeCheckResponse, AdventError> { 316 unimplemented!("Second day challenges are optional") 317 } 318} 319 320pub fn get_random_token() -> String { 321 let mut rng = rand::rng(); 322 323 let full_code = Alphanumeric.sample_string(&mut rng, 10); 324 325 let slice_one = &full_code[0..5].to_ascii_uppercase(); 326 let slice_two = &full_code[5..10].to_ascii_uppercase(); 327 format!("{slice_one}-{slice_two}") 328} 329 330fn get_markdown_options() -> Options { 331 Options { 332 parse: Default::default(), 333 compile: CompileOptions { 334 //Setting this to allow HTML in the markdown. So pleas be careful what you put in there 335 allow_dangerous_html: true, 336 ..Default::default() 337 }, 338 } 339} 340 341/// Get completion status for all 25 days at once 342/// Returns a Vec of tuples (day_number, CompletionStatus) for days 1-25 343pub async fn get_all_days_completion_status( 344 pool: &PgPool, 345 did: Option<&String>, 346) -> Result<Vec<(u8, CompletionStatus)>, AdventError> { 347 // If no user is logged in, return None for all days 348 let did = match did { 349 Some(d) => d, 350 None => { 351 return Ok((1..=25).map(|day| (day, CompletionStatus::None)).collect()); 352 } 353 }; 354 355 // Query all challenge progress for this user in a single query 356 let results = sqlx::query!( 357 "SELECT day, time_challenge_one_completed, time_challenge_two_completed 358 FROM challenges 359 WHERE user_did = $1 AND day BETWEEN 1 AND 25 360 ORDER BY day", 361 did 362 ) 363 .fetch_all(pool) 364 .await?; 365 366 // Create a map of day -> completion status 367 let mut status_map: std::collections::HashMap<u8, CompletionStatus> = 368 std::collections::HashMap::new(); 369 370 for row in results { 371 let day = row.day as u8; 372 let status = match ( 373 row.time_challenge_one_completed, 374 row.time_challenge_two_completed, 375 ) { 376 (None, None) => CompletionStatus::None, 377 (Some(_), None) => CompletionStatus::PartOne, 378 (Some(_), Some(_)) => CompletionStatus::Both, 379 _ => CompletionStatus::None, 380 }; 381 status_map.insert(day, status); 382 } 383 384 // Build the result vec for all 25 days 385 let result: Vec<(u8, CompletionStatus)> = (1..=25) 386 .map(|day| { 387 let status = status_map 388 .get(&day) 389 .copied() 390 .unwrap_or(CompletionStatus::None); 391 (day, status) 392 }) 393 .collect(); 394 395 Ok(result) 396}