2026-09-24 14:35:05 +03:00
|
|
|
//! 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<Variable>,
|
|
|
|
|
pub rules: Vec<Rule>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub route_position: usize,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub review_id: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
struct Prepared {
|
|
|
|
|
report: Value,
|
|
|
|
|
record: WorkspaceRecord,
|
|
|
|
|
issues: Vec<Issue>,
|
|
|
|
|
review: String,
|
|
|
|
|
}
|
|
|
|
|
async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Result<Prepared> {
|
|
|
|
|
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()),
|
2026-10-07 13:28:43 +01:00
|
|
|
aliases: vec![],
|
2026-09-24 14:35:05 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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<Value> {
|
|
|
|
|
Ok(prepare(db, actor, workspace, &draft).await?.report)
|
|
|
|
|
}
|
|
|
|
|
pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result<Value> {
|
|
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.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}))
|
|
|
|
|
}
|