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