//! 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, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Draft { pub revision: i64, #[serde(default)] pub shared_revision: Option, 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?; crate::store::account_people(&mut record.document, &directory)?; 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(user.person.clone()); } } 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 publication_changes = crate::store::publication_changes( &expanded.document, db.published_document(workspace).await?.as_ref(), )?; let report = json!({"shared_revision":draft.shared_revision,"publication_changes":publication_changes,"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})) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Transfer { pub revision: i64, pub shared_revision: i64, #[serde(default)] pub text: String, #[serde(default)] pub review_id: Option, } async fn transfer_base( db: &Store, actor: Uuid, workspace: Uuid, request: &Transfer, ) -> Result<(WorkspaceRecord, Draft)> { let record = db .prepare( workspace, request.revision, actor, Permission::EditSchedules, ) .await?; let shared = db.shared_draft(workspace).await?; ensure( shared["revision"].as_i64() == Some(request.shared_revision), crate::i18n::text("err_shared_draft_stale"), )?; let mut draft: Draft = if shared["payload"].is_null() { Draft { revision: record.revision, shared_revision: Some(request.shared_revision), variables: record.document.generation.variables.clone(), rules: record.document.generation.rules.clone(), route_position: record.document.generation.route_position, review_id: None, } } else { serde_json::from_value(shared["payload"].clone())? }; draft.revision = record.revision; draft.shared_revision = Some(request.shared_revision); Ok((record, draft)) } pub async fn export_config( db: &Store, actor: Uuid, workspace: Uuid, request: Transfer, ) -> Result { let (record, draft) = transfer_base(db, actor, workspace, &request).await?; Ok( json!({"text":crate::portable::format_config(&crate::portable::export(&record.document,&draft)?)}), ) } pub async fn import_config( db: &Store, actor: Uuid, workspace: Uuid, request: Transfer, apply: bool, ) -> Result { let (record, base) = transfer_base(db, actor, workspace, &request).await?; let (draft, inputs) = crate::portable::import(workspace, &record.document, &base, &request.text)?; let prepared = prepare(db, actor, workspace, &draft).await?; let review = generation::stable_id(&json!([ prepared.review, request.shared_revision, request.text ])) .to_string(); if apply { ensure( prepared.issues.is_empty(), crate::i18n::text("transfer_fix_errors"), )?; ensure( request.review_id.as_deref() == Some(review.as_str()), crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"), )?; return db .save_shared_draft( workspace, actor, request.shared_revision, serde_json::to_value(draft)?, ) .await; } let contacts: Vec<_> = prepared .record .document .people .iter() .filter(|p| record.document.person(p.id).is_none() && p.user_id.is_none()) .collect(); Ok(json!({"review_id":review,"inputs":inputs,"contacts":contacts,"preview":prepared.report})) }