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 ]
}
2026-10-07 13:28:43 +01:00
/// 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 > ,
2026-10-07 15:30:31 +01:00
/// Canonical account-owned person; email is required by account persistence.
#[serde(flatten)]
pub person : Person ,
2026-09-24 14:35:05 +03:00
pub username : String ,
/// None follows the browser timezone; this never changes scheduling rules.
pub timezone : Option < String > ,
2026-10-07 13:28:43 +01:00
#[serde(default)]
pub instance_admin : bool ,
#[serde(default)]
pub has_password : bool ,
2026-09-24 14:35:05 +03:00
}
2026-10-07 15:30:31 +01:00
impl std ::ops ::Deref for User {
type Target = Person ;
fn deref ( & self ) -> & Person {
& self . person
}
}
2026-09-24 14:35:05 +03:00
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub user : User ,
pub permissions : Vec < Permission > ,
2026-10-07 13:28:43 +01:00
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 > ,
2026-10-07 13:28:43 +01:00
/// 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 ,
2026-10-07 13:28:43 +01:00
/// 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 ,
2026-10-07 13:28:43 +01:00
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 (
2026-10-07 16:06:10 +01:00
person . user_id . is_none_or ( | id | id == person . id ),
2026-09-24 14:35:05 +03:00
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 (
2026-10-07 15:30:31 +01:00
Uuid ::parse_str ( cloud_id ). is_ok ()
&& Uuid ::parse_str ( team_id . strip_prefix ( "og-" ). unwrap_or ( team_id )). is_ok (),
2026-09-24 14:35:05 +03:00
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 > ,
}
2026-10-07 15:30:31 +01:00
/// 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 > ,
}