Files
meguri/src/domain.rs
T

728 lines
25 KiB
Rust
Raw Normal View History

2026-09-24 14:35:05 +03:00
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)
}
2026-09-24 14:35:05 +03:00
}
#[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>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub instance_admin: bool,
#[serde(default)]
pub has_password: bool,
2026-09-24 14:35:05 +03:00
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub user: User,
pub permissions: Vec<Permission>,
pub is_owner: bool,
2026-09-24 14:35:05 +03:00
}
/// 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>,
2026-09-24 14:35:05 +03:00
}
#[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>,
2026-09-24 14:35:05 +03:00
#[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(),
2026-09-24 14:35:05 +03:00
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>,
}