use crate::error::{Result, ensure}; use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum Permission { View, EditSchedules, RequestOverrides, ForceOverrides, ManageMembers, ManageIntegration, ViewAudit, } impl Permission { pub fn all() -> Vec { vec![ Self::View, Self::EditSchedules, Self::RequestOverrides, Self::ForceOverrides, Self::ManageMembers, Self::ManageIntegration, Self::ViewAudit, ] } pub fn member() -> Vec { vec![Self::View, Self::RequestOverrides] } /// Default employee role: calendar access, schedule edits, and approved replacements. pub fn employee_default() -> Vec { vec![Self::View, Self::EditSchedules, Self::RequestOverrides] } /// Permissions an administrator may grant the employee role. pub fn grantable_to_employees(self) -> bool { !matches!(self, Self::ManageMembers | Self::ManageIntegration) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { #[serde(default)] pub language: Option, /// Canonical account-owned person; email is required by account persistence. #[serde(flatten)] pub person: Person, pub username: String, /// None follows the browser timezone; this never changes scheduling rules. pub timezone: Option, #[serde(default)] pub instance_admin: bool, #[serde(default)] pub has_password: bool, } impl std::ops::Deref for User { type Target = Person; fn deref(&self) -> &Person { &self.person } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Member { pub user: User, pub permissions: Vec, pub is_owner: bool, } /// Scheduling metadata, not an authentication principal. Registered users retain their ID. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Person { pub id: Uuid, pub display_name: String, pub email: Option, pub country: Option, pub user_id: Option, /// Former contact IDs that were merged into this person when a login was linked by email. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Holiday { pub country: String, pub date: NaiveDate, pub name: String, } pub fn default_handover() -> NaiveTime { NaiveTime::from_hms_opt(11, 0, 0).unwrap() } pub fn valid_country(country: &str) -> bool { // ISO 3166-1 alpha-2. No network dependency for validating scheduling state. const CODES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW"; CODES.split_whitespace().any(|code| code == country) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceDocument { pub working_interval: WorkingWindow, pub overwrite_drift: bool, pub name: String, pub timezone: String, pub horizon_days: u16, /// Grants held by the employee role; administrators always hold every permission. #[serde(default = "Permission::employee_default")] pub member_permissions: Vec, #[serde(default)] pub people: Vec, #[serde(default)] pub schedules: Vec, #[serde(default)] pub holidays: Vec, #[serde(default)] pub absences: Vec, #[serde(default)] pub requests: Vec, #[serde(default)] pub escalations: Vec, #[serde(default)] pub routes: Vec, #[serde(default)] pub integration: Integration, #[serde(default)] pub generation: crate::generation::Generation, } impl WorkspaceDocument { pub fn new(name: String) -> Self { Self { name, working_interval: WorkingWindow::default(), overwrite_drift: false, timezone: "Europe/Bucharest".into(), horizon_days: 90, member_permissions: Permission::employee_default(), people: vec![], schedules: vec![], holidays: vec![], absences: vec![], requests: vec![], escalations: vec![], routes: vec![], integration: Integration::Mock, generation: Default::default(), } } pub fn is_absent(&self, user: Uuid, day: NaiveDate) -> bool { self.absences .iter() .any(|a| a.user_id == user && a.start <= day && day <= a.end) } pub fn schedule(&self, id: Uuid) -> Option<&Schedule> { self.schedules.iter().find(|s| s.id == id) } pub fn person(&self, id: Uuid) -> Option<&Person> { self.people.iter().find(|p| p.id == id) } pub fn person_ids(&self, members: &BTreeSet) -> BTreeSet { members .iter() .copied() .chain(self.people.iter().map(|p| p.id)) .collect() } pub fn country_holiday(&self, id: Uuid, day: NaiveDate) -> bool { let country = self .person(id) .and_then(|p| p.country.as_deref()) .unwrap_or("CY"); self.holidays .iter() .any(|h| h.date == day && h.country == country) } pub fn validate(&self, members: &BTreeSet) -> Result<()> { ensure( self.people.len() <= 1000, "A team supports at most 1,000 people", )?; let mut person_ids = BTreeSet::new(); let mut emails = BTreeSet::new(); for person in &self.people { ensure( person_ids.insert(person.id), crate::i18n::text("err_person_ids_must_be_unique_2d45f9"), )?; validate_name(&person.display_name)?; ensure( person.user_id.is_none_or(|id| id == person.id), crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), )?; ensure( person.country.as_deref().is_none_or(valid_country), crate::i18n::text("err_choose_a_valid_country_code_e79c72"), )?; if let Some(email) = &person.email { ensure( email.len() <= 254 && email_address::EmailAddress::is_valid(email), crate::i18n::text("err_enter_a_valid_email_address_1577ea"), )?; ensure( email == &email.trim().to_lowercase(), crate::i18n::text("err_email_addresses_must_be_normalized_c8f704"), )?; ensure( emails.insert(email), crate::i18n::text("err_this_email_is_already_in_the_team_directory_2692f4"), )?; } ensure( person.user_id.is_some() || person.email.is_some(), "A contact needs an email address", )?; } let members = self.person_ids(members); validate_name(&self.name)?; ensure( self.timezone.parse::().is_ok(), crate::i18n::text("err_use_a_valid_iana_timezone_b90408"), )?; ensure( (7..=366).contains(&self.horizon_days), crate::i18n::text("err_publication_horizon_must_be_7_366_days_95023e"), )?; ensure( self.schedules.len() <= 200 && self.escalations.len() <= 200 && self.routes.len() <= 500, "A team supports at most 200 schedules, 200 policies, and 500 routing rules", )?; ensure( self.absences.len() <= 2000 && self.holidays.len() <= 2000 && self.requests.len() <= 5000, crate::i18n::text("err_team_history_is_too_large_archive_old_data_befor_fc0b97"), )?; let ids: BTreeSet<_> = self.schedules.iter().map(|s| s.id).collect(); ensure( ids.len() == self.schedules.len(), crate::i18n::text("err_schedule_ids_must_be_unique_4f12c8"), )?; for s in &self.schedules { validate_name(&s.name)?; match &s.kind { ScheduleKind::Rotation { participants, anchor, shift_days, } => { ensure( !participants.is_empty() && participants.len() <= 100, "A rotation needs 1–100 participants", )?; ensure( participants.iter().all(|id| members.contains(id)), crate::i18n::text( "err_choose_a_workspace_user_or_email_contact_for_eve_879191", ), )?; ensure( participants.iter().collect::>().len() == participants.len(), crate::i18n::text("err_a_rotation_cannot_repeat_a_participant_adc324"), )?; ensure( (1..=366).contains(shift_days), crate::i18n::text("err_shift_length_must_be_1_366_calendar_days_b772e3"), )?; ensure( (2000..=2100).contains(&chrono::Datelike::year(anchor)), crate::i18n::text("err_anchor_year_must_be_2000_2100_5009d7"), )?; } ScheduleKind::Priority { participants, fallback, } => { ensure( !participants.is_empty() && participants.len() <= 100, crate::i18n::text("err_priority_coverage_needs_1_100_people_b162ae"), )?; ensure( participants.iter().all(|id| members.contains(id)), crate::i18n::text( "err_choose_a_workspace_user_or_email_contact_for_eve_291413", ), )?; ensure( participants.iter().collect::>().len() == participants.len(), crate::i18n::text("err_priority_coverage_cannot_repeat_a_person_c524c8"), )?; ensure( ids.contains(fallback), crate::i18n::text("err_fallback_schedule_does_not_exist_0ef8b9"), )?; } } let mut seen = BTreeSet::new(); let mut next = s.id; loop { ensure( seen.insert(next), crate::i18n::text("err_fallback_schedules_cannot_contain_a_cycle_b96e58"), )?; match &self .schedule(next) .ok_or_else(|| { crate::error::Error::Validation( crate::i18n::text("err_missing_fallback_schedule_6234b6").into(), ) })? .kind { ScheduleKind::Priority { fallback, .. } => next = *fallback, ScheduleKind::Rotation { .. } => break, } } } for a in &self.absences { ensure( members.contains(&a.user_id), crate::i18n::text("err_absent_employee_must_be_a_team_member_510f37"), )?; ensure( a.start <= a.end && (a.end - a.start).num_days() <= 730, crate::i18n::text("err_absence_must_be_a_valid_range_of_at_most_two_yea_db96bb"), )?; } let mut holidays = BTreeSet::new(); for holiday in &self.holidays { validate_name(&holiday.name)?; ensure( holiday.country == "*" || valid_country(&holiday.country), crate::i18n::text("err_holiday_country_must_be_an_iso_country_code_09ebeb"), )?; ensure( (2000..=2100).contains(&chrono::Datelike::year(&holiday.date)), crate::i18n::text("err_holiday_year_must_be_2000_2100_e542b1"), )?; ensure( holidays.insert((&holiday.country, holiday.date)), "A country/date can have only one holiday", )?; } let policy_ids: BTreeSet<_> = self.escalations.iter().map(|e| e.id).collect(); ensure( policy_ids.len() == self.escalations.len(), crate::i18n::text("err_escalation_ids_must_be_unique_8346e7"), )?; for e in &self.escalations { validate_name(&e.name)?; ensure( !e.steps.is_empty() && e.steps.len() <= 20, crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"), )?; let mut previous = 0; for (index, step) in e.steps.iter().enumerate() { ensure( step.delay_minutes >= previous && step.delay_minutes <= 10080, crate::i18n::text( "err_escalation_delays_must_be_ordered_and_at_most_on_93513d", ), )?; previous = step.delay_minutes; match step.recipient { Recipient::Schedule { id } => ensure( ids.contains(&id), crate::i18n::message( "err_escalation_p0_step_p1_references_a_missing_sched_a8026c", &[ ("p0", (e.name).to_string()), ("p1", (index + 1).to_string()), ], ), )?, Recipient::User { id } => ensure( members.contains(&id), crate::i18n::text("err_escalation_employee_must_be_a_team_member_8b2c9f"), )?, _ => {} } } ensure( e.repeat.count <= 20 && e.repeat.wait_minutes <= 10080, crate::i18n::text("err_invalid_escalation_repeat_settings_508f67"), )?; } let mut orders = BTreeSet::new(); let mut route_ids = BTreeSet::new(); for r in &self.routes { validate_name(&r.name)?; ensure( route_ids.insert(r.id) && orders.insert(r.order), crate::i18n::text("err_routing_ids_and_priorities_must_be_unique_1753f6"), )?; ensure( r.escalation_id.is_none_or(|id| policy_ids.contains(&id)), crate::i18n::text("err_routing_escalation_does_not_exist_0c1581"), )?; ensure( r.conditions.len() <= 20, crate::i18n::text("err_too_many_routing_conditions_7c419b"), )?; for c in &r.conditions { ensure( [ "tags", "priority", "message", "description", "source", "alias", ] .contains(&c.field.as_str()), crate::i18n::text("err_unsupported_routing_field_0b98b6"), )?; ensure( [ "matches", "contains", "equals", "greater-than", "less-than", "starts-with", "ends-with", ] .contains(&c.operation.as_str()), crate::i18n::text("err_unsupported_routing_operation_c5c07c"), )?; ensure( !c.value.is_empty() && c.value.len() <= 500, crate::i18n::text("err_routing_value_must_contain_1_500_characters_0f3a17"), )?; } if let Some(window) = &r.window { window.validate()?; } } if let Integration::Jsm { cloud_id, team_id, users, .. } = &self.integration { ensure( Uuid::parse_str(cloud_id).is_ok() && Uuid::parse_str(team_id.strip_prefix("og-").unwrap_or(team_id)).is_ok(), crate::i18n::text("err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f"), )?; ensure( users.keys().all(|id| members.contains(id)), crate::i18n::text("err_jsm_mapping_contains_a_non_member"), )?; ensure( users .values() .all(|v| !v.trim().is_empty() && v.len() <= 200), crate::i18n::text("err_jsm_account_ids_must_contain_1_200_characters_9fb430"), )?; } Ok(()) } } pub fn validate_name(name: &str) -> Result<()> { ensure( !name.trim().is_empty() && name.len() <= 120, crate::i18n::text("err_names_must_contain_1_120_characters_8071e9"), ) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schedule { pub id: Uuid, pub name: String, #[serde(default = "default_handover")] pub handover: NaiveTime, #[serde(flatten)] pub kind: ScheduleKind, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", from = "ScheduleKindWire")] pub enum ScheduleKind { /// First available person wins; the ordered list does not rotate. Priority { participants: Vec, fallback: Uuid, }, Rotation { participants: Vec, anchor: NaiveDate, shift_days: u16, }, } // Legacy JSON is normalized on read, including immutable history snapshots. #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ScheduleKindWire { Priority { participants: Vec, fallback: Uuid, }, Coverage { primary: Uuid, secondary: Uuid, fallback: Uuid, }, Rotation { participants: Vec, anchor: NaiveDate, shift_days: u16, }, } impl From for ScheduleKind { fn from(value: ScheduleKindWire) -> Self { match value { ScheduleKindWire::Priority { participants, fallback, } => Self::Priority { participants, fallback, }, ScheduleKindWire::Coverage { primary, secondary, fallback, } => Self::Priority { participants: vec![primary, secondary], fallback, }, ScheduleKindWire::Rotation { participants, anchor, shift_days, } => Self::Rotation { participants, anchor, shift_days, }, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Absence { pub id: Uuid, pub user_id: Uuid, pub start: NaiveDate, pub end: NaiveDate, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReplacementLeg { pub schedule_id: Uuid, pub start: DateTime, pub end: DateTime, pub from_user: Uuid, pub to_user: Uuid, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RequestStatus { Pending, Applied, Rejected, Cancelled, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReplacementRequest { pub id: Uuid, #[serde(default)] pub absence_id: Option, pub requested_by: Uuid, pub created_at: DateTime, pub reason: String, pub legs: Vec, /// Remaining active spans; original legs remain available for history. #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_legs: Option>, pub required_approvers: BTreeSet, pub approvals: BTreeSet, pub status: RequestStatus, pub applied_at: Option>, pub forced_by: Option, pub force_reason: Option, } impl ReplacementRequest { pub fn effective_legs(&self) -> &[ReplacementLeg] { self.effective_legs.as_deref().unwrap_or(&self.legs) } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(tag = "provider", rename_all = "snake_case")] pub enum Integration { #[default] Mock, Jsm { cloud_id: String, team_id: String, advanced_escalations: bool, users: BTreeMap, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Escalation { pub id: Uuid, pub name: String, pub steps: Vec, #[serde(default)] pub repeat: EscalationRepeat, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EscalationStep { pub delay_minutes: u16, pub condition: EscalationCondition, pub recipient: Recipient, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum EscalationCondition { IfNotAcked, IfNotClosed, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Recipient { Schedule { id: Uuid }, User { id: Uuid }, TeamAll, TeamRandom, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EscalationRepeat { pub count: u8, pub wait_minutes: u16, pub reset_recipient_states: bool, pub close_alert_after_all: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoutingRule { pub id: Uuid, pub name: String, pub order: u16, pub conditions: Vec, pub window: Option, pub escalation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RouteCondition { pub field: String, pub operation: String, pub value: String, #[serde(default)] pub negate: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkingWindow { pub timezone: String, pub weekdays: Vec, pub start: NaiveTime, pub end: NaiveTime, } impl Default for WorkingWindow { fn default() -> Self { Self { timezone: "Europe/Bucharest".into(), weekdays: vec![1, 2, 3, 4, 5], start: default_handover(), end: NaiveTime::from_hms_opt(19, 0, 0).unwrap(), } } } impl WorkingWindow { pub fn validate(&self) -> Result<()> { ensure( self.timezone.parse::().is_ok(), crate::i18n::text("err_use_a_valid_iana_timezone_for_working_hours_b4e2c6"), )?; ensure( !self.weekdays.is_empty() && self.weekdays.iter().all(|d| (1..=7).contains(d)) && self.weekdays.iter().collect::>().len() == self.weekdays.len(), crate::i18n::text("err_choose_distinct_working_days_9a4ea4"), )?; ensure( self.start < self.end, crate::i18n::text("err_working_hours_must_end_after_they_start_on_the_s_a19afe"), ) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Assignment { pub schedule_id: Uuid, pub start: DateTime, pub end: DateTime, pub user_id: Uuid, pub reason: String, pub unavailable: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Timeline { pub from: DateTime, pub to: DateTime, pub assignments: Vec, } /// A compact representation of a verified calendar-day cycle, independent of providers. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecurringRotation { pub start: DateTime, pub end: DateTime, pub shift_days: u16, pub participants: Vec, }