feat: add meals registrations

This commit is contained in:
Nathan Lamy 2025-08-25 19:12:52 +02:00
parent 36e10cf8d2
commit 1b73fe2cbb
6 changed files with 401 additions and 5 deletions

16
README.md Normal file
View file

@ -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.
<!-- TODO: Add documentation for used Redis keys -->

View file

@ -70,6 +70,74 @@ pub async fn post_upcoming_colles(
Ok(())
}
pub async fn post_submittable_meals(
meals: &[Meal],
config: &Settings,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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"
}

View file

@ -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.

View file

@ -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;

161
src/parser/repas.rs Normal file
View file

@ -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<Vec<Meal>, Box<dyn std::error::Error>> {
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::<Vec<_>>()
.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<Meal> {
// 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<dyn std::error::Error>> {
let url = "https://bjcolle.fr/no_waste.php";
let client = Client::new();
let mut form: HashMap<String, String> = 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(())
}

View file

@ -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<NaiveDate, Box<dyn Error>>
// 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<NaiveDate, Box<dyn Error>>
.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")?;