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}