Files
meguri/src/domain.rs
T
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
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>
2026-10-07 16:06:10 +01:00

742 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>,
}