diff --git a/bins/nittei/src/main.rs b/bins/nittei/src/main.rs index b97d0dba..fa7063f3 100644 --- a/bins/nittei/src/main.rs +++ b/bins/nittei/src/main.rs @@ -4,7 +4,7 @@ use nittei_infra::setup_context; use nittei_utils::config; use tikv_jemallocator::Jemalloc; use tokio::signal; -use tracing::info; +use tracing::{error, info}; /// Use jemalloc as the global allocator /// This is a performance optimization for the application @@ -38,7 +38,11 @@ fn main() { .enable_all() // Enable all features .build() .unwrap() - .block_on(run()); + .block_on(run()) + .inspect_err(|e| { + error!(error = ?e, "[run] Failed to run"); + std::process::exit(1); + }); } "multi_thread" => { let mut runtime = tokio::runtime::Builder::new_multi_thread(); @@ -57,7 +61,10 @@ fn main() { // Build the runtime and block on the run function #[allow(clippy::unwrap_used)] - let _ = runtime.build().unwrap().block_on(run()); + let _ = runtime.build().unwrap().block_on(run()).inspect_err(|e| { + error!(error = ?e, "[run] Failed to run"); + std::process::exit(1); + }); } _ => { tracing::error!( @@ -73,10 +80,15 @@ fn main() { async fn run() -> anyhow::Result<()> { print_runtime_info(); - let context = setup_context().await?; + let context = setup_context() + .await + .inspect_err(|e| error!(error = ?e, "[setup_context] Failed to setup context"))?; + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); - let app = Application::new(context).await?; + let app = Application::new(context) + .await + .inspect_err(|e| error!(error = ?e, "[Application::new] Failed to create application"))?; // Listen for SIGINT (Ctrl+C) to shutdown the service // This sends a message on the channel to shutdown the server gracefully diff --git a/bins/nittei/tests/api_ical_export.rs b/bins/nittei/tests/api_ical_export.rs new file mode 100644 index 00000000..56695b53 --- /dev/null +++ b/bins/nittei/tests/api_ical_export.rs @@ -0,0 +1,668 @@ +mod helpers; + +use chrono::Utc; +use helpers::setup::spawn_app; +use nittei_domain::{CalendarEventStatus, RRuleFrequency, RRuleOptions, Weekday}; +use nittei_sdk::{CreateCalendarInput, CreateEventInput, CreateUserInput, NitteiSDK}; + +#[tokio::test] +async fn test_export_calendar_ical_user_endpoint() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Create user + let user = admin_client + .user + .create(CreateUserInput { + metadata: None, + external_id: None, + user_id: None, + }) + .await + .unwrap() + .user; + + // Create calendar + let calendar = admin_client + .calendar + .create(CreateCalendarInput { + user_id: user.id.clone(), + timezone: chrono_tz::UTC, + name: Some("Test Calendar".to_string()), + key: None, + week_start: Weekday::Mon, + metadata: None, + }) + .await + .unwrap() + .calendar; + + // Create an event within the default timespan (1 month ago) + let event_start = Utc::now() - chrono::Duration::days(30); + let _event = admin_client + .event + .create(CreateEventInput { + external_parent_id: None, + external_id: None, + title: Some("Test Event".to_string()), + description: Some("Test Description".to_string()), + event_type: None, + location: Some("Test Location".to_string()), + status: CalendarEventStatus::Confirmed, + all_day: Some(false), + user_id: user.id.clone(), + calendar_id: calendar.id.clone(), + duration: 1000 * 60 * 60, // 1 hour + reminders: Vec::new(), + busy: Some(true), + recurrence: None, + exdates: None, + recurring_event_id: None, + original_start_time: None, + service_id: None, + start_time: event_start, + metadata: None, + }) + .await + .unwrap() + .event; + + // Test the iCal export endpoint using the SDK client (using default timespan) + let ical_content = admin_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: calendar.id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await + .expect("Expected to export calendar as iCal"); + + // Verify iCal content structure using template validation + let expected_structure = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nittei//Calendar API//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Test Calendar +X-WR-TIMEZONE:UTC +BEGIN:VEVENT +UID:* +SUMMARY:Test Event +DESCRIPTION:Test Description +LOCATION:Test Location +DTSTART:* +DTEND:* +STATUS:CONFIRMED +CREATED:* +LAST-MODIFIED:* +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR"#; + + // Split into lines and validate structure + let expected_lines: Vec<&str> = expected_structure.lines().collect(); + let actual_lines: Vec<&str> = ical_content.lines().collect(); + + // Check that all expected lines are present (ignoring dynamic values marked with *) + for (i, expected_line) in expected_lines.iter().enumerate() { + if expected_line.ends_with("*") { + // Skip wildcard lines (dynamic values) + continue; + } + + if !actual_lines.contains(expected_line) { + panic!("Missing expected line {}: '{}'", i + 1, expected_line); + } + } + + // Verify specific structure requirements + assert_eq!(actual_lines[0], "BEGIN:VCALENDAR"); + assert_eq!(*actual_lines.last().unwrap(), "END:VCALENDAR"); + + // Verify event block is properly contained + let event_start = actual_lines + .iter() + .position(|&line| line == "BEGIN:VEVENT") + .unwrap(); + let event_end = actual_lines + .iter() + .position(|&line| line == "END:VEVENT") + .unwrap(); + assert!( + event_start < event_end, + "Event block not properly contained" + ); + + // Verify dynamic values are present and properly formatted + let event_lines = &actual_lines[event_start..=event_end]; + assert!(event_lines.iter().any(|line| line.starts_with("UID:"))); + assert!(event_lines.iter().any(|line| line.starts_with("DTSTART:"))); + assert!(event_lines.iter().any(|line| line.starts_with("DTEND:"))); + assert!(event_lines.iter().any(|line| line.starts_with("CREATED:"))); + assert!( + event_lines + .iter() + .any(|line| line.starts_with("LAST-MODIFIED:")) + ); +} + +#[tokio::test] +async fn test_export_calendar_ical_admin_endpoint() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Create user + let user = admin_client + .user + .create(CreateUserInput { + metadata: None, + external_id: None, + user_id: None, + }) + .await + .unwrap() + .user; + + // Create calendar + let calendar = admin_client + .calendar + .create(CreateCalendarInput { + user_id: user.id.clone(), + timezone: chrono_tz::Europe::Oslo, + name: Some("Admin Test Calendar".to_string()), + key: None, + week_start: Weekday::Mon, + metadata: None, + }) + .await + .unwrap() + .calendar; + + // Create multiple events within the default timespan + let event1_start = Utc::now() - chrono::Duration::days(60); // 2 months ago + let event2_start = Utc::now() + chrono::Duration::days(30); // 1 month in future + + let _event1 = admin_client + .event + .create(CreateEventInput { + external_parent_id: None, + external_id: None, + title: Some("First Event".to_string()), + description: Some("First event description".to_string()), + event_type: None, + location: None, + status: CalendarEventStatus::Confirmed, + all_day: Some(false), + user_id: user.id.clone(), + calendar_id: calendar.id.clone(), + duration: 1000 * 60 * 30, // 30 minutes + reminders: Vec::new(), + busy: Some(true), + recurrence: None, + exdates: None, + recurring_event_id: None, + original_start_time: None, + service_id: None, + start_time: event1_start, + metadata: None, + }) + .await + .unwrap() + .event; + + let _event2 = admin_client + .event + .create(CreateEventInput { + external_parent_id: None, + external_id: None, + title: Some("Second Event".to_string()), + description: None, + event_type: None, + location: Some("Office".to_string()), + status: CalendarEventStatus::Tentative, + all_day: Some(true), + user_id: user.id.clone(), + calendar_id: calendar.id.clone(), + duration: 1000 * 60 * 60 * 24, // 24 hours (all day) + reminders: Vec::new(), + busy: Some(false), + recurrence: None, + exdates: None, + recurring_event_id: None, + original_start_time: None, + service_id: None, + start_time: event2_start, + metadata: None, + }) + .await + .unwrap() + .event; + + // Test the iCal export endpoint using the SDK client (using default timespan) + let ical_content = admin_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: calendar.id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await + .expect("Expected to export calendar as iCal"); + + // Verify iCal content structure using template validation + let expected_structure = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nittei//Calendar API//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Admin Test Calendar +X-WR-TIMEZONE:Europe/Oslo +BEGIN:VEVENT +UID:* +SUMMARY:First Event +DESCRIPTION:First event description +DTSTART:* +DTEND:* +STATUS:CONFIRMED +CREATED:* +LAST-MODIFIED:* +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:* +SUMMARY:Second Event +LOCATION:Office +DTSTART;VALUE=DATE:* +DTEND;VALUE=DATE:* +STATUS:TENTATIVE +CREATED:* +LAST-MODIFIED:* +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR"#; + + // Split into lines and validate structure + let expected_lines: Vec<&str> = expected_structure.lines().collect(); + let actual_lines: Vec<&str> = ical_content.lines().collect(); + + // Check that all expected lines are present (ignoring dynamic values marked with *) + for (i, expected_line) in expected_lines.iter().enumerate() { + if expected_line.ends_with("*") { + // Skip wildcard lines (dynamic values) + continue; + } + + if !actual_lines.contains(expected_line) { + panic!("Missing expected line {}: '{}'", i + 1, expected_line); + } + } + + // Verify specific structure requirements + assert_eq!(actual_lines[0], "BEGIN:VCALENDAR"); + assert_eq!(*actual_lines.last().unwrap(), "END:VCALENDAR"); + + // Verify we have exactly 2 events + let event_count = actual_lines + .iter() + .filter(|&&line| line == "BEGIN:VEVENT") + .count(); + assert_eq!(event_count, 2, "Expected 2 events"); + + // Verify dynamic values are present and properly formatted + assert!(actual_lines.iter().any(|line| line.starts_with("UID:"))); + assert!(actual_lines.iter().any(|line| line.starts_with("DTSTART:"))); + assert!(actual_lines.iter().any(|line| line.starts_with("DTEND:"))); + assert!( + actual_lines + .iter() + .any(|line| line.starts_with("DTSTART;VALUE=DATE:")) + ); + assert!( + actual_lines + .iter() + .any(|line| line.starts_with("DTEND;VALUE=DATE:")) + ); + assert!(actual_lines.iter().any(|line| line.starts_with("CREATED:"))); + assert!( + actual_lines + .iter() + .any(|line| line.starts_with("LAST-MODIFIED:")) + ); +} + +#[tokio::test] +async fn test_export_calendar_ical_with_recurring_events() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Create user + let user = admin_client + .user + .create(CreateUserInput { + metadata: None, + external_id: None, + user_id: None, + }) + .await + .unwrap() + .user; + + // Create calendar + let calendar = admin_client + .calendar + .create(CreateCalendarInput { + user_id: user.id.clone(), + timezone: chrono_tz::UTC, + name: Some("Recurring Events Calendar".to_string()), + key: None, + week_start: Weekday::Mon, + metadata: None, + }) + .await + .unwrap() + .calendar; + + // Create a recurring event (daily for 5 days) within the default timespan + let recurrence = RRuleOptions { + freq: RRuleFrequency::Daily, + interval: 1, + count: Some(5), + until: None, + bysetpos: None, + byweekday: None, + bymonthday: None, + bymonth: None, + byyearday: None, + byweekno: None, + weekstart: None, + }; + + let recurring_event_start = Utc::now() + chrono::Duration::days(7); // 1 week in future + let _recurring_event = admin_client + .event + .create(CreateEventInput { + external_parent_id: None, + external_id: None, + title: Some("Daily Meeting".to_string()), + description: Some("Daily standup meeting".to_string()), + event_type: None, + location: Some("Conference Room".to_string()), + status: CalendarEventStatus::Confirmed, + all_day: Some(false), + user_id: user.id.clone(), + calendar_id: calendar.id.clone(), + duration: 1000 * 60 * 30, // 30 minutes + reminders: Vec::new(), + busy: Some(true), + recurrence: Some(recurrence), + exdates: None, + recurring_event_id: None, + original_start_time: None, + service_id: None, + start_time: recurring_event_start, + metadata: None, + }) + .await + .unwrap() + .event; + + // Test the iCal export endpoint using the SDK client (using default timespan) + let ical_content = admin_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: calendar.id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await + .expect("Expected to export calendar as iCal"); + + // Verify iCal content structure using template validation + let expected_structure = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nittei//Calendar API//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Recurring Events Calendar +X-WR-TIMEZONE:UTC +BEGIN:VEVENT +UID:* +SUMMARY:Daily Meeting +DESCRIPTION:Daily standup meeting +LOCATION:Conference Room +DTSTART:* +DTEND:* +STATUS:CONFIRMED +RRULE:FREQ=DAILY;COUNT=5 +CREATED:* +LAST-MODIFIED:* +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR"#; + + // Split into lines and validate structure + let expected_lines: Vec<&str> = expected_structure.lines().collect(); + let actual_lines: Vec<&str> = ical_content.lines().collect(); + + // print!("actual_lines: {:?}", actual_lines); + + // Check that all expected lines are present (ignoring dynamic values marked with *) + for (i, expected_line) in expected_lines.iter().enumerate() { + if expected_line.ends_with("*") { + // Skip wildcard lines (dynamic values) + continue; + } + + if !actual_lines.contains(expected_line) { + panic!("Missing expected line {}: '{}'", i + 1, expected_line); + } + } + + // Verify specific structure requirements + assert_eq!(actual_lines[0], "BEGIN:VCALENDAR"); + assert_eq!(*actual_lines.last().unwrap(), "END:VCALENDAR"); + + // Verify we have exactly 1 event + let event_count = actual_lines + .iter() + .filter(|&&line| line == "BEGIN:VEVENT") + .count(); + assert_eq!(event_count, 1, "Expected 1 recurring event"); + + // Verify dynamic values are present and properly formatted + assert!(actual_lines.iter().any(|line| line.starts_with("UID:"))); + assert!(actual_lines.iter().any(|line| line.starts_with("DTSTART:"))); + assert!(actual_lines.iter().any(|line| line.starts_with("DTEND:"))); + assert!(actual_lines.iter().any(|line| line.starts_with("CREATED:"))); + assert!( + actual_lines + .iter() + .any(|line| line.starts_with("LAST-MODIFIED:")) + ); +} + +#[tokio::test] +async fn test_export_empty_calendar_ical() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Create user + let user = admin_client + .user + .create(CreateUserInput { + metadata: None, + external_id: None, + user_id: None, + }) + .await + .unwrap() + .user; + + // Create calendar (no events) + let calendar = admin_client + .calendar + .create(CreateCalendarInput { + user_id: user.id.clone(), + timezone: chrono_tz::UTC, + name: Some("Empty Calendar".to_string()), + key: None, + week_start: Weekday::Mon, + metadata: None, + }) + .await + .unwrap() + .calendar; + + // Test the iCal export endpoint using the SDK client (using default timespan) + let ical_content = admin_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: calendar.id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await + .expect("Expected to export calendar as iCal"); + + // Verify iCal content structure using template validation for empty calendar + let expected_structure = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nittei//Calendar API//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Empty Calendar +X-WR-TIMEZONE:UTC +END:VCALENDAR"#; + + // Split into lines and validate structure + let expected_lines: Vec<&str> = expected_structure.lines().collect(); + let actual_lines: Vec<&str> = ical_content.lines().collect(); + + // Check that all expected lines are present + for (i, expected_line) in expected_lines.iter().enumerate() { + if !actual_lines.contains(expected_line) { + panic!("Missing expected line {}: '{}'", i + 1, expected_line); + } + } + + // Verify specific structure requirements + assert_eq!(actual_lines[0], "BEGIN:VCALENDAR"); + assert_eq!(*actual_lines.last().unwrap(), "END:VCALENDAR"); + + // Verify no events are present (only calendar header/footer) + assert!(!actual_lines.contains(&"BEGIN:VEVENT")); + assert!(!actual_lines.contains(&"END:VEVENT")); + + // Verify we have exactly the expected number of lines (no extra content) + assert_eq!( + actual_lines.len(), + expected_lines.len(), + "Empty calendar should have no extra content" + ); +} + +#[tokio::test] +async fn test_export_calendar_ical_unauthorized() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Create user + let user = admin_client + .user + .create(CreateUserInput { + metadata: None, + external_id: None, + user_id: None, + }) + .await + .unwrap() + .user; + + // Create calendar + let calendar = admin_client + .calendar + .create(CreateCalendarInput { + user_id: user.id.clone(), + timezone: chrono_tz::UTC, + name: Some("Test Calendar".to_string()), + key: None, + week_start: Weekday::Mon, + metadata: None, + }) + .await + .unwrap() + .calendar; + + // Test that unauthorized access fails by creating a new SDK client without API key + let unauthorized_client = NitteiSDK::new(address, "invalid_api_key".to_string()); + + // Try to export calendar as iCal with invalid API key - should fail + let ical_result = unauthorized_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: calendar.id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await; + + // Should return an error due to invalid API key + assert!(ical_result.is_err()); +} + +#[tokio::test] +async fn test_export_calendar_ical_not_found() { + let (app, sdk, address) = spawn_app().await; + let res = sdk + .account + .create(&app.config.create_account_secret_code) + .await + .expect("Expected to create account"); + + let admin_client = NitteiSDK::new(address.clone(), res.secret_api_key.clone()); + + // Test the iCal export endpoint with non-existent calendar + let non_existent_id = nittei_sdk::ID::default(); + + // Try to export non-existent calendar as iCal - should fail + let ical_result = admin_client + .calendar + .export_ical(nittei_sdk::ExportCalendarIcalInput { + calendar_id: non_existent_id.clone(), + start_time: None, // Use default (3 months ago) + end_time: None, // Use default (6 months in future) + }) + .await; + assert!(ical_result.is_err()); +} diff --git a/clients/rust/src/base.rs b/clients/rust/src/base.rs index 5325ec05..4e4a7276 100644 --- a/clients/rust/src/base.rs +++ b/clients/rust/src/base.rs @@ -126,6 +126,30 @@ impl BaseClient { self.handle_api_response(res, expected_status_code).await } + /// Get a text response from the API. + /// + /// This is useful for endpoints that return plain text, such as iCal exports. + pub async fn get_text( + &self, + path: String, + query_params: Option>, + expected_status_code: StatusCode, + ) -> APIResponse { + let res = match self + .get_client(Method::GET, path, query_params) + .send() + .await + { + Ok(res) => res, + Err(_) => return Err(self.network_error()), + }; + let res = self.check_status_code(res, expected_status_code).await?; + res.text().await.map_err(|e| APIError { + variant: APIErrorVariant::MalformedResponse, + message: e.to_string(), + }) + } + pub async fn delete Deserialize<'de>>( &self, path: String, diff --git a/clients/rust/src/calendar.rs b/clients/rust/src/calendar.rs index 5ab5e1d0..3dddf0a9 100644 --- a/clients/rust/src/calendar.rs +++ b/clients/rust/src/calendar.rs @@ -68,6 +68,16 @@ pub struct GetOutlookCalendars { pub min_access_role: OutlookCalendarAccessRole, } +/// Input for exporting a calendar as iCal. +/// +/// Timespan defaults to 3 months in the past and 6 months in the future if not provided. +/// +pub struct ExportCalendarIcalInput { + pub calendar_id: ID, + pub start_time: Option>, + pub end_time: Option>, +} + impl CalendarClient { pub(crate) fn new(base: Arc) -> Self { Self { base } @@ -246,4 +256,34 @@ impl CalendarClient { ) .await } + + /// Export a calendar as iCal. + /// + /// Timespan defaults to 3 months in the past and 6 months in the future if not provided. + /// + /// Returns a plain text response containing the iCal content. + /// + pub async fn export_ical(&self, input: ExportCalendarIcalInput) -> APIResponse { + let mut query_params = Vec::new(); + + if let Some(start_time) = input.start_time { + query_params.push(("startTime".to_string(), start_time.to_string())); + } + + if let Some(end_time) = input.end_time { + query_params.push(("endTime".to_string(), end_time.to_string())); + } + + self.base + .get_text( + format!("user/calendar/{}/ical", input.calendar_id), + if query_params.is_empty() { + None + } else { + Some(query_params) + }, + StatusCode::OK, + ) + .await + } } diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs index c6a34cc8..07d509d3 100644 --- a/clients/rust/src/lib.rs +++ b/clients/rust/src/lib.rs @@ -16,6 +16,7 @@ pub use base::{APIError, APIErrorVariant, APIResponse}; use calendar::CalendarClient; pub use calendar::{ CreateCalendarInput, + ExportCalendarIcalInput, GetCalendarEventsInput, GetGoogleCalendars, GetOutlookCalendars, diff --git a/crates/api/src/calendar/export_ical.rs b/crates/api/src/calendar/export_ical.rs new file mode 100644 index 00000000..a9d6d62f --- /dev/null +++ b/crates/api/src/calendar/export_ical.rs @@ -0,0 +1,296 @@ +use std::collections::HashMap; + +use anyhow::anyhow; +use axum::{ + Extension, + extract::{Path, Query}, + http::{HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Months, Utc}; +use nittei_api_structs::get_calendar_events_ical::{PathParams, QueryParams}; +use nittei_domain::{Account, ID, TimeSpan, User, generate_ical_content}; +use nittei_infra::NitteiContext; +use tracing::error; + +use crate::{ + error::NitteiError, + shared::{ + auth::{Policy, account_can_modify_calendar}, + usecase::{UseCase, execute}, + }, +}; + +/// Notes +/// - Right now it doesn't fetch all the ongoing recurring events (even though it should) +/// - It doesn't limit the number of events to export - ideally it should +/// - The endpoints are not public - ideally users should be able to expose the calendars to the public via a public link (but hard to find) + +#[utoipa::path( + get, + tag = "Calendar", + path = "/api/v1/user/calendar/{calendar_id}/ical", + summary = "Export calendar events as iCalendar format (admin only)", + security( + ("api_key" = []) + ), + params( + ("calendar_id" = ID, Path, description = "The id of the calendar to export"), + ("start_time" = Option>, Query, description = "The start time of the events to export"), + ("end_time" = Option>, Query, description = "The end time of the events to export"), + ("limit" = Option, Query, description = "The limit for the number of events to export"), + ("offset" = Option, Query, description = "The offset for the events to export"), + ), + responses( + (status = 200, description = "iCalendar file", content_type = "text/calendar") + ) +)] +/// Export calendar events as iCalendar format for admin users +/// +/// This endpoint allows admin users to export events from any calendar as an iCalendar (.ics) file. +/// The exported file can be imported into any calendar application that supports the iCalendar format. +/// +/// # Parameters +/// - `calendar_id`: The ID of the calendar to export +/// - `start_time`: The start time for the export range (UTC) +/// - `end_time`: The end time for the export range (UTC) +/// +/// # Returns +/// Returns an iCalendar file with Content-Type: text/calendar +pub async fn export_calendar_ical_admin_controller( + Extension(account): Extension, + query_params: Query, + path: Path, + Extension(ctx): Extension, +) -> Result { + let cal = account_can_modify_calendar(&account, &path.calendar_id, &ctx).await?; + + let usecase = ExportCalendarIcalUseCase { + user_id: cal.user_id, + calendar_id: cal.id, + start_time: query_params.start_time, + end_time: query_params.end_time, + }; + + execute(usecase, &ctx) + .await + .map_err(NitteiError::from) + .map(|ical_content| { + let headers = [ + ( + "content-type", + HeaderValue::from_static("text/calendar; charset=utf-8"), + ), + ( + "content-disposition", + HeaderValue::from_static("attachment; filename=\"calendar.ics\""), + ), + ]; + + (StatusCode::OK, headers, ical_content.ical_content).into_response() + }) +} + +#[utoipa::path( + get, + tag = "Calendar", + path = "/api/v1/calendar/{calendar_id}/ical", + summary = "Export calendar events as iCalendar format", + params( + ("calendar_id" = ID, Path, description = "The id of the calendar to export"), + ("start_time" = Option>, Query, description = "The start time of the events to export"), + ("end_time" = Option>, Query, description = "The end time of the events to export"), + ), + responses( + (status = 200, description = "iCalendar file", content_type = "text/calendar") + ) +)] +/// Export calendar events as iCalendar format for regular users +/// +/// This endpoint allows users to export events from their own calendars as an iCalendar (.ics) file. +/// The exported file can be imported into any calendar application that supports the iCalendar format. +/// +/// # Parameters +/// - `calendar_id`: The ID of the calendar to export (must belong to the authenticated user) +/// - `start_time`: The start time for the export range (UTC) +/// - `end_time`: The end time for the export range (UTC) +/// +/// # Returns +/// Returns an iCalendar file with Content-Type: text/calendar +pub async fn export_calendar_ical_controller( + Extension((user, _policy)): Extension<(User, Policy)>, + query_params: Query, + path: Path, + Extension(ctx): Extension, +) -> Result { + let usecase = ExportCalendarIcalUseCase { + user_id: user.id, + calendar_id: path.calendar_id.clone(), + start_time: query_params.start_time, + end_time: query_params.end_time, + }; + + execute(usecase, &ctx) + .await + .map_err(NitteiError::from) + .map(|ical_content| { + let headers = [ + ( + "content-type", + HeaderValue::from_static("text/calendar; charset=utf-8"), + ), + ( + "content-disposition", + HeaderValue::from_static("attachment; filename=\"calendar.ics\""), + ), + ]; + + (StatusCode::OK, headers, ical_content.ical_content).into_response() + }) +} + +/// Use case for exporting calendar events as iCalendar format +/// +/// This use case handles the business logic for retrieving calendar events +/// within a specified time range and generating iCalendar content. +#[derive(Debug)] +pub struct ExportCalendarIcalUseCase { + /// The ID of the calendar to export + pub calendar_id: ID, + /// The ID of the user who owns the calendar + pub user_id: ID, + /// The start time for the export range (UTC) + pub start_time: Option>, + /// The end time for the export range (UTC) + pub end_time: Option>, +} + +/// Response containing the generated iCalendar content +#[derive(Debug)] +pub struct UseCaseResponse { + /// The generated iCalendar content as a string + pub ical_content: String, +} + +/// Errors that can occur during iCalendar export +#[derive(Debug, thiserror::Error)] +pub enum UseCaseError { + /// The requested calendar was not found or the user doesn't have access to it + #[error("Calendar not found")] + CalendarNotFound, + /// An internal error occurred during processing + #[error("Internal error")] + InternalError, +} + +impl From for NitteiError { + fn from(error: UseCaseError) -> Self { + match error { + UseCaseError::CalendarNotFound => NitteiError::NotFound("Calendar".to_string()), + UseCaseError::InternalError => NitteiError::InternalError, + } + } +} + +#[async_trait::async_trait] +impl UseCase for ExportCalendarIcalUseCase { + type Response = UseCaseResponse; + type Error = UseCaseError; + + const NAME: &'static str = "ExportCalendarIcal"; + + async fn execute(&mut self, ctx: &NitteiContext) -> Result { + // Get the calendar + let calendar = ctx + .repos + .calendars + .find(&self.calendar_id) + .await + .map_err(|_| UseCaseError::InternalError)? + .ok_or(UseCaseError::CalendarNotFound)?; + + // Verify the calendar belongs to the user + if calendar.user_id != self.user_id { + return Err(UseCaseError::CalendarNotFound); + } + + // Create timespan for the export + let timespan = TimeSpan::new( + self.start_time.unwrap_or( + Utc::now() + .checked_sub_months(Months::new(3)) + .ok_or(anyhow!("Invalid start time")) + .map_err(|err| { + error!( + "[export_calendar_ical] Got an error while getting the start time: {:?}", + err + ); + UseCaseError::InternalError + })?, + ), + self.end_time.unwrap_or( + Utc::now() + .checked_add_months(Months::new(6)) + .ok_or(anyhow!("Invalid end time")) + .map_err(|err| { + error!( + "[export_calendar_ical] Got an error while getting the end time: {:?}", + err + ); + UseCaseError::InternalError + })?, + ), + ); + + // Get events for the calendar in the specified time range + let events = ctx + .repos + .events + .find_by_calendar(&self.calendar_id, Some(timespan.clone())) + .await + .map_err(|err| { + error!( + "[export_calendar_ical] Got an error while getting the events: {:?}", + err + ); + UseCaseError::InternalError + })?; + + // Separate normal events, recurring events, and exceptions + let (normal_events, recurring_events, exceptions) = events.into_iter().fold( + (Vec::new(), Vec::new(), Vec::new()), + |(mut normal, mut recurring, mut exceptions), event| { + if event.recurring_event_id.is_some() { + exceptions.push(event); + } else if event.recurrence.is_some() { + recurring.push(event); + } else { + normal.push(event); + } + (normal, recurring, exceptions) + }, + ); + + // Generate map of exceptions for recurring events + let mut map_recurring_event_id_to_exceptions = HashMap::new(); + + for event in &exceptions { + if let Some(recurring_event_id) = &event.recurring_event_id { + map_recurring_event_id_to_exceptions + .entry(recurring_event_id) + .or_insert_with(Vec::new) + .push(event.clone()); + } + } + + // Generate iCalendar content + let ical_content = generate_ical_content( + &calendar, + &normal_events, + &recurring_events, + &map_recurring_event_id_to_exceptions, + ); + + Ok(UseCaseResponse { ical_content }) + } +} diff --git a/crates/api/src/calendar/mod.rs b/crates/api/src/calendar/mod.rs index 96fcc6c8..13afa1d3 100644 --- a/crates/api/src/calendar/mod.rs +++ b/crates/api/src/calendar/mod.rs @@ -1,6 +1,7 @@ pub mod add_sync_calendar; pub mod create_calendar; pub mod delete_calendar; +pub mod export_ical; pub mod get_calendar; pub mod get_calendar_events; pub mod get_calendars; @@ -14,6 +15,7 @@ use add_sync_calendar::add_sync_calendar_admin_controller; use axum::routing::{delete, get, post, put}; use create_calendar::{create_calendar_admin_controller, create_calendar_controller}; use delete_calendar::{delete_calendar_admin_controller, delete_calendar_controller}; +use export_ical::{export_calendar_ical_admin_controller, export_calendar_ical_controller}; use get_calendar::{get_calendar_admin_controller, get_calendar_controller}; use get_calendar_events::{get_calendar_events_admin_controller, get_calendar_events_controller}; use get_calendars_by_meta::get_calendars_by_meta_controller; @@ -68,6 +70,11 @@ pub fn configure_routes() -> OpenApiRouter { "/user/calendar/{calendar_id}/events", get(get_calendar_events_admin_controller), ) + // Export calendar as iCalendar format (admin route) + .route( + "/user/calendar/{calendar_id}/ical", + get(export_calendar_ical_admin_controller), + ) .route( "/user/{user_id}/calendar/provider/google", get(get_google_calendars_admin_controller), @@ -107,6 +114,11 @@ pub fn configure_routes() -> OpenApiRouter { "/calendar/{calendar_id}/events", get(get_calendar_events_controller), ) + // Export calendar as iCalendar format + .route( + "/calendar/{calendar_id}/ical", + get(export_calendar_ical_controller), + ) // Calendar providers .route( "/calendar/provider/google", diff --git a/crates/api_structs/src/calendar/api.rs b/crates/api_structs/src/calendar/api.rs index 0bab3905..e35db612 100644 --- a/crates/api_structs/src/calendar/api.rs +++ b/crates/api_structs/src/calendar/api.rs @@ -183,6 +183,32 @@ pub mod delete_calendar { pub type APIResponse = CalendarResponse; } +pub mod get_calendar_events_ical { + use chrono::{DateTime, Utc}; + + use super::*; + + #[derive(Debug, Deserialize)] + pub struct PathParams { + pub calendar_id: ID, + } + + #[derive(Debug, Deserialize)] + pub struct QueryParams { + /// Optional start time for the query (UTC) + /// + /// Default to 3 months before now + pub start_time: Option>, + + /// Optional end time for the query (UTC) + /// + /// Default to 6 months from now + pub end_time: Option>, + } + + pub type APIResponse = String; +} + pub mod get_calendar_events { use chrono::{DateTime, Utc}; use nittei_domain::EventWithInstances; diff --git a/crates/domain/src/event.rs b/crates/domain/src/event.rs index ef9cd9da..6814422d 100644 --- a/crates/domain/src/event.rs +++ b/crates/domain/src/event.rs @@ -41,6 +41,16 @@ impl From for String { } } +impl std::fmt::Display for CalendarEventStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CalendarEventStatus::Tentative => write!(f, "tentative"), + CalendarEventStatus::Confirmed => write!(f, "confirmed"), + CalendarEventStatus::Cancelled => write!(f, "cancelled"), + } + } +} + impl TryFrom for CalendarEventStatus { type Error = anyhow::Error; fn try_from(e: String) -> anyhow::Result { diff --git a/crates/domain/src/ical.rs b/crates/domain/src/ical.rs new file mode 100644 index 00000000..8e937ea7 --- /dev/null +++ b/crates/domain/src/ical.rs @@ -0,0 +1,476 @@ +use std::collections::HashMap; + +use crate::{Calendar, CalendarEvent, CalendarEventStatus, ID, RRuleFrequency, RRuleOptions}; + +/// Generates iCalendar content from calendar events and instances +/// +/// This function creates a complete iCalendar (.ics) file content including: +/// - Calendar metadata (name, timezone) +/// - Event details (title, description, location, dates, status) +/// - Recurrence rules for recurring events +/// - Exception dates for modified recurring events +/// +/// # Arguments +/// * `calendar` - The calendar containing the events +/// * `instances` - Event instances with resolved start/end times +/// * `events` - The original calendar events with full metadata +/// +/// # Returns +/// A string containing the complete iCalendar content +pub fn generate_ical_content( + calendar: &Calendar, + normal_events: &[CalendarEvent], + recurring_events: &[CalendarEvent], + map_recurring_event_id_to_exceptions: &HashMap<&ID, Vec>, +) -> String { + let mut ical = String::new(); + + // iCalendar header + ical.push_str("BEGIN:VCALENDAR\r\n"); + ical.push_str("VERSION:2.0\r\n"); + ical.push_str("PRODID:-//Nittei//Calendar API//EN\r\n"); + ical.push_str("CALSCALE:GREGORIAN\r\n"); + ical.push_str("METHOD:PUBLISH\r\n"); + + // Add calendar name if available + if let Some(name) = &calendar.name { + ical.push_str(&format!("X-WR-CALNAME:{}\r\n", escape_text(name))); + } + + // Add timezone information + ical.push_str(&format!("X-WR-TIMEZONE:{}\r\n", calendar.settings.timezone)); + + // Add events + for event in normal_events { + ical.push_str(&generate_ical_content_for_event(event)); + } + + for recurring_event in recurring_events { + ical.push_str(&generate_ical_content_for_event(recurring_event)); + + if let Some(exceptions) = map_recurring_event_id_to_exceptions.get(&recurring_event.id) { + for exception in exceptions { + ical.push_str(&generate_ical_content_for_exception( + exception, + recurring_event, + )); + } + } + } + + // iCalendar footer + ical.push_str("END:VCALENDAR\r\n"); + + ical +} + +/// Generates iCalendar content for a single event +pub fn generate_ical_content_for_event(event: &CalendarEvent) -> String { + let mut ical = String::new(); + + ical.push_str("BEGIN:VEVENT\r\n"); + + // Event ID + ical.push_str(&format!("UID:{}\r\n", event.id)); + + // Summary (title) + if let Some(title) = &event.title { + ical.push_str(&format!("SUMMARY:{}\r\n", escape_text(title))); + } + + // Description + if let Some(description) = &event.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_text(description))); + } + + // Location + if let Some(location) = &event.location { + ical.push_str(&format!("LOCATION:{}\r\n", escape_text(location))); + } + + // Start and end time + if event.all_day { + ical.push_str(&format!( + "DTSTART;VALUE=DATE:{}\r\n", + event.start_time.format("%Y%m%d") + )); + ical.push_str(&format!( + "DTEND;VALUE=DATE:{}\r\n", + event.end_time.format("%Y%m%d") + )); + } else { + ical.push_str(&format!( + "DTSTART:{}\r\n", + event.start_time.format("%Y%m%dT%H%M%SZ") + )); + ical.push_str(&format!( + "DTEND:{}\r\n", + event.end_time.format("%Y%m%dT%H%M%SZ") + )); + } + + // Status + ical.push_str(&format!( + "STATUS:{}\r\n", + match event.status { + CalendarEventStatus::Confirmed => "CONFIRMED", + CalendarEventStatus::Tentative => "TENTATIVE", + CalendarEventStatus::Cancelled => "CANCELLED", + } + )); + + // Created and modified dates + ical.push_str(&format!( + "CREATED:{}\r\n", + event.created.format("%Y%m%dT%H%M%SZ") + )); + ical.push_str(&format!( + "LAST-MODIFIED:{}\r\n", + event.updated.format("%Y%m%dT%H%M%SZ") + )); + + // Recurrence rules + if let Some(recurrence) = &event.recurrence + && let Some(rrule) = recurrence_to_rrule_string(recurrence) + { + ical.push_str(&format!("RRULE:{}\r\n", rrule)); + } + + // Exception dates + for exdate in &event.exdates { + ical.push_str(&format!("EXDATE:{}\r\n", exdate.format("%Y%m%dT%H%M%SZ"))); + } + + // Busy status + if !event.busy { + ical.push_str("TRANSP:TRANSPARENT\r\n"); + } else { + ical.push_str("TRANSP:OPAQUE\r\n"); + } + + ical.push_str("END:VEVENT\r\n"); + + ical +} + +/// Generate ical content for an exception of a recurring event +pub fn generate_ical_content_for_exception( + exception: &CalendarEvent, + parent_event: &CalendarEvent, +) -> String { + let mut ical = String::new(); + ical.push_str("BEGIN:VEVENT\r\n"); + + // Same UID as parent + ical.push_str(&format!("UID:{}\r\n", parent_event.id)); + + // RECURRENCE-ID identifies which occurrence is being modified + if exception.all_day { + if let Some(original_start_time) = &exception.original_start_time { + ical.push_str(&format!( + "RECURRENCE-ID;VALUE=DATE:{}\r\n", + original_start_time.format("%Y%m%d") + )); + } + } else if let Some(original_start_time) = &exception.original_start_time { + ical.push_str(&format!( + "RECURRENCE-ID:{}\r\n", + original_start_time.format("%Y%m%dT%H%M%SZ") + )); + } + + // Modified properties + if let Some(title) = &exception.title { + ical.push_str(&format!("SUMMARY:{}\r\n", escape_text(title))); + } else if let Some(title) = &parent_event.title { + ical.push_str(&format!("SUMMARY:{}\r\n", escape_text(title))); + } + + // Similar pattern for description, location, etc. + + // Modified start and end times + if exception.all_day { + ical.push_str(&format!( + "DTSTART;VALUE=DATE:{}\r\n", + exception.start_time.format("%Y%m%d") + )); + ical.push_str(&format!( + "DTEND;VALUE=DATE:{}\r\n", + exception.end_time.format("%Y%m%d") + )); + } else { + ical.push_str(&format!( + "DTSTART:{}\r\n", + exception.start_time.format("%Y%m%dT%H%M%SZ") + )); + ical.push_str(&format!( + "DTEND:{}\r\n", + exception.end_time.format("%Y%m%dT%H%M%SZ") + )); + } + + // Status (important for cancelled occurrences) + let status = &exception.status; + ical.push_str(&format!("STATUS:{}\r\n", status)); + + // Created and modified dates + ical.push_str(&format!( + "CREATED:{}\r\n", + exception.created.format("%Y%m%dT%H%M%SZ") + )); + ical.push_str(&format!( + "LAST-MODIFIED:{}\r\n", + exception.updated.format("%Y%m%dT%H%M%SZ") + )); + + // No RRULE on exceptions + + // Busy status + let busy = exception.busy; + if !busy { + ical.push_str("TRANSP:TRANSPARENT\r\n"); + } else { + ical.push_str("TRANSP:OPAQUE\r\n"); + } + + ical.push_str("END:VEVENT\r\n"); + ical +} + +/// Converts RRuleOptions to an iCalendar RRULE string +/// +/// This function transforms the internal recurrence rule representation into +/// the standard iCalendar RRULE format. It handles all supported recurrence +/// patterns including frequency, interval, count, until date, and various +/// by-rules (weekday, monthday, month, etc.). +/// +/// # Arguments +/// * `recurrence` - The recurrence rule options to convert +/// +/// # Returns +/// An optional string containing the RRULE value, or None if the rule is invalid +/// +/// # Examples +/// A weekly recurrence with interval 2 and count 10 would produce: +/// `"FREQ=WEEKLY;INTERVAL=2;COUNT=10"` +fn recurrence_to_rrule_string(recurrence: &RRuleOptions) -> Option { + let mut rrule = String::new(); + + // Frequency + let freq = match recurrence.freq { + RRuleFrequency::Yearly => "YEARLY", + RRuleFrequency::Monthly => "MONTHLY", + RRuleFrequency::Weekly => "WEEKLY", + RRuleFrequency::Daily => "DAILY", + }; + rrule.push_str(&format!("FREQ={}", freq)); + + // Interval + if recurrence.interval != 1 { + rrule.push_str(&format!(";INTERVAL={}", recurrence.interval)); + } + + // Count + if let Some(count) = recurrence.count { + rrule.push_str(&format!(";COUNT={}", count)); + } + + // Until + if let Some(until) = recurrence.until { + rrule.push_str(&format!(";UNTIL={}", until.format("%Y%m%dT%H%M%SZ"))); + } + + // Byweekday + if let Some(byweekday) = &recurrence.byweekday { + let weekdays: Vec = byweekday + .iter() + .map(|wd| match wd.nth() { + None => format!("{}", wd.weekday()), + Some(n) => format!("{}{}", n, wd.weekday()), + }) + .collect(); + if !weekdays.is_empty() { + rrule.push_str(&format!(";BYDAY={}", weekdays.join(","))); + } + } + + // Bymonthday + if let Some(bymonthday) = &recurrence.bymonthday { + let monthdays: Vec = bymonthday.iter().map(|d| d.to_string()).collect(); + if !monthdays.is_empty() { + rrule.push_str(&format!(";BYMONTHDAY={}", monthdays.join(","))); + } + } + + // Bymonth - convert chrono::Month to month number (1-12) + if let Some(bymonth) = &recurrence.bymonth { + let months: Vec = bymonth + .iter() + .map(|m| { + match m { + chrono::Month::January => "1", + chrono::Month::February => "2", + chrono::Month::March => "3", + chrono::Month::April => "4", + chrono::Month::May => "5", + chrono::Month::June => "6", + chrono::Month::July => "7", + chrono::Month::August => "8", + chrono::Month::September => "9", + chrono::Month::October => "10", + chrono::Month::November => "11", + chrono::Month::December => "12", + } + .to_string() + }) + .collect(); + if !months.is_empty() { + rrule.push_str(&format!(";BYMONTH={}", months.join(","))); + } + } + + // Weekstart + if let Some(weekstart) = &recurrence.weekstart { + rrule.push_str(&format!(";WKST={}", weekstart)); + } + + Some(rrule) +} + +/// Escapes text content for safe inclusion in iCalendar format +/// +/// This function escapes special characters according to the iCalendar specification +/// to ensure proper parsing by calendar applications. It handles backslashes, +/// newlines, carriage returns, semicolons, and commas. +/// +/// # Arguments +/// * `text` - The text to escape +/// +/// # Returns +/// The escaped text safe for use in iCalendar properties +/// +/// # Examples +/// `"Hello; World"` becomes `"Hello\\; World"` +fn escape_text(text: &str) -> String { + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace(";", "\\;") + .replace(",", "\\,") +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use chrono_tz::UTC; + use rrule::Weekday; + + use super::*; + use crate::CalendarSettings; + + #[test] + fn test_generate_ical_content() { + let calendar = Calendar { + id: ID::default(), + user_id: ID::default(), + account_id: ID::default(), + name: Some("Test Calendar".to_string()), + key: None, + settings: CalendarSettings { + week_start: Weekday::Mon, + timezone: UTC, + }, + metadata: None, + }; + + let events = vec![CalendarEvent { + id: ID::default(), + external_parent_id: None, + external_id: None, + title: Some("Test Event".to_string()), + description: Some("Test Description".to_string()), + event_type: None, + location: Some("Test Location".to_string()), + all_day: false, + status: CalendarEventStatus::Confirmed, + start_time: UTC + .with_ymd_and_hms(2024, 1, 15, 10, 0, 0) + .unwrap() + .with_timezone(&chrono::Utc), + duration: 3600000, // 1 hour in milliseconds + busy: true, + end_time: UTC + .with_ymd_and_hms(2024, 1, 15, 11, 0, 0) + .unwrap() + .with_timezone(&chrono::Utc), + created: UTC + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .unwrap() + .with_timezone(&chrono::Utc), + updated: UTC + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .unwrap() + .with_timezone(&chrono::Utc), + recurrence: None, + exdates: vec![], + recurring_until: None, + recurring_event_id: None, + original_start_time: None, + calendar_id: ID::default(), + user_id: ID::default(), + account_id: ID::default(), + reminders: vec![], + service_id: None, + metadata: None, + }]; + + let ical_content = generate_ical_content(&calendar, &events, &[], &HashMap::new()); + + // Verify basic iCalendar structure + assert!(ical_content.contains("BEGIN:VCALENDAR")); + assert!(ical_content.contains("END:VCALENDAR")); + assert!(ical_content.contains("BEGIN:VEVENT")); + assert!(ical_content.contains("END:VEVENT")); + assert!(ical_content.contains("VERSION:2.0")); + assert!(ical_content.contains("PRODID:-//Nittei//Calendar API//EN")); + + // Verify calendar properties + assert!(ical_content.contains("X-WR-CALNAME:Test Calendar")); + assert!(ical_content.contains("X-WR-TIMEZONE:UTC")); + + // Verify event properties + assert!(ical_content.contains("SUMMARY:Test Event")); + assert!(ical_content.contains("DESCRIPTION:Test Description")); + assert!(ical_content.contains("LOCATION:Test Location")); + assert!(ical_content.contains("STATUS:CONFIRMED")); + assert!(ical_content.contains("DTSTART:20240115T100000Z")); + assert!(ical_content.contains("DTEND:20240115T110000Z")); + } + + #[test] + fn test_recurrence_to_rrule_string() { + let recurrence = RRuleOptions { + freq: RRuleFrequency::Weekly, + interval: 2, + count: Some(10), + until: None, + bysetpos: None, + byweekday: None, + bymonthday: None, + bymonth: None, + byyearday: None, + byweekno: None, + weekstart: None, + }; + + let rrule = recurrence_to_rrule_string(&recurrence).unwrap(); + assert_eq!(rrule, "FREQ=WEEKLY;INTERVAL=2;COUNT=10"); + } + + #[test] + fn test_escape_text() { + let text = "Hello; World, with\nnewlines\r"; + let escaped = escape_text(text); + assert_eq!(escaped, "Hello\\; World\\, with\\nnewlines\\r"); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 98c0db95..df23a040 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -5,6 +5,7 @@ mod date; mod event; pub mod event_group; mod event_instance; +pub mod ical; pub mod providers; mod reminder; mod schedule; @@ -33,6 +34,11 @@ pub use event_instance::{ FreeBusy, get_free_busy, }; +pub use ical::{ + generate_ical_content, + generate_ical_content_for_event, + generate_ical_content_for_exception, +}; pub use reminder::{EventRemindersExpansionJob, Reminder}; pub use schedule::{Schedule, ScheduleRule}; pub use service::{