706 lines
24 KiB
Rust
706 lines
24 KiB
Rust
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<Self> {
|
|||
|
|
vec![
|
|||
|
|
Self::View,
|
|||
|
|
Self::EditSchedules,
|
|||
|
|
Self::RequestOverrides,
|
|||
|
|
Self::ForceOverrides,
|
|||
|
|
Self::ManageMembers,
|
|||
|
|
Self::ManageIntegration,
|
|||
|
|
Self::ViewAudit,
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
pub fn member() -> Vec<Self> {
|
|||
|
|
vec![Self::View, Self::RequestOverrides]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct User {
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub language: Option<String>,
|
|||
|
|
pub id: Uuid,
|
|||
|
|
pub username: String,
|
|||
|
|
pub display_name: String,
|
|||
|
|
pub country: String,
|
|||
|
|
/// None follows the browser timezone; this never changes scheduling rules.
|
|||
|
|
pub timezone: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct Member {
|
|||
|
|
pub user: User,
|
|||
|
|
pub permissions: Vec<Permission>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 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<String>,
|
|||
|
|
pub country: Option<String>,
|
|||
|
|
pub user_id: Option<Uuid>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[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,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub people: Vec<Person>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub schedules: Vec<Schedule>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub holidays: Vec<Holiday>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub absences: Vec<Absence>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub requests: Vec<ReplacementRequest>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub escalations: Vec<Escalation>,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub routes: Vec<RoutingRule>,
|
|||
|
|
#[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,
|
|||
|
|
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<Uuid>) -> BTreeSet<Uuid> {
|
|||
|
|
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<Uuid>) -> 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 && members.contains(&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::<chrono_tz::Tz>().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::<BTreeSet<_>>().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::<BTreeSet<_>>().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).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<Uuid>,
|
|||
|
|
fallback: Uuid,
|
|||
|
|
},
|
|||
|
|
Rotation {
|
|||
|
|
participants: Vec<Uuid>,
|
|||
|
|
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<Uuid>,
|
|||
|
|
fallback: Uuid,
|
|||
|
|
},
|
|||
|
|
Coverage {
|
|||
|
|
primary: Uuid,
|
|||
|
|
secondary: Uuid,
|
|||
|
|
fallback: Uuid,
|
|||
|
|
},
|
|||
|
|
Rotation {
|
|||
|
|
participants: Vec<Uuid>,
|
|||
|
|
anchor: NaiveDate,
|
|||
|
|
shift_days: u16,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
impl From<ScheduleKindWire> 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<Utc>,
|
|||
|
|
pub end: DateTime<Utc>,
|
|||
|
|
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<Uuid>,
|
|||
|
|
pub requested_by: Uuid,
|
|||
|
|
pub created_at: DateTime<Utc>,
|
|||
|
|
pub reason: String,
|
|||
|
|
pub legs: Vec<ReplacementLeg>,
|
|||
|
|
/// Remaining active spans; original legs remain available for history.
|
|||
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|||
|
|
pub effective_legs: Option<Vec<ReplacementLeg>>,
|
|||
|
|
pub required_approvers: BTreeSet<Uuid>,
|
|||
|
|
pub approvals: BTreeSet<Uuid>,
|
|||
|
|
pub status: RequestStatus,
|
|||
|
|
pub applied_at: Option<DateTime<Utc>>,
|
|||
|
|
pub forced_by: Option<Uuid>,
|
|||
|
|
pub force_reason: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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<Uuid, String>,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct Escalation {
|
|||
|
|
pub id: Uuid,
|
|||
|
|
pub name: String,
|
|||
|
|
pub steps: Vec<EscalationStep>,
|
|||
|
|
#[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<RouteCondition>,
|
|||
|
|
pub window: Option<WorkingWindow>,
|
|||
|
|
pub escalation_id: Option<Uuid>,
|
|||
|
|
}
|
|||
|
|
#[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<u8>,
|
|||
|
|
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::<chrono_tz::Tz>().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::<BTreeSet<_>>().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<Utc>,
|
|||
|
|
pub end: DateTime<Utc>,
|
|||
|
|
pub user_id: Uuid,
|
|||
|
|
pub reason: String,
|
|||
|
|
pub unavailable: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct Timeline {
|
|||
|
|
pub from: DateTime<Utc>,
|
|||
|
|
pub to: DateTime<Utc>,
|
|||
|
|
pub assignments: Vec<Assignment>,
|
|||
|
|
}
|