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