NEXT predates the access work, so the identity layer follows master: access comes from configured groups, instance administrators replace the site administration, and accounts are created by SSO or by an administrator. Kept from NEXT: manual publication with the Review changes panel and shared generator drafts, portable import/export (meguri/v2), the layered generation map with binding provenance, JSM provider changes, contact/approval semantics and the account-owned Person. Dropped: site administration, local sign-up, and the mandatory unique email with its contact/account migrations. The manual-publication migration is renumbered m_0010 after m_0009_access. Accounts without an email still get a Person in every workspace they reach, a changed sign-in email updates the account's person, and an account that loses access keeps its linked person so schedules stay valid. Instance administrators may use Sync and shared drafts. Co-Authored-By: Claude <noreply@anthropic.com>
742 lines
25 KiB
Rust
742 lines
25 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]
|
||
}
|
||
/// Default employee role: calendar access, schedule edits, and approved replacements.
|
||
pub fn employee_default() -> Vec<Self> {
|
||
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<String>,
|
||
/// 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<String>,
|
||
#[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<Permission>,
|
||
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<String>,
|
||
pub country: Option<String>,
|
||
pub user_id: Option<Uuid>,
|
||
/// 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<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,
|
||
/// Grants held by the employee role; administrators always hold every permission.
|
||
#[serde(default = "Permission::employee_default")]
|
||
pub member_permissions: Vec<Permission>,
|
||
#[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,
|
||
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<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),
|
||
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.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<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>,
|
||
}
|
||
|
||
/// A compact representation of a verified calendar-day cycle, independent of providers.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct RecurringRotation {
|
||
pub start: DateTime<Utc>,
|
||
pub end: DateTime<Utc>,
|
||
pub shift_days: u16,
|
||
pub participants: Vec<Uuid>,
|
||
}
|