//! Read-only expansion and atomic activation for one workspace. use crate::{ domain::*, engine, error::{Result, ensure}, generation::{self, Issue, Rule, Variable}, providers, store::{Store, WorkspaceRecord}, }; use chrono::{Duration, Utc}; use serde::Deserialize; use serde_json::{Value, json}; use uuid::Uuid; #[derive(Debug, Clone, Deserialize)] pub struct Draft { pub revision: i64, pub variables: Vec, pub rules: Vec, #[serde(default)] pub route_position: usize, #[serde(default)] pub review_id: Option, } struct Prepared { report: Value, record: WorkspaceRecord, issues: Vec, review: String, } async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Result { let mut record = db .prepare(workspace, draft.revision, actor, Permission::EditSchedules) .await?; let directory = db.directory(workspace).await?; let members = directory.iter().map(|u| u.id).collect(); let now = Utc::now(); let mut expanded = generation::expand( &record.document, workspace, &draft.variables, &draft.rules, draft.route_position, &record.document.working_interval, &members, now, ); let used: std::collections::BTreeSet<_> = expanded .document .schedules .iter() .flat_map(|s| match &s.kind { ScheduleKind::Rotation { participants, .. } | ScheduleKind::Priority { participants, .. } => participants.clone(), }) .chain(expanded.document.escalations.iter().flat_map(|e| { e.steps.iter().filter_map(|s| match s.recipient { Recipient::User { id } => Some(id), _ => None, }) })) .collect(); for user in &directory { if used.contains(&user.id) && expanded.document.person(user.id).is_none() { expanded.document.people.push(Person { id: user.id, user_id: Some(user.id), display_name: user.display_name.clone(), email: None, country: Some(user.country.clone()), aliases: vec![], }); } } if expanded.issues.is_empty() { if let Err(e) = expanded.document.validate(&members) { expanded.issues.push(Issue { path: "People".into(), message: e.to_string(), ..Issue::default() }); } if let Err(e) = providers::validate_capabilities(&expanded.document, now) { expanded.issues.push(Issue { path: "Provider".into(), message: e.to_string(), ..Issue::default() }); } // Both shared and manually configured calendars must remain valid under DST. if let Err(e) = engine::timeline( &expanded.document, now, now + Duration::days(i64::from(expanded.document.horizon_days)), ) { expanded.issues.push(Issue { path: "Calendar".into(), message: e.to_string(), ..Issue::default() }); } } let issues = expanded.issues; let cancelled = record .document .requests .iter() .filter(|r| { r.status == RequestStatus::Pending && expanded .document .requests .iter() .any(|n| n.id == r.id && n.status == RequestStatus::Cancelled) }) .count(); let result=issues.is_empty().then(||{let d=&expanded.document;json!({"timezone":d.timezone,"people":d.people,"schedules":d.schedules,"escalations":d.escalations,"routes":d.routes,"managed":d.generation.managed})}); let review = generation::stable_id(&json!([ workspace, draft.revision, draft.variables, draft.rules, draft.route_position ])) .to_string(); let report = json!({"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues}); record.document = expanded.document; Ok(Prepared { report, record, issues, review, }) } pub async fn preview(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result { Ok(prepare(db, actor, workspace, &draft).await?.report) } pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result { let mut prepared = prepare(db, actor, workspace, &draft).await?; ensure( prepared.issues.is_empty(), prepared .issues .iter() .take(8) .map(|i| format!("{}: {}", i.path, i.message)) .collect::>() .join("; "), )?; ensure( draft.review_id.as_deref() == Some(prepared.review.as_str()), crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"), )?; db.commit( &mut prepared.record, actor, Permission::EditSchedules, "generation.applied", prepared.report, None, ) .await?; Ok(json!({"ok":true})) }