kasl/api/si.rs
1//! Internal SiServer API client for company reporting and calendar integration.
2//!
3//! Provides integration with an internal company API system that handles employee
4//! time tracking reports and company calendar information.
5//!
6//! ## Features
7//!
8//! - **Report Submission**: Submit daily and monthly time tracking reports
9//! - **Calendar Integration**: Fetch company rest dates and holidays
10//! - **Two-Stage Authentication**: LDAP authentication followed by session token exchange
11//! - **Error Resilience**: Graceful handling of network failures and API errors
12//! - **Session Management**: Automatic session caching and renewal
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use kasl::api::{Si, SiConfig};
18//! use chrono::Local;
19//!
20//! let config = SiConfig {
21//! login: "username".to_string(),
22//! auth_url: "https://auth.company.com".to_string(),
23//! api_url: "https://api.company.com".to_string(),
24//! };
25//!
26//! let mut si = Si::new(&config);
27//! let today = Local::now().date_naive();
28//! let rest_dates = si.rest_dates(today).await?;
29//! ```
30
31use crate::{
32 api::Session,
33 libs::{config::ConfigModule, messages::Message, secret::Secret},
34 msg_error, msg_print,
35};
36use anyhow::Result;
37use base64::prelude::*;
38use chrono::{Datelike, Duration, NaiveDate, Weekday};
39use dialoguer::{theme::ColorfulTheme, Input};
40use reqwest::{
41 header::{self, HeaderMap, HeaderValue, COOKIE},
42 multipart, Client, StatusCode,
43};
44use serde::{Deserialize, Serialize};
45use std::collections::HashSet;
46
47/// Maximum number of authentication retries before giving up.
48/// SiServer has more complex auth flow, so we use the same conservative limit.
49const MAX_RETRY_COUNT: i32 = 3;
50
51/// Cookie name prefix used by SiServer for session identification.
52const COOKIE_KEY: &str = "PORTALSESSID=";
53
54/// Filename for storing SiServer session tokens in the user data directory.
55const SESSION_ID_FILE: &str = ".si_session_id";
56
57/// Filename for storing encrypted SiServer credentials for password caching.
58const SECRET_FILE: &str = ".si_secret";
59
60/// SiServer API endpoint for LDAP authentication (first stage).
61const AUTH_URL: &str = "auth/ldap";
62
63/// SiServer API endpoint for token-to-session exchange (second stage).
64const LOGIN_URL: &str = "auth/login-by-token";
65
66/// SiServer API endpoint for submitting daily time reports.
67const REPORT_URL: &str = "report-card/send-daily-report";
68
69/// SiServer API endpoint for submitting monthly summary reports.
70const MONTHLY_REPORT_URL: &str = "report-card/send-monthly-report";
71
72/// SiServer API endpoint for fetching company rest dates and holidays.
73const REST_DATES_URL: &str = "report-card/get-rest-dates";
74
75/// User credentials for SiServer authentication.
76///
77/// SiServer requires special password encoding (double base64) for security.
78/// Credentials are only held in memory during the authentication process.
79#[derive(Serialize, Clone, Debug)]
80pub struct LoginCredentials {
81 /// Username for LDAP authentication
82 login: String,
83 /// Double base64-encoded password for enhanced security
84 password: String,
85}
86
87/// Response structure for SiServer LDAP authentication.
88///
89/// The first stage of authentication returns a temporary token that must
90/// be exchanged for a session cookie in the second stage.
91#[derive(Deserialize)]
92pub struct AuthSession {
93 /// Payload containing the authentication token
94 payload: AuthPayload,
95}
96
97/// Authentication payload containing the temporary token.
98///
99/// This token is used to authenticate the second stage of the login process
100/// where it's exchanged for a session cookie.
101#[derive(Deserialize)]
102pub struct AuthPayload {
103 /// Temporary authentication token for session exchange
104 token: String,
105}
106
107/// Response structure for SiServer rest dates API.
108///
109/// SiServer provides company calendar information including various types
110/// of non-working days such as holidays, vacation days, and weekend days.
111/// Different date arrays represent different types of rest periods.
112#[derive(Debug, Deserialize)]
113pub struct RestDatesResponse {
114 /// Regular rest dates (general holidays)
115 dates: Vec<String>,
116 /// Vacation dates (company-specific holidays)
117 v_dates: Vec<String>,
118 /// Weekend dates (extended weekend periods)
119 w_dates: Vec<String>,
120}
121
122impl RestDatesResponse {
123 /// Parses and combines all rest dates into a single unified set.
124 ///
125 /// This method processes all three categories of rest dates and combines them
126 /// into a single `HashSet` for easy lookup operations. Duplicate dates across
127 /// categories are automatically deduplicated.
128 ///
129 /// ## Date Format Handling
130 ///
131 /// The API returns dates in "YYYY-MM-DD" format. Invalid date strings are
132 /// silently ignored to handle potential API inconsistencies gracefully.
133 ///
134 /// # Returns
135 ///
136 /// * `Result<HashSet<NaiveDate>>` - Unified set of all rest dates
137 ///
138 /// # Errors
139 ///
140 /// Currently cannot fail, but returns `Result` for future error handling
141 /// such as date validation or API response verification.
142 pub fn unique_dates(&self) -> Result<HashSet<NaiveDate>> {
143 let mut date_set = HashSet::new();
144
145 // Process all three date categories
146 self.process_dates(&self.dates, &mut date_set)?;
147 self.process_dates(&self.v_dates, &mut date_set)?;
148 self.process_dates(&self.w_dates, &mut date_set)?;
149
150 Ok(date_set)
151 }
152
153 /// Helper function to parse date strings and add them to the result set.
154 ///
155 /// Processes a vector of date strings, attempting to parse each one into
156 /// a `NaiveDate`. Invalid dates are silently skipped to handle API
157 /// inconsistencies without failing the entire operation.
158 ///
159 /// # Arguments
160 ///
161 /// * `dates` - Vector of date strings in "YYYY-MM-DD" format
162 /// * `date_set` - Mutable reference to the result set for adding parsed dates
163 ///
164 /// # Returns
165 ///
166 /// Always returns `Ok(())` as this operation cannot fail.
167 fn process_dates(&self, dates: &Vec<String>, date_set: &mut HashSet<NaiveDate>) -> Result<()> {
168 dates
169 .iter()
170 .filter_map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
171 .for_each(|date| {
172 date_set.insert(date);
173 });
174 Ok(())
175 }
176}
177
178/// SiServer API client with advanced session management.
179///
180/// This client handles the complex two-stage authentication flow required by
181/// SiServer and provides methods for report submission and calendar data retrieval.
182/// It implements resilient error handling to ensure application stability.
183///
184/// ## Thread Safety
185///
186/// The client is not thread-safe due to mutable retry state. Each thread
187/// should use its own client instance for concurrent operations.
188///
189/// ## Authentication Architecture
190///
191/// SiServer uses a sophisticated authentication system:
192/// 1. **LDAP Stage**: Credentials sent to LDAP endpoint, token received
193/// 2. **Session Stage**: Token sent to session endpoint, cookie received
194/// 3. **API Usage**: Cookie included in all subsequent API requests
195///
196/// This design provides enhanced security but requires careful session management.
197#[derive(Debug)]
198pub struct Si {
199 /// HTTP client for making API requests with connection pooling
200 client: Client,
201 /// Configuration containing API endpoints and user information
202 config: SiConfig,
203 /// In-memory storage for authentication credentials during auth process
204 credentials: Option<LoginCredentials>,
205 /// Counter for tracking authentication retry attempts
206 retries: i32,
207}
208
209impl Session for Si {
210 /// Performs two-stage authentication with SiServer.
211 ///
212 /// This method implements SiServer's unique authentication flow which requires
213 /// two separate API calls to establish a session. The process is more complex
214 /// than standard session authentication but provides enhanced security.
215 ///
216 /// ## Authentication Process
217 ///
218 /// 1. **LDAP Authentication**: Send credentials to LDAP endpoint
219 /// 2. **Token Extraction**: Parse authentication token from response
220 /// 3. **Session Exchange**: Send token to session endpoint with Bearer auth
221 /// 4. **Cookie Extraction**: Parse session cookie from Set-Cookie header
222 /// 5. **Format Preparation**: Extract session ID for use in subsequent requests
223 ///
224 /// ## Error Scenarios
225 ///
226 /// - LDAP authentication failure (invalid credentials)
227 /// - Token parsing failure (unexpected response format)
228 /// - Session exchange failure (token expired or invalid)
229 /// - Cookie extraction failure (missing or malformed Set-Cookie header)
230 ///
231 /// # Returns
232 ///
233 /// Returns the session ID string extracted from the PORTALSESSID cookie.
234 ///
235 /// # Errors
236 ///
237 /// Returns an error if:
238 /// - No credentials have been set (programming error)
239 /// - Network requests fail
240 /// - LDAP authentication fails
241 /// - Token or cookie parsing fails
242 /// - Authentication flow completes but no valid session is established
243 async fn login(&self) -> Result<String> {
244 // Ensure credentials are available for authentication
245 let credentials = self.credentials.clone().expect("Credentials not set!");
246
247 // Stage 1: LDAP Authentication
248 let auth_url = format!("{}/{}", self.config.auth_url, AUTH_URL);
249 let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
250 let auth_body = auth_res.text().await?;
251 let auth_session: AuthSession = serde_json::from_str(&auth_body)?;
252
253 // Stage 2: Token-to-Session Exchange
254 let login_url = format!("{}/{}", self.config.api_url, LOGIN_URL);
255 let login_res = self
256 .client
257 .post(login_url)
258 .header(header::AUTHORIZATION, format!("Bearer {}", auth_session.payload.token))
259 .send()
260 .await?;
261
262 // Stage 3: Cookie Extraction
263 if let Some(cookie) = login_res.headers().get("Set-Cookie") {
264 if let Ok(cookie_val) = cookie.to_str() {
265 // Find the PORTALSESSID cookie in the Set-Cookie header
266 if let Some(portalsessid) = cookie_val.split(";").find(|c| c.starts_with(COOKIE_KEY)) {
267 let session_id = portalsessid.trim_start_matches(COOKIE_KEY);
268 return Ok(session_id.to_string());
269 }
270 }
271 }
272
273 // Authentication completed but no valid session cookie was found
274 anyhow::bail!("Login failed")
275 }
276
277 /// Sets user credentials with SiServer-specific password encoding.
278 ///
279 /// SiServer requires passwords to be double base64-encoded for security.
280 /// This method handles the encoding and stores credentials in memory for
281 /// use during the authentication process.
282 ///
283 /// ## Password Encoding
284 ///
285 /// The password undergoes double base64 encoding:
286 /// 1. First encoding: `base64(password)`
287 /// 2. Second encoding: `base64(base64(password))`
288 ///
289 /// This provides additional security layers for credential transmission.
290 ///
291 /// # Arguments
292 ///
293 /// * `password` - The user's SiServer password in plain text
294 ///
295 /// # Returns
296 ///
297 /// Always returns `Ok(())` as encoding cannot fail.
298 fn set_credentials(&mut self, password: &str) -> Result<()> {
299 // Apply double base64 encoding as required by SiServer
300 let encoded_password = BASE64_STANDARD.encode(BASE64_STANDARD.encode(password));
301
302 self.credentials = Some(LoginCredentials {
303 login: self.config.login.to_string(),
304 password: encoded_password,
305 });
306 Ok(())
307 }
308
309 /// Returns the filename for storing SiServer session tokens.
310 ///
311 /// The session file is stored in the user's application data directory
312 /// and contains the cached session token for automatic login restoration.
313 fn session_id_file(&self) -> &str {
314 SESSION_ID_FILE
315 }
316
317 /// Returns a configured Secret instance for secure password prompting.
318 ///
319 /// The Secret manager handles secure password input with hidden characters
320 /// and optional encrypted caching in the user's data directory.
321 ///
322 /// # Returns
323 ///
324 /// A configured `Secret` instance with SiServer-specific prompts and file names.
325 fn secret(&self) -> Secret {
326 Secret::new(SECRET_FILE, "Enter your SiServer password")
327 }
328
329 /// Returns the current authentication retry count.
330 ///
331 /// Used by the session management system to track failed authentication
332 /// attempts and implement retry limits.
333 fn retry(&self) -> i32 {
334 self.retries
335 }
336
337 /// Increments the authentication retry counter.
338 ///
339 /// Called after each failed authentication attempt to track progress
340 /// toward the maximum retry limit.
341 fn inc_retry(&mut self) {
342 self.retries += 1;
343 }
344
345 /// Resets the authentication retry counter to zero.
346 ///
347 /// Called after successful authentication to ensure future session
348 /// requests start with a clean slate.
349 fn reset_retry(&mut self) {
350 self.retries = 0;
351 }
352}
353
354impl Si {
355 /// Creates a new SiServer API client instance.
356 ///
357 /// Initializes the HTTP client with default settings suitable for SiServer API
358 /// interactions. The client is configured for both JSON and multipart requests
359 /// to handle different SiServer endpoints appropriately.
360 ///
361 /// # Arguments
362 ///
363 /// * `config` - SiServer configuration containing API endpoints and user information
364 ///
365 /// # Examples
366 ///
367 /// ```rust,no_run
368 /// use kasl::api::{Si, SiConfig};
369 ///
370 /// let config = SiConfig {
371 /// login: "username".to_string(),
372 /// auth_url: "https://auth.company.com".to_string(),
373 /// api_url: "https://api.company.com".to_string(),
374 /// };
375 /// let si = Si::new(&config);
376 /// ```
377 pub fn new(config: &SiConfig) -> Self {
378 Self {
379 client: Client::new(),
380 config: config.clone(),
381 credentials: None,
382 retries: 0,
383 }
384 }
385
386 /// Submits a daily time tracking report to SiServer.
387 ///
388 /// This method sends formatted daily report data to the SiServer API for
389 /// payroll and time tracking integration. It handles session management
390 /// and implements retry logic for authentication failures.
391 ///
392 /// ## Report Format
393 ///
394 /// The report data should be a JSON string containing:
395 /// - Work hours and break information
396 /// - Task completion details
397 /// - Productivity metrics
398 /// - Any relevant metadata for the specified date
399 ///
400 /// ## Session Management
401 ///
402 /// The method implements automatic session handling:
403 /// 1. **Session Retrieval**: Get or create a valid session token
404 /// 2. **Report Submission**: Send report data with session authentication
405 /// 3. **Error Handling**: Detect expired sessions and retry with re-authentication
406 /// 4. **Status Return**: Return HTTP status for caller handling
407 ///
408 /// # Arguments
409 ///
410 /// * `data` - JSON string containing the formatted report data
411 /// * `date` - The date for which the report is being submitted
412 ///
413 /// # Returns
414 ///
415 /// Returns the HTTP status code from the API response, allowing callers
416 /// to determine success or specific failure modes.
417 ///
418 /// # Errors
419 ///
420 /// Returns an error if:
421 /// - Session management fails persistently
422 /// - Network request fails
423 /// - Request formatting fails
424 /// - Duration conversion fails (internal error)
425 ///
426 /// # Examples
427 ///
428 /// ```rust,no_run
429 /// # use kasl::api::{Si, SiConfig};
430 /// # use chrono::Local;
431 /// # use anyhow::Result;
432 /// # async fn example() -> Result<()> {
433 /// let mut si = Si::new(&config);
434 /// let report_data = r#"{"hours": 8, "tasks": 5}"#.to_string();
435 /// let today = Local::now().date_naive();
436 ///
437 /// let status = si.send(&report_data, &today).await?;
438 /// if status.is_success() {
439 /// println!("Report submitted successfully");
440 /// }
441 /// # Ok(())
442 /// # }
443 /// ```
444 pub async fn send(&mut self, data: &String, date: &NaiveDate) -> Result<StatusCode> {
445 let mut local_retries = 0;
446 loop {
447 // Get valid session for API request
448 let session_id = self.get_session_id().await?;
449 let url = format!("{}/{}", self.config.api_url, REPORT_URL);
450 let date = date.format("%Y-%m-%d").to_string();
451
452 // Prepare multipart form data for submission
453 let form = multipart::Form::new()
454 .text("date", date)
455 .text("tasks", data.clone())
456 .text("comment", "")
457 .text("day_type", "1")
458 .text("duty", "0")
459 .text("only_save", "0");
460
461 // Set up authentication headers
462 let mut headers = HeaderMap::new();
463 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
464
465 // Submit the report
466 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
467 Ok(response) => response,
468 Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
469 };
470
471 // Handle response and potential session expiration
472 match res.status() {
473 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
474 // Session expired - clear cache and retry
475 self.delete_session_id()?;
476 tokio::time::sleep(Duration::seconds(1).to_std()?).await;
477 local_retries += 1;
478 continue;
479 }
480 _ => return Ok(res.status()),
481 }
482 }
483 }
484
485 /// Submits a monthly summary report to SiServer.
486 ///
487 /// Sends aggregated monthly statistics to the SiServer API for organizational
488 /// reporting and payroll integration. The report covers the entire month
489 /// containing the specified date.
490 ///
491 /// ## Monthly Report Contents
492 ///
493 /// The system automatically generates a summary containing:
494 /// - Total working hours for the month
495 /// - Number of working days
496 /// - Average daily productivity
497 /// - Compliance with company policies
498 ///
499 /// ## Last Working Day Logic
500 ///
501 /// Monthly reports are typically submitted on the last working day of each month.
502 /// The system can automatically detect this condition and prompt for submission.
503 ///
504 /// # Arguments
505 ///
506 /// * `date` - Any date within the target month for report generation
507 ///
508 /// # Returns
509 ///
510 /// Returns the HTTP status code from the API response.
511 ///
512 /// # Examples
513 ///
514 /// ```rust,no_run
515 /// # use kasl::api::{Si, SiConfig};
516 /// # use chrono::Local;
517 /// # use anyhow::Result;
518 /// # async fn example() -> Result<()> {
519 /// let mut si = Si::new(&config);
520 /// let today = Local::now().date_naive();
521 ///
522 /// if si.is_last_working_day_of_month(&today)? {
523 /// let status = si.send_monthly(&today).await?;
524 /// if status.is_success() {
525 /// println!("Monthly report submitted");
526 /// }
527 /// }
528 /// # Ok(())
529 /// # }
530 /// ```
531 pub async fn send_monthly(&mut self, date: &NaiveDate) -> Result<StatusCode> {
532 let mut local_retries = 0;
533 loop {
534 // Get valid session for API request
535 let session_id = self.get_session_id().await?;
536 let url = format!("{}/{}", self.config.api_url, MONTHLY_REPORT_URL);
537 let (year, month) = (date.year(), date.month());
538
539 // Prepare monthly report form data
540 let form = multipart::Form::new().text("month", month.to_string()).text("year", year.to_string());
541
542 // Set up authentication headers
543 let mut headers = HeaderMap::new();
544 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
545
546 // Submit the monthly report
547 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
548 Ok(response) => response,
549 Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
550 };
551
552 // Handle response and potential session expiration
553 match res.status() {
554 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
555 // Session expired - clear cache and retry
556 self.delete_session_id()?;
557 tokio::time::sleep(Duration::seconds(1).to_std()?).await;
558 local_retries += 1;
559 continue;
560 }
561 _ => return Ok(res.status()),
562 }
563 }
564 }
565
566 /// Fetches company rest dates and holidays for the specified year.
567 ///
568 /// This method retrieves the official company calendar including holidays,
569 /// vacation days, and extended weekend periods. The data is used for accurate
570 /// productivity calculations and report generation.
571 ///
572 /// ## Error Resilience
573 ///
574 /// This function prioritizes application stability over data completeness:
575 /// - Network errors return empty results rather than failing
576 /// - Authentication failures are logged but don't interrupt operation
577 /// - API parsing errors result in empty calendar (graceful degradation)
578 /// - Session failures are handled with automatic retry
579 ///
580 /// This design ensures that calendar integration enhances functionality
581 /// without breaking core time tracking features when services are unavailable.
582 ///
583 /// ## Date Processing
584 ///
585 /// The API returns three categories of rest dates:
586 /// - Regular holidays (national and company holidays)
587 /// - Vacation dates (company-specific rest periods)
588 /// - Weekend extensions (long weekend periods)
589 ///
590 /// All categories are combined into a single set for unified processing.
591 ///
592 /// # Arguments
593 ///
594 /// * `year` - Any date within the target year for calendar retrieval
595 ///
596 /// # Returns
597 ///
598 /// Returns a `HashSet<NaiveDate>` containing all rest dates for the year.
599 /// Returns an empty set on any error to ensure graceful degradation.
600 ///
601 /// # Examples
602 ///
603 /// ```rust,no_run
604 /// # use kasl::api::{Si, SiConfig};
605 /// # use chrono::Local;
606 /// # use anyhow::Result;
607 /// # async fn example() -> Result<()> {
608 /// let mut si = Si::new(&config);
609 /// let this_year = Local::now().date_naive();
610 ///
611 /// let rest_dates = si.rest_dates(this_year).await?;
612 /// println!("Found {} rest dates this year", rest_dates.len());
613 ///
614 /// // Check if a specific date is a rest day
615 /// let today = Local::now().date_naive();
616 /// if rest_dates.contains(&today) {
617 /// println!("Today is a company rest day");
618 /// }
619 /// # Ok(())
620 /// # }
621 /// ```
622 pub async fn rest_dates(&mut self, year: NaiveDate) -> Result<HashSet<NaiveDate>> {
623 let mut local_retries = 0;
624 loop {
625 // Get valid session for API request
626 let session_id = match self.get_session_id().await {
627 Ok(id) => id,
628 Err(e) => {
629 msg_error!(Message::SiServerSessionFailed(e.to_string()));
630 return Ok(HashSet::new()); // Return empty set on session failure
631 }
632 };
633
634 // Prepare rest dates request
635 let url = format!("{}/{}", self.config.api_url, REST_DATES_URL);
636 let form = multipart::Form::new().text("year", year.format("%Y").to_string());
637 let mut headers = HeaderMap::new();
638 headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
639
640 // Request rest dates from API
641 let res = match self.client.post(url).headers(headers).multipart(form).send().await {
642 Ok(resp) => resp,
643 Err(e) => {
644 msg_error!(Message::SiServerRestDatesFailed(e.to_string()));
645 return Ok(HashSet::new()); // Return empty set on network error
646 }
647 };
648
649 // Handle response and potential session expiration
650 match res.status() {
651 StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
652 // Session expired - clear cache and retry
653 self.delete_session_id()?;
654 local_retries += 1;
655 continue;
656 }
657 _ => {
658 // Process successful response or non-recoverable error
659 return match res.json::<RestDatesResponse>().await {
660 Ok(response) => Ok(response.unique_dates()?),
661 Err(e) => {
662 msg_error!(Message::SiServerRestDatesParsingFailed(e.to_string()));
663 Ok(HashSet::new()) // Return empty set on parsing error
664 }
665 };
666 }
667 }
668 }
669 }
670
671 /// Determines if the specified date is the last working day of its month.
672 ///
673 /// This utility function calculates whether a given date represents the final
674 /// working day in its month, which is useful for triggering monthly report
675 /// submissions and other end-of-month processing.
676 ///
677 /// ## Algorithm
678 ///
679 /// The calculation process:
680 /// 1. **Find Month End**: Determine the last calendar day of the month
681 /// 2. **Weekend Adjustment**: Move backward from weekends to find working days
682 /// 3. **Comparison**: Check if the input date matches the calculated last working day
683 ///
684 /// ## Limitations
685 ///
686 /// Currently only considers weekends (Saturday/Sunday) as non-working days.
687 /// Future versions may integrate with the rest dates API to consider holidays
688 /// and company-specific non-working days for more accurate calculations.
689 ///
690 /// # Arguments
691 ///
692 /// * `date` - The date to check against the last working day
693 ///
694 /// # Returns
695 ///
696 /// Returns `true` if the date is the last working day of its month,
697 /// `false` otherwise.
698 ///
699 /// # Errors
700 ///
701 /// Currently cannot fail, but returns `Result` for consistency and
702 /// future enhancement with holiday integration.
703 ///
704 /// # Examples
705 ///
706 /// ```rust,no_run
707 /// # use kasl::api::{Si, SiConfig};
708 /// # use chrono::NaiveDate;
709 /// # use anyhow::Result;
710 /// # fn example() -> Result<()> {
711 /// let si = Si::new(&config);
712 /// let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); // January 31st
713 ///
714 /// if si.is_last_working_day_of_month(&date)? {
715 /// println!("Time to submit monthly report!");
716 /// }
717 /// # Ok(())
718 /// # }
719 /// ```
720 pub fn is_last_working_day_of_month(&self, date: &NaiveDate) -> Result<bool> {
721 let (year, month) = (date.year(), date.month());
722
723 // Calculate the last day of the current month
724 let mut last_day_of_month = NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap();
725
726 // Move backward from weekends to find the last working day
727 while matches!(last_day_of_month.weekday(), Weekday::Sat | Weekday::Sun) {
728 last_day_of_month = last_day_of_month - Duration::days(1);
729 }
730
731 // Check if the input date matches the calculated last working day
732 Ok(date == &last_day_of_month)
733 }
734}
735
736/// Configuration for SiServer API integration.
737///
738/// This structure holds the necessary information for connecting to internal
739/// SiServer systems. Unlike other API integrations, SiServer requires separate
740/// authentication and API endpoints due to its sophisticated security architecture.
741///
742/// ## Multi-Endpoint Architecture
743///
744/// SiServer uses different endpoints for different purposes:
745/// - **Authentication URL**: LDAP authentication endpoint
746/// - **API URL**: Main API endpoint for reports and data
747/// - **Separation Benefits**: Enhanced security, load distribution, service isolation
748///
749/// ## Security Considerations
750///
751/// - Passwords are never stored in configuration files
752/// - Only username and endpoints are persisted
753/// - Session tokens are cached separately with encryption
754/// - Double base64 password encoding for transmission security
755#[derive(Serialize, Deserialize, Clone, Debug)]
756pub struct SiConfig {
757 /// Username for SiServer authentication.
758 ///
759 /// This should be the corporate username used for LDAP authentication.
760 /// Typically matches the username used for other company systems.
761 pub login: String,
762
763 /// URL for the SiServer authentication endpoint.
764 ///
765 /// This endpoint handles LDAP authentication and token generation.
766 /// Example: `https://auth.company.com`
767 ///
768 /// This is separate from the main API URL due to SiServer's security architecture.
769 pub auth_url: String,
770
771 /// Base URL for the main SiServer API endpoints.
772 ///
773 /// This endpoint handles report submission and data retrieval operations.
774 /// Example: `https://api.company.com`
775 ///
776 /// All API operations (reports, calendar) use this base URL.
777 pub api_url: String,
778}
779
780impl SiConfig {
781 /// Returns the configuration module metadata for SiServer.
782 ///
783 /// Used by the configuration system to identify and manage
784 /// SiServer-specific settings during interactive setup.
785 ///
786 /// # Returns
787 ///
788 /// A `ConfigModule` with SiServer identification information.
789 pub fn module() -> ConfigModule {
790 ConfigModule {
791 key: "si".to_string(),
792 name: "SiServer".to_string(),
793 }
794 }
795
796 /// Runs an interactive configuration setup for SiServer integration.
797 ///
798 /// Prompts the user for SiServer connection details including username
799 /// and both authentication and API endpoints. Uses existing configuration
800 /// values as defaults if available.
801 ///
802 /// ## Interactive Prompts
803 ///
804 /// 1. **Username**: Corporate username for LDAP authentication
805 /// 2. **Authentication URL**: LDAP endpoint for token generation
806 /// 3. **API URL**: Main API endpoint for reports and data operations
807 ///
808 /// All prompts show existing values as defaults if configuration already
809 /// exists, making it easy to update specific values without re-entering everything.
810 ///
811 /// ## Configuration Validation
812 ///
813 /// While this method doesn't validate actual connectivity, it provides
814 /// helpful prompts to guide users toward correct configuration values
815 /// for their corporate SiServer deployment.
816 ///
817 /// # Arguments
818 ///
819 /// * `config` - Existing SiServer configuration to use as defaults (if any)
820 ///
821 /// # Returns
822 ///
823 /// * `Result<Self>` - New SiServer configuration with user input
824 ///
825 /// # Errors
826 ///
827 /// Returns an error if:
828 /// - Terminal input/output fails
829 /// - User cancels the configuration process
830 /// - Input validation fails
831 ///
832 /// # Example
833 ///
834 /// ```rust,no_run
835 /// # use kasl::api::SiConfig;
836 /// # use anyhow::Result;
837 /// # fn example() -> Result<()> {
838 /// let existing_config = Some(SiConfig {
839 /// login: "olduser".to_string(),
840 /// auth_url: "https://old-auth.com".to_string(),
841 /// api_url: "https://old-api.com".to_string(),
842 /// });
843 ///
844 /// let new_config = SiConfig::init(&existing_config)?;
845 /// # Ok(())
846 /// # }
847 /// ```
848 pub fn init(config: &Option<SiConfig>) -> Result<Self> {
849 // Use existing configuration as defaults, or create empty defaults
850 let config = config
851 .clone()
852 .or(Some(Self {
853 login: "".to_string(),
854 auth_url: "".to_string(),
855 api_url: "".to_string(),
856 }))
857 .unwrap();
858
859 // Display configuration module header
860 msg_print!(Message::ConfigModuleSiServer);
861
862 // Interactive configuration with existing values as defaults
863 Ok(Self {
864 login: Input::with_theme(&ColorfulTheme::default())
865 .with_prompt("Enter your SiServer login")
866 .default(config.login)
867 .interact_text()?,
868 auth_url: Input::with_theme(&ColorfulTheme::default())
869 .with_prompt("Enter your SiServer login URL")
870 .default(config.auth_url)
871 .interact_text()?,
872 api_url: Input::with_theme(&ColorfulTheme::default())
873 .with_prompt("Enter the SiServer API URL")
874 .default(config.api_url)
875 .interact_text()?,
876 })
877 }
878}