From 1b73fe2cbbbae37c59c205867fa70d48fe4bafb9 Mon Sep 17 00:00:00 2001 From: Nathan Lamy Date: Mon, 25 Aug 2025 19:12:52 +0200 Subject: [PATCH] feat: add meals registrations --- README.md | 16 +++++ src/api.rs | 74 ++++++++++++++++++++ src/main.rs | 139 +++++++++++++++++++++++++++++++++++++- src/parser/mod.rs | 6 ++ src/parser/repas.rs | 161 ++++++++++++++++++++++++++++++++++++++++++++ src/parser/utils.rs | 10 ++- 6 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 README.md create mode 100644 src/parser/repas.rs diff --git a/README.md b/README.md new file mode 100644 index 0000000..b38833e --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Khollisé Worker + +## Job Types + +| job_id | description | +|--------|------------------------------------------------------------------| +| 0 | Fetch ONE colle (with class_name, colle_id and colle_secret) | +| 1 | Fetch class colles (with class_name and optionally go to date) | +| 2 | Fetch upcoming colles (with class_name) | +| 3 | Fetch sumbittable meals | +| 4 | Submit meals for user (must include bj_username and bj_password) | +| 5 | Test authentication (must include bj_username and bj_password) | + +Actually, all jobs require `class_name` to be specified. + + \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 0c089e2..e398296 100644 --- a/src/api.rs +++ b/src/api.rs @@ -70,6 +70,74 @@ pub async fn post_upcoming_colles( Ok(()) } +pub async fn post_submittable_meals( + meals: &[Meal], + config: &Settings, +) -> Result<(), Box> { + let api_url = get_config(config, "api"); + let api_token = get_config(config, "token"); + + let url = format!("{api_url}/meals"); + let meals_json = json!({ + "meals": &meals + }); + println!("Posting submittable meals: {:?}", meals_json); + + let response = Client::new() + .post(&url) + .json(&meals_json) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + // Bearer token for authentication + .header("Authorization", format!("Bearer {api_token}")) + .send() + .await?; + // Check if the response is successful + if !response.status().is_success() { + eprintln!( + "Failed to post submittable meals: HTTP {}", + response.status(), + ); + eprint!("Response: {:?}", response.text().await?); + return Err("Failed to post submittable meals".into()); + } + + Ok(()) +} + +pub async fn post_selected_meals( + meals: &[Meal], + user_id: &str, + config: &Settings, +) -> Result<(), Box> { + let api_url = get_config(config, "api"); + let api_token = get_config(config, "token"); + + let url = format!("{api_url}/internals/meals-registrations"); + let meals_json = json!({ + "meals": &meals, + "userId": user_id, + }); + + let response = Client::new() + .post(&url) + .json(&meals_json) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + // Bearer token for authentication + .header("Authorization", format!("Bearer {api_token}")) + .send() + .await?; + // Check if the response is successful + if !response.status().is_success() { + eprintln!("Failed to post selected meals: HTTP {}", response.status(),); + eprint!("Response: {:?}", response.text().await?); + return Err("Failed to post selected meals".into()); + } + + Ok(()) +} + #[derive(Debug, serde::Serialize)] pub struct Colle { pub date: NaiveDateTime, @@ -90,3 +158,9 @@ pub struct ColleAttachment { pub url: String, pub name: String, } + +#[derive(Debug, serde::Serialize)] +pub struct Meal { + pub date: String, // "YYYY-MM-DD" + pub meal_type: String, // "lunch" or "dinner" +} diff --git a/src/main.rs b/src/main.rs index eec1e99..d59d733 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ use std::{str::FromStr, sync::Arc}; use crate::{ - api::{post_colle, post_upcoming_colles}, + api::{Meal, post_colle, post_selected_meals, post_submittable_meals, post_upcoming_colles}, configuration::{get_config, get_cron, list_classes, load_config}, - parser::{authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles}, + parser::{ + authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles, get_available_meals, + login, request_session, + }, }; use chrono::{DateTime, Utc}; use cron::Schedule; @@ -179,6 +182,111 @@ async fn process_job( eprintln!("Failed to fetch colle {}: {}", colle_id, e); } } + } else if job["type"] == 4 || job["type"] == 5 { + let user_id = job["user_id"].as_str().unwrap_or_default(); + println!("Submitting meals for user: {}", user_id); + + // Authenticate (job data contains password and username) + let username = job["bj_username"].as_str().unwrap_or_default(); + let password = job["bj_password"].as_str().unwrap_or_default(); + if username.is_empty() || password.is_empty() { + eprintln!("Job does not contain username or password."); + return Err(redis::RedisError::from(( + redis::ErrorKind::InvalidClientConfig, + "Job missing username or password", + ))); + } + + let session = request_session().await; + if let Err(err) = session { + eprintln!("Failed to request session: {}", err); + return Err(redis::RedisError::from(( + redis::ErrorKind::AuthenticationFailed, + "Session request failed", + ))); + } + let session_id = session.unwrap(); + + let login_result = login(&username, &password, &session_id).await; + if let Err(err) = login_result { + if job["type"] == 5 { + // Set auth failed in Redis + let auth_key = format!("auth_success_{}", user_id); + con.set_ex(&auth_key, false, 300)?; + } + eprintln!("Failed to login: {}", err); + return Err(redis::RedisError::from(( + redis::ErrorKind::AuthenticationFailed, + "Login failed", + ))); + } + + if job["type"] == 4 { + // Fetch already selected meals + let selected_meals = get_available_meals(&session_id, true).await; + if let Err(err) = selected_meals { + eprintln!("Failed to fetch available meals: {}", err); + return Err(redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "Failed to fetch available meals", + ))); + } + let selected_meals = selected_meals.unwrap(); + + // Add job["meal"] (ONLY ONE MEAL) to selected meals + let mut meals_parsed = selected_meals; + let meal_to_add = Meal { + date: job["meal"]["date"].as_str().unwrap_or_default().to_string(), + meal_type: job["meal"]["meal_type"] + .as_str() + .unwrap_or("dinner") + .to_string(), + }; + if !meals_parsed + .iter() + .any(|m| m.date == meal_to_add.date && m.meal_type == meal_to_add.meal_type) + { + meals_parsed.push(meal_to_add); + + // Submit meals + let res = parser::submit_meals(&meals_parsed, &session_id).await; + if res.is_err() { + eprintln!("Failed to submit meals: {}", res.unwrap_err()); + return Err(redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "Failed to submit meals", + ))); + } + println!("Meals submitted successfully."); + } else { + println!("Meal already selected, skipping addition."); + } + + let meals = get_available_meals(&session_id, true).await; + if meals.is_err() { + eprintln!("Failed to fetch available meals: {}", meals.unwrap_err()); + return Err(redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "Failed to fetch available meals", + ))); + } + let meals = meals.unwrap(); + + // Post selected meals to API + let res = post_selected_meals(&meals, user_id, config).await; + if res.is_err() { + eprintln!("Failed to post selected meals: {}", res.unwrap_err()); + return Err(redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "Failed to post selected meals", + ))); + } + println!("Posted {} selected meals successfully.", meals.len()); + } else if job["type"] == 5 { + // Set auth successful in Redis + let auth_key = format!("auth_success_{}", user_id); + con.set_ex(&auth_key, true, 300)?; + } } else { // Authenticate let session = authenticate(class_name, &mut *con, config).await; @@ -256,6 +364,33 @@ async fn process_job( ))); } println!("Posted upcoming colles successfully."); + } else if job["type"] == 3 { + println!("Fetching available meals..."); + + /* + * Fetch available meals + */ + match get_available_meals(&session, false).await { + Ok(meals) => { + if meals.is_empty() { + println!("No available meals found."); + return Ok(()); + } + // Post available meals to API + let res = post_submittable_meals(&meals, config).await; + if res.is_err() { + eprintln!("Failed to post submittable meals: {}", res.unwrap_err()); + return Err(redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "Failed to post submittable meals", + ))); + } + println!("Posted {} submittable meals successfully.", meals.len()); + } + Err(e) => { + eprintln!("Failed to fetch available meals: {}", e); + } + } /* * Handle unknown job types * This is a catch-all for any job types that are not recognized. diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 771752d..b17a28b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,9 +1,15 @@ mod auth; mod utils; mod colles; +mod repas; pub use colles::fetch_class_colles; pub use colles::fetch_colle; pub use colles::fetch_upcoming_colles; pub use auth::authenticate; +pub use auth::login; +pub use auth::request_session; + +pub use repas::get_available_meals; +pub use repas::submit_meals; diff --git a/src/parser/repas.rs b/src/parser/repas.rs new file mode 100644 index 0000000..aa69b47 --- /dev/null +++ b/src/parser/repas.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; + +use chrono::{Datelike, NaiveDate}; +use reqwest::Client; +use scraper::{Html, Selector}; + +use crate::{api::Meal, parser::utils::parse_french_date}; + +pub async fn get_available_meals( + session: &str, + filter_selected: bool, +) -> Result, Box> { + let url = "https://bjcolle.fr/no_waste.php"; + let response = Client::new() + .get(url) + .header("Cookie", session) + .send() + .await? + .text() + .await?; + + let document = Html::parse_document(&response); + + // Select each table row + let row_selector = Selector::parse("#Choix > table > tbody > tr").unwrap(); + let label_selector = Selector::parse("label").unwrap(); + let input_selector = Selector::parse("input[type=\"checkbox\"]").unwrap(); + + let mut meals = Vec::new(); + + for row in document.select(&row_selector) { + // Extract the label text (meal name) + if let Some(label) = row.select(&label_selector).next() { + let meal_name = label + .text() + .collect::>() + .join(" ") + .trim() + .to_string(); + + // Extract the input checkbox + if let Some(input) = row.select(&input_selector).next() { + let is_checked = input.value().attr("checked").is_some(); + + // Apply filtering: if filter_selected = true, skip unchecked + if filter_selected && !is_checked { + continue; + } + + if !meal_name.is_empty() { + if let Some(meal) = parse_meal(&meal_name) { + meals.push(meal); + } + } + } + } + } + + Ok(meals) +} + +pub fn parse_meal(name: &str) -> Option { + // Normalize input + let trimmed = name.trim(); + + // Detect meal type + let meal_type = if trimmed.starts_with("Déjeuner") { + "lunch" + } else if trimmed.starts_with("Dîner") { + "dinner" + } else { + return None; // Unknown meal type + } + .to_string(); + + // Extract date string (after "du ") and format it YYYY-MM-DD + let date_part = trimmed.splitn(2, "du ").nth(1)?; + let date = parse_french_date(date_part).unwrap(); + let date = date.format("%Y-%m-%d").to_string(); + + Some(Meal { date, meal_type }) +} + +// Map chrono weekday to French first letter +fn french_day_letter(date: NaiveDate) -> char { + match date.weekday() { + chrono::Weekday::Mon => 'l', // Lundi + chrono::Weekday::Tue => 'm', // Mardi + chrono::Weekday::Wed => 'm', // Mercredi + chrono::Weekday::Thu => 'j', // Jeudi + chrono::Weekday::Fri => 'v', // Vendredi + chrono::Weekday::Sat => 's', // Samedi + chrono::Weekday::Sun => 'd', // Dimanche + } +} + +// Map chrono month to French month name +fn french_month(month: u32) -> &'static str { + match month { + 1 => "janvier", + 2 => "février", + 3 => "mars", + 4 => "avril", + 5 => "mai", + 6 => "juin", + 7 => "juillet", + 8 => "août", + 9 => "septembre", + 10 => "octobre", + 11 => "novembre", + 12 => "décembre", + _ => "", + } +} + +pub async fn submit_meals(meals: &[Meal], session: &str) -> Result<(), Box> { + let url = "https://bjcolle.fr/no_waste.php"; + let client = Client::new(); + + let mut form: HashMap = HashMap::new(); + + // Mandatory field + form.insert( + "VALIDER_CHOIX".to_string(), + "Valider+(même+si+rien+n'est+coché)".to_string(), + ); + + for meal in meals { + let meal_date = NaiveDate::parse_from_str(&meal.date, "%Y-%m-%d")?; + let day_letter = french_day_letter(meal_date); + + let key = match meal.meal_type.as_str() { + "lunch" => format!("{}m", day_letter), // midi + "dinner" => format!("{}s", day_letter), // soir + other => { + eprintln!("Unknown meal_type: {}", other); + continue; + } + }; + + // Format like "30+août" + let value = format!("{}+{}", meal_date.day(), french_month(meal_date.month())); + + form.insert(key, value); + } + + let res = client + .post(url) + .header("Cookie", session) + .form(&form) + .send() + .await?; + + if !res.status().is_success() { + eprintln!("Failed to submit meals: HTTP {}", res.status()); + eprint!("Response: {:?}", res.text().await?); + return Err("Failed to submit meals".into()); + } + + Ok(()) +} diff --git a/src/parser/utils.rs b/src/parser/utils.rs index 2343d62..a85ecbd 100644 --- a/src/parser/utils.rs +++ b/src/parser/utils.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::error::Error; @@ -24,7 +24,7 @@ pub fn parse_french_date(french_date: &str) -> Result> // Split by spaces let parts: Vec<&str> = french_date.split_whitespace().collect(); - if parts.len() < 4 { + if parts.len() < 3 { return Err("Date string too short".into()); } @@ -42,7 +42,11 @@ pub fn parse_french_date(french_date: &str) -> Result> .ok_or_else(|| format!("Unknown month: {}", parts[2]))?; // Year - let year: i32 = parts[3].parse()?; + let year = if let Some(s) = parts.get(3) { + s.parse()? + } else { + chrono::Local::now().year() + }; let date = NaiveDate::from_ymd_opt(year, *month, day).ok_or("Invalid date components")?;