Init
Checks / test (push) Failing after 12m20s

This commit is contained in:
ab committed 2026-09-24 14:35:05 +03:00
commit a47c4a0398
96 files changed
+25721

No files matched your search

+714
View File
@@ -0,0 +1,714 @@
//! Pure absence impact planning. Explicit ranges expand across affected schedules.
use crate::{
domain::*,
engine,
error::{Result, ensure},
};
use chrono::{DateTime, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExactCover {
pub schedule_id: Uuid,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LocalCover {
#[serde(default)]
pub schedule_id: Option<Uuid>,
pub start: chrono::NaiveDateTime,
pub end: chrono::NaiveDateTime,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CoverRange {
#[serde(default)]
pub local: Option<LocalCover>,
#[serde(default)]
pub exact: Option<ExactCover>,
pub start: NaiveDate,
pub end: NaiveDate,
pub replacement: Uuid,
#[serde(default)]
pub replace_projects: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Draft {
pub absence: Absence,
#[serde(default)]
pub ranges: Vec<CoverRange>,
}
#[derive(Debug, Serialize)]
pub struct Impact {
pub start_day: NaiveDate,
pub end_day: NaiveDate,
pub schedule_id: Uuid,
pub schedule_name: String,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub before: Uuid,
pub after: Option<Uuid>,
pub status: String,
}
#[derive(Debug, Serialize)]
pub struct Plan {
pub rows: Vec<Impact>,
pub groups: Vec<Vec<ReplacementLeg>>,
pub warnings: Vec<String>,
pub errors: Vec<String>,
pub schedules: usize,
pub uncovered: usize,
}
pub fn synthetic_request(
absence: Uuid,
legs: Vec<ReplacementLeg>,
now: DateTime<Utc>,
) -> ReplacementRequest {
ReplacementRequest {
effective_legs: None,
id: Uuid::nil(),
absence_id: Some(absence),
requested_by: Uuid::nil(),
created_at: now,
reason: crate::i18n::text("err_absence_coverage_2fccc7").into(),
legs,
required_approvers: BTreeSet::new(),
approvals: BTreeSet::new(),
status: RequestStatus::Applied,
applied_at: Some(now),
forced_by: None,
force_reason: None,
}
}
/// Validate against original ownership, but preserve all other absences and overrides.
pub fn validate_request(
doc: &WorkspaceDocument,
request: &ReplacementRequest,
members: &BTreeSet<Uuid>,
now: DateTime<Utc>,
) -> Result<()> {
let absence = doc
.absences
.iter()
.find(|a| Some(a.id) == request.absence_id)
.ok_or_else(|| {
crate::error::Error::Validation(
crate::i18n::text("err_this_absence_no_longer_exists_8d51ae").into(),
)
})?;
ensure(
!request.legs.is_empty() && request.legs.len() <= 500,
crate::i18n::text("err_absence_coverage_supports_1_500_affected_interva_d61c7a"),
)?;
let mut original = doc.clone();
original.absences.retain(|a| a.id != absence.id);
for leg in &request.legs {
ensure(
leg.from_user == absence.user_id,
crate::i18n::text("err_absence_coverage_can_replace_only_the_absent_emp_a99c60"),
)?;
ensure(
leg.start >= engine::schedule_handover(doc, leg.schedule_id, absence.start)?
&& leg.end
<= engine::schedule_handover(
doc,
leg.schedule_id,
absence.end + Duration::days(1),
)?,
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
)?;
engine::validate_legs(&original, std::slice::from_ref(leg), members, now)?;
}
for (index, leg) in request.legs.iter().enumerate() {
ensure(
request.legs[..index].iter().all(|other| {
other.schedule_id != leg.schedule_id
|| other.end <= leg.start
|| leg.end <= other.start
}),
crate::i18n::text("err_absence_replacement_intervals_overlap_d053f4"),
)?;
}
Ok(())
}
fn range_interval(
doc: &WorkspaceDocument,
range: &CoverRange,
schedule: &Schedule,
now: DateTime<Utc>,
absence: &Absence,
) -> Result<Option<(DateTime<Utc>, DateTime<Utc>)>> {
if let Some(local) = &range.local {
if local.schedule_id.is_some_and(|id| id != schedule.id)
|| (local.schedule_id.is_none()
&& matches!(schedule.kind, ScheduleKind::Priority { .. })
&& !range.replace_projects)
{
return Ok(None);
}
let start = engine::local_time(doc, local.start.date(), local.start.time())?;
let end = engine::local_time(doc, local.end.date(), local.end.time())?;
let first = engine::schedule_handover(doc, schedule.id, absence.start)?;
let last = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?;
if local.schedule_id.is_some() {
ensure(
start >= first && end <= last,
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
)?;
}
let span = (start.max(first).max(now), end.min(last));
return Ok((span.0 < span.1).then_some(span));
}
if let Some(exact) = &range.exact {
return Ok((exact.schedule_id == schedule.id).then_some((exact.start.max(now), exact.end)));
}
if matches!(schedule.kind, ScheduleKind::Priority { .. }) && !range.replace_projects {
return Ok(None);
}
Ok(Some((
engine::schedule_handover(doc, schedule.id, range.start)?.max(now),
engine::schedule_handover(doc, schedule.id, range.end + Duration::days(1))?,
)))
}
pub fn plan(
doc: &WorkspaceDocument,
draft: &Draft,
members: &BTreeSet<Uuid>,
now: DateTime<Utc>,
) -> Result<Plan> {
let absence = &draft.absence;
ensure(
absence.start <= absence.end && (absence.end - absence.start).num_days() < 366,
crate::i18n::text("err_choose_an_absence_of_1_366_duty_days_090954"),
)?;
ensure(
absence.end >= engine::duty_day(doc, now)?
&& absence.end <= now.date_naive() + Duration::days(366),
crate::i18n::text("err_absence_must_end_within_the_next_year_767689"),
)?;
ensure(
!doc.absences.iter().any(|a| {
a.id == absence.id
|| (a.user_id == absence.user_id
&& a.start <= absence.end
&& absence.start <= a.end)
}),
crate::i18n::text("err_this_person_already_has_an_overlapping_absence_f17a77"),
)?;
ensure(
draft.ranges.len() <= 24,
crate::i18n::text("err_use_at_most_24_replacement_ranges_52203c"),
)?;
let mut candidate = doc.clone();
candidate.absences.push(absence.clone());
candidate.validate(members)?;
let people = candidate.person_ids(members);
let mut ranges = draft.ranges.clone();
ranges.sort_by_key(|r| r.start);
for range in &ranges {
if let Some(local) = &range.local {
ensure(
range.exact.is_none(),
crate::i18n::text("absence_invalid_interval"),
)?;
if let Some(id) = local.schedule_id {
ensure(
doc.schedule(id).is_some(),
crate::i18n::text("err_schedule_does_not_exist_637210"),
)?;
}
let start = engine::local_time(doc, local.start.date(), local.start.time())?;
let end = engine::local_time(doc, local.end.date(), local.end.time())?;
ensure(
start < end
&& end - start <= Duration::days(90)
&& local.start.date() >= absence.start
&& local.end.date() <= absence.end + Duration::days(1),
crate::i18n::text("absence_invalid_interval"),
)?;
} else {
ensure(
range.start >= absence.start
&& range.end <= absence.end
&& range.start <= range.end
&& (range.end - range.start).num_days() < 90,
crate::i18n::text("err_each_replacement_range_must_cover_1_90_duty_days_f18f21"),
)?;
}
if let Some(exact) = &range.exact {
ensure(
doc.schedule(exact.schedule_id).is_some(),
crate::i18n::text("err_schedule_does_not_exist_637210"),
)?;
ensure(
exact.start < exact.end
&& exact.start
>= engine::schedule_handover(doc, exact.schedule_id, range.start)?
&& exact.end
<= engine::schedule_handover(
doc,
exact.schedule_id,
range.end + Duration::days(1),
)?,
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
)?;
}
ensure(
range.replacement != absence.user_id && people.contains(&range.replacement),
crate::i18n::text("err_choose_a_different_workspace_user_or_email_conta_a0a168"),
)?;
}
for (i, range) in ranges.iter().enumerate() {
for other in &ranges[..i] {
for schedule in &doc.schedules {
if let (Some((a, b)), Some((c, d))) = (
range_interval(doc, range, schedule, now, absence)?,
range_interval(doc, other, schedule, now, absence)?,
) {
ensure(
b <= c || d <= a,
crate::i18n::text("err_replacement_ranges_must_not_overlap_bf6aad"),
)?;
}
}
}
}
let mut plan = Plan {
rows: vec![],
groups: vec![],
warnings: vec![],
errors: vec![],
schedules: 0,
uncovered: 0,
};
for range in ranges {
let mut legs = vec![];
for schedule in &doc.schedules {
let Some((start, end)) = range_interval(doc, &range, schedule, now, absence)? else {
continue;
};
if start >= end {
continue;
}
let before = engine::timeline(doc, start, end)?;
for assignment in before
.assignments
.iter()
.filter(|a| a.schedule_id == schedule.id && a.user_id == absence.user_id)
{
legs.push(ReplacementLeg {
schedule_id: schedule.id,
start: assignment.start,
end: assignment.end,
from_user: absence.user_id,
to_user: range.replacement,
});
}
}
if legs.is_empty() {
continue;
}
let request = synthetic_request(absence.id, legs.clone(), now);
match validate_request(&candidate, &request, members, now) {
Ok(()) => {
engine::supersede_overrides(&mut candidate, &request.legs);
candidate.requests.push(request);
plan.groups.push(legs);
}
Err(error) => plan.errors.push(format!(
"{} – {}: {error}",
range.start.format("%d-%m-%Y"),
range.end.format("%d-%m-%Y")
)),
}
}
for schedule in &doc.schedules {
let start = engine::schedule_handover(doc, schedule.id, absence.start)?.max(now);
let end = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?;
if start >= end {
continue;
}
let before = engine::timeline(doc, start, end)?;
let after = engine::timeline(&candidate, start, end)?;
let old: Vec<_> = before
.assignments
.iter()
.filter(|a| a.schedule_id == schedule.id)
.collect();
let new: Vec<_> = after
.assignments
.iter()
.filter(|a| a.schedule_id == schedule.id)
.collect();
let mut bounds = BTreeSet::from([start, end]);
for a in old.iter().chain(&new) {
bounds.insert(a.start);
bounds.insert(a.end);
}
let bounds: Vec<_> = bounds.into_iter().collect();
for pair in bounds.windows(2) {
let a = old
.iter()
.find(|a| a.start <= pair[0] && pair[0] < a.end)
.unwrap_or_else(|| {
panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844"))
});
let b = new
.iter()
.find(|a| a.start <= pair[0] && pair[0] < a.end)
.unwrap_or_else(|| {
panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844"))
});
if a.user_id != absence.user_id
&& a.user_id == b.user_id
&& a.unavailable == b.unavailable
{
continue;
}
let proposed = plan
.groups
.iter()
.flatten()
.any(|l| l.schedule_id == schedule.id && l.start <= pair[0] && pair[0] < l.end);
let status = if b.unavailable {
"uncovered"
} else if proposed {
"replacement"
} else {
"automatic"
};
let after = (!b.unavailable).then_some(b.user_id);
if let Some(last) = plan.rows.last_mut()
&& last.schedule_id == schedule.id
&& last.end == pair[0]
&& last.before == a.user_id
&& last.after == after
&& last.status == status
{
last.end = pair[1];
last.end_day = engine::schedule_duty_day(
doc,
schedule.id,
pair[1] - Duration::nanoseconds(1),
)?;
continue;
}
plan.rows.push(Impact {
start_day: engine::schedule_duty_day(doc, schedule.id, pair[0])?,
end_day: engine::schedule_duty_day(
doc,
schedule.id,
pair[1] - Duration::nanoseconds(1),
)?,
schedule_id: schedule.id,
schedule_name: schedule.name.clone(),
start: pair[0],
end: pair[1],
before: a.user_id,
after,
status: status.into(),
});
}
}
plan.schedules = plan
.rows
.iter()
.map(|r| r.schedule_id)
.collect::<BTreeSet<_>>()
.len();
plan.uncovered = plan.rows.iter().filter(|r| r.after.is_none()).count();
for policy in &doc.escalations {
if policy.steps.iter().any(|s| {
matches!(s.recipient,Recipient::User{id} if id==absence.user_id)
|| matches!(s.recipient, Recipient::TeamAll | Recipient::TeamRandom)
}) {
plan.warnings.push(crate::i18n::message(
"err__p0_direct_user_team_notifications_are_not_chang_66a117",
&[("p0", (policy.name).to_string())],
));
}
}
Ok(plan)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (
WorkspaceDocument,
Draft,
BTreeSet<Uuid>,
DateTime<Utc>,
[Uuid; 3],
) {
let people = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
let mut doc = WorkspaceDocument::new("Platform".into());
doc.timezone = "UTC".into();
let start: NaiveDate = "2027-01-04".parse().unwrap();
let rotation = Uuid::new_v4();
doc.schedules.push(Schedule {
id: rotation,
name: "General".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![people[0]],
anchor: start,
shift_days: 7,
},
});
doc.schedules.push(Schedule {
id: Uuid::new_v4(),
name: "Backup".into(),
handover: "09:00:00".parse().unwrap(),
kind: ScheduleKind::Rotation {
participants: vec![people[0]],
anchor: "2026-09-21".parse().unwrap(),
shift_days: 7,
},
});
for i in 0..5 {
doc.schedules.push(Schedule {
id: Uuid::new_v4(),
name: format!("Project {i}"),
handover: default_handover(),
kind: ScheduleKind::Priority {
participants: vec![people[0], people[2]],
fallback: rotation,
},
});
}
let draft = Draft {
absence: Absence {
id: Uuid::new_v4(),
user_id: people[0],
start,
end: start + Duration::days(13),
},
ranges: vec![
CoverRange {
local: None,
exact: None,
start,
end: start + Duration::days(6),
replacement: people[1],
replace_projects: false,
},
CoverRange {
local: None,
exact: None,
start: start + Duration::days(7),
end: start + Duration::days(13),
replacement: people[2],
replace_projects: false,
},
],
};
(
doc,
draft,
people.into(),
"2027-01-01T00:00:00Z".parse().unwrap(),
people,
)
}
#[test]
fn two_ranges_cover_all_rotations_and_backups_and_promote_all_projects() {
let (doc, draft, people, now, ids) = fixture();
let plan = plan(&doc, &draft, &people, now).unwrap();
assert!(plan.errors.is_empty());
assert_eq!(plan.schedules, 7);
assert_eq!(plan.uncovered, 0);
assert_eq!(plan.groups.len(), 2);
assert_eq!(plan.groups[0].len(), 2);
assert_eq!(plan.groups[1].len(), 2);
assert!(plan.groups[0].iter().all(|l| l.to_user == ids[1]));
assert!(plan.groups[1].iter().all(|l| l.to_user == ids[2]));
assert!(
plan.rows
.iter()
.filter(|r| r.schedule_name.starts_with("Project"))
.all(|r| r.after == Some(ids[2]) && r.status == "automatic")
);
assert!(doc.absences.is_empty());
assert!(doc.requests.is_empty());
assert!(
plan.groups[0]
.iter()
.any(|l| l.start.time().to_string() == "09:00:00")
);
}
#[test]
fn uncovered_ranges_unavailable_substitutes_and_explicit_project_cover_are_distinct() {
let (mut doc, mut draft, people, now, ids) = fixture();
draft.ranges.clear();
let uncovered = plan(&doc, &draft, &people, now).unwrap();
assert_eq!(uncovered.uncovered, 2);
assert_eq!(uncovered.groups.len(), 0);
draft.ranges.push(CoverRange {
local: None,
exact: None,
start: draft.absence.start,
end: draft.absence.end,
replacement: ids[1],
replace_projects: true,
});
let covered = plan(&doc, &draft, &people, now).unwrap();
assert_eq!(covered.groups[0].len(), 7);
assert_eq!(covered.uncovered, 0);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: ids[1],
start: draft.absence.start,
end: draft.absence.start,
});
assert!(!plan(&doc, &draft, &people, now).unwrap().errors.is_empty());
draft.ranges.push(draft.ranges[0].clone());
assert!(plan(&doc, &draft, &people, now).is_err());
}
#[test]
fn planned_substitutions_preserve_other_owners_and_can_replace_current_cover() {
let (mut doc, draft, people, now, ids) = fixture();
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
participants.push(ids[1]);
}
let covered = plan(&doc, &draft, &people, now).unwrap();
assert_eq!(
covered.groups[1].len(),
1,
"Second week already belongs to someone else on the rotation"
);
let mut existing = synthetic_request(
Uuid::new_v4(),
vec![ReplacementLeg {
schedule_id: doc.schedules[0].id,
start: covered.groups[0][0].start,
end: covered.groups[0][0].end,
from_user: ids[1],
to_user: ids[0],
}],
now,
);
existing.absence_id = None;
doc.requests.push(existing);
let preview = plan(&doc, &draft, &people, now).unwrap();
assert!(preview.errors.is_empty());
assert_eq!(preview.uncovered, 0);
assert!(
preview
.groups
.iter()
.flatten()
.all(|leg| leg.from_user == ids[0])
);
assert!(
doc.requests[0].effective_legs.is_none(),
"Preview must not modify the approved request"
);
}
#[test]
fn local_ranges_share_scoping_bounds_and_reject_dst_discontinuities() {
let (mut doc, mut draft, people, now, ids) = fixture();
draft.ranges = vec![CoverRange {
local: Some(LocalCover {
schedule_id: Some(doc.schedules[0].id),
start: "2027-01-04T12:30:00".parse().unwrap(),
end: "2027-01-05T14:00:00".parse().unwrap(),
}),
exact: None,
start: draft.absence.start,
end: draft.absence.end,
replacement: ids[1],
replace_projects: false,
}];
let result = plan(&doc, &draft, &people, now).unwrap();
assert_eq!(result.groups[0].len(), 1);
assert_eq!(
result.groups[0][0].start,
"2027-01-04T12:30:00Z".parse::<DateTime<Utc>>().unwrap()
);
draft.ranges[0].local.as_mut().unwrap().schedule_id = None;
assert_eq!(plan(&doc, &draft, &people, now).unwrap().groups[0].len(), 2);
draft.ranges.push(draft.ranges[0].clone());
assert!(plan(&doc, &draft, &people, now).is_err());
draft.ranges.pop();
doc.timezone = "Europe/Nicosia".into();
for date in ["2027-03-28", "2027-10-31"] {
let day: NaiveDate = date.parse().unwrap();
draft.absence.start = day - Duration::days(1);
draft.absence.end = day + Duration::days(1);
let local = draft.ranges[0].local.as_mut().unwrap();
local.start = day.and_hms_opt(3, 30, 0).unwrap();
local.end = day.and_hms_opt(5, 0, 0).unwrap();
assert!(plan(&doc, &draft, &people, now).is_err());
}
}
#[test]
fn exact_impact_cover_is_scoped_and_rejects_overlaps_or_outside_bounds() {
let (doc, mut draft, people, now, ids) = fixture();
draft.ranges.clear();
let impact = plan(&doc, &draft, &people, now).unwrap();
let gaps: Vec<_> = impact
.rows
.iter()
.filter(|r| r.status == "uncovered")
.collect();
assert!(gaps.len() >= 2);
let gap = gaps[0];
assert_eq!(
gap.end_day, draft.absence.end,
"Merged impact retains its final duty day"
);
let exact = ExactCover {
schedule_id: gap.schedule_id,
start: gap.start + Duration::hours(2),
end: gap.end - Duration::hours(2),
};
draft.ranges.push(CoverRange {
local: None,
start: gap.start_day,
end: gap.end_day,
replacement: ids[1],
replace_projects: false,
exact: Some(exact.clone()),
});
let proposed = plan(&doc, &draft, &people, now).unwrap();
assert!(proposed.errors.is_empty());
assert_eq!(proposed.groups[0].len(), 1);
let leg = &proposed.groups[0][0];
assert_eq!(
(leg.schedule_id, leg.start, leg.end),
(exact.schedule_id, exact.start, exact.end)
);
let other = gaps[1];
draft.ranges.push(CoverRange {
local: None,
start: other.start_day,
end: other.end_day,
replacement: ids[2],
replace_projects: false,
exact: Some(ExactCover {
schedule_id: other.schedule_id,
start: other.start,
end: other.end,
}),
});
assert!(plan(&doc, &draft, &people, now).unwrap().errors.is_empty());
draft.ranges.push(draft.ranges[0].clone());
assert!(plan(&doc, &draft, &people, now).is_err());
draft.ranges.pop();
draft.ranges[0].exact.as_mut().unwrap().start = gap.start - Duration::hours(1);
assert!(plan(&doc, &draft, &people, now).is_err());
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Read-only audit presentation; persisted event payloads are never rewritten.
use crate::domain::WorkspaceDocument;
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub type Names = BTreeMap<String, String>;
pub fn remember(names: &mut Names, doc: &WorkspaceDocument) {
for person in &doc.people {
names.insert(person.id.to_string(), person.display_name.clone());
if let Some(id) = person.user_id {
names.insert(id.to_string(), person.display_name.clone());
}
}
for (id, name) in doc
.schedules
.iter()
.map(|s| (s.id, &s.name))
.chain(doc.escalations.iter().map(|s| (s.id, &s.name)))
.chain(doc.routes.iter().map(|s| (s.id, &s.name)))
.chain(doc.generation.rules.iter().map(|s| (s.id, &s.name)))
.chain(doc.generation.variables.iter().map(|s| (s.id, &s.name)))
{
names.insert(id.to_string(), name.clone());
}
}
pub fn resolve(value: &Value, names: &Names) -> Value {
match value {
Value::String(id) => names
.get(id)
.map(|name| json!({"name":name,"id":id}))
.unwrap_or_else(|| value.clone()),
Value::Array(items) => Value::Array(items.iter().map(|v| resolve(v, names)).collect()),
Value::Object(fields) => Value::Object(
fields
.iter()
.map(|(key, value)| {
// Free text must remain verbatim, even if a comment happens to equal a UUID.
let reference = key == "id"
|| key.ends_with("_id")
|| matches!(
key.as_str(),
"from_user"
| "to_user"
| "requested_by"
| "forced_by"
| "approvals"
| "required_approvers"
| "participants"
| "primary"
| "secondary"
| "fallback"
| "user"
| "redirect_to"
| "escalation"
| "variable"
| "iteration"
| "output"
);
let resolved = if reference || value.is_object() || value.is_array() {
resolve(value, names)
} else {
value.clone()
};
(key.clone(), resolved)
})
.collect(),
),
_ => value.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn references_expand_without_changing_comments_unknown_ids_or_raw_details() {
let id = uuid::Uuid::new_v4().to_string();
let names = Names::from([(id.clone(), "Alice".into())]);
let raw = json!({"from_user":id,"approvals":[id],"reason":id,"unknown_id":"missing","legs":[{"to_user":id}]});
let resolved = resolve(&raw, &names);
assert_eq!(resolved["from_user"]["name"], "Alice");
assert_eq!(resolved["approvals"][0]["id"], id);
assert_eq!(resolved["legs"][0]["to_user"]["name"], "Alice");
assert_eq!(resolved["reason"], id);
assert_eq!(resolved["unknown_id"], "missing");
assert_eq!(raw["from_user"], id);
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Pure edits of the configuration graph. No storage or provider side effects.
use crate::{
domain::*,
error::{Error, Result, ensure},
};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::BTreeSet;
use uuid::Uuid;
#[derive(Debug, Serialize)]
pub struct NamedResource {
pub id: Uuid,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct EscalationUse {
pub policy_id: Uuid,
pub policy_name: String,
pub step_number: usize,
pub delay_minutes: u16,
}
#[derive(Debug, Serialize)]
pub struct ScheduleRemovalPlan {
pub schedule: NamedResource,
pub escalation_steps: Vec<EscalationUse>,
pub coverage_fallbacks: Vec<NamedResource>,
pub policies_emptied: Vec<NamedResource>,
pub pending_requests: Vec<Uuid>,
pub active_replacements: Vec<Uuid>,
}
pub fn schedule_removal_plan(
doc: &WorkspaceDocument,
id: Uuid,
now: DateTime<Utc>,
) -> Result<ScheduleRemovalPlan> {
ensure(
!doc.generation.managed.iter().any(|m| m.id == id),
crate::i18n::text("err_this_schedule_is_generated_remove_it_through_var_6da973"),
)?;
let schedule = doc.schedule(id).ok_or(Error::NotFound)?;
let mut plan = ScheduleRemovalPlan {
schedule: NamedResource {
id,
name: schedule.name.clone(),
},
escalation_steps: vec![],
coverage_fallbacks: vec![],
policies_emptied: vec![],
pending_requests: vec![],
active_replacements: vec![],
};
for policy in &doc.escalations {
let mut removed = 0;
for (index, step) in policy.steps.iter().enumerate() {
if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) {
removed += 1;
plan.escalation_steps.push(EscalationUse {
policy_id: policy.id,
policy_name: policy.name.clone(),
step_number: index + 1,
delay_minutes: step.delay_minutes,
});
}
}
if removed > 0 && removed == policy.steps.len() {
plan.policies_emptied.push(NamedResource {
id: policy.id,
name: policy.name.clone(),
});
}
}
for schedule in &doc.schedules {
if matches!(schedule.kind, ScheduleKind::Priority { fallback,.. } if fallback==id) {
plan.coverage_fallbacks.push(NamedResource {
id: schedule.id,
name: schedule.name.clone(),
});
}
}
for request in &doc.requests {
if request.status == RequestStatus::Pending
&& request.legs.iter().any(|leg| leg.schedule_id == id)
{
plan.pending_requests.push(request.id);
}
if request.status == RequestStatus::Applied
&& request
.effective_legs()
.iter()
.any(|leg| leg.schedule_id == id && leg.end > now)
{
plan.active_replacements.push(request.id);
}
}
Ok(plan)
}
pub fn remove_schedule(
doc: &WorkspaceDocument,
id: Uuid,
redirect: Option<Uuid>,
now: DateTime<Utc>,
) -> Result<WorkspaceDocument> {
let plan = schedule_removal_plan(doc, id, now)?;
ensure(
plan.active_replacements.is_empty(),
crate::i18n::text("err_this_schedule_has_current_or_future_approved_rep_49a098"),
)?;
if let Some(target) = redirect {
ensure(
target != id && doc.schedule(target).is_some(),
crate::i18n::text("err_choose_a_different_existing_schedule_for_the_ref_ea3955"),
)?;
} else {
ensure(
plan.coverage_fallbacks.is_empty(),
crate::i18n::message(
"err_choose_a_replacement_fallback_for_p0_6e17d4",
&[(
"p0",
(plan
.coverage_fallbacks
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", "))
.to_string(),
)],
),
)?;
ensure(
plan.policies_emptied.is_empty(),
crate::i18n::message(
"err_choose_a_replacement_schedule_or_edit_these_poli_f9b0c9",
&[(
"p0",
(plan
.policies_emptied
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", "))
.to_string(),
)],
),
)?;
}
let mut result = doc.clone();
result.schedules.retain(|s| s.id != id);
for policy in &mut result.escalations {
policy.steps.retain_mut(|step| {
if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) {
if let Some(target) = redirect {
step.recipient = Recipient::Schedule { id: target };
} else {
return false;
}
}
true
});
}
for schedule in &mut result.schedules {
if let ScheduleKind::Priority { fallback, .. } = &mut schedule.kind
&& *fallback == id
{
*fallback = redirect.expect("A fallback redirect was validated above");
}
}
for request in &mut result.requests {
if plan.pending_requests.contains(&request.id) {
request.status = RequestStatus::Cancelled;
}
}
crate::generation::validate_manual_edit(doc, &mut result)?;
Ok(result)
}
pub fn reorder_routes(doc: &mut WorkspaceDocument, ids: &[Uuid]) -> Result<()> {
let expected: BTreeSet<_> = doc.routes.iter().map(|r| r.id).collect();
let supplied: BTreeSet<_> = ids.iter().copied().collect();
ensure(
ids.len() <= 500
&& ids.len() == doc.routes.len()
&& supplied.len() == ids.len()
&& supplied == expected,
crate::i18n::text("err_the_ordered_list_must_contain_every_current_rout_a7854b"),
)?;
let mut routes = Vec::with_capacity(ids.len());
for (index, id) in ids.iter().enumerate() {
let mut route = doc
.routes
.iter()
.find(|r| r.id == *id)
.unwrap_or_else(|| panic!("{}", crate::i18n::text("err_validated_permutation_74a9c2")))
.clone();
route.order = index as u16;
routes.push(route);
}
doc.routes = routes;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn fixture() -> (WorkspaceDocument, Uuid, Uuid, Uuid) {
let mut doc = WorkspaceDocument::new("Platform".into());
let primary = Uuid::new_v4();
let secondary = Uuid::new_v4();
let person = Uuid::new_v4();
doc.schedules = [(primary, "Primary"), (secondary, "Secondary")]
.into_iter()
.map(|(id, name)| Schedule {
id,
name: name.into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![person],
anchor: "2026-09-21".parse().unwrap(),
shift_days: 7,
},
})
.collect();
doc.escalations.push(Escalation {
id: Uuid::new_v4(),
name: "Payments escalation".into(),
steps: vec![
EscalationStep {
delay_minutes: 0,
condition: EscalationCondition::IfNotAcked,
recipient: Recipient::Schedule { id: primary },
},
EscalationStep {
delay_minutes: 10,
condition: EscalationCondition::IfNotAcked,
recipient: Recipient::Schedule { id: secondary },
},
],
repeat: Default::default(),
});
(doc, primary, secondary, person)
}
#[test]
fn remove_backup_prunes_only_its_steps_and_cancels_pending_requests_as_a_whole() {
let (mut doc, primary, secondary, person) = fixture();
let now = DateTime::parse_from_rfc3339("2027-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut request = ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: person,
created_at: now,
reason: "Swap".into(),
legs: vec![
ReplacementLeg {
schedule_id: secondary,
start: now + Duration::days(1),
end: now + Duration::days(2),
from_user: person,
to_user: Uuid::new_v4(),
},
ReplacementLeg {
schedule_id: primary,
start: now + Duration::days(8),
end: now + Duration::days(9),
from_user: Uuid::new_v4(),
to_user: person,
},
],
required_approvers: Default::default(),
approvals: Default::default(),
status: RequestStatus::Pending,
applied_at: None,
forced_by: None,
force_reason: None,
};
doc.requests.push(request.clone());
let plan = schedule_removal_plan(&doc, secondary, now).unwrap();
assert_eq!(plan.escalation_steps[0].step_number, 2);
assert_eq!(plan.pending_requests, vec![request.id]);
let removed = remove_schedule(&doc, secondary, None, now).unwrap();
assert_eq!(doc.schedules.len(), 2, "Input remains unchanged");
assert_eq!(removed.schedules.len(), 1);
assert_eq!(removed.escalations[0].steps.len(), 1);
assert_eq!(removed.requests[0].status, RequestStatus::Cancelled);
assert_eq!(removed.requests[0].legs.len(), 2);
request.status = RequestStatus::Applied;
doc.requests[0] = request;
assert!(
remove_schedule(&doc, secondary, Some(primary), now).is_err(),
"Redirecting references cannot rewrite an approved replacement"
);
let past = remove_schedule(&doc, secondary, None, now + Duration::days(10)).unwrap();
assert_eq!(past.requests[0].status, RequestStatus::Applied);
}
#[test]
fn last_policy_step_and_coverage_fallback_require_an_explicit_valid_redirect() {
let (mut doc, primary, secondary, person) = fixture();
let now = Utc::now();
doc.escalations[0].steps.remove(0);
assert!(remove_schedule(&doc, secondary, None, now).is_err());
let redirect = remove_schedule(&doc, secondary, Some(primary), now).unwrap();
assert!(
matches!(redirect.escalations[0].steps[0].recipient,Recipient::Schedule{id} if id==primary)
);
doc.schedules[0].kind = ScheduleKind::Priority {
participants: vec![person, Uuid::new_v4()],
fallback: secondary,
};
assert_eq!(
schedule_removal_plan(&doc, secondary, now)
.unwrap()
.coverage_fallbacks
.len(),
1
);
let cycle = remove_schedule(&doc, secondary, Some(primary), now).unwrap();
let people = match &doc.schedules[0].kind {
ScheduleKind::Priority { participants, .. } => participants.iter().copied().collect(),
_ => unreachable!(),
};
assert!(
cycle.validate(&people).is_err(),
"The service must validate the rewritten graph before committing"
);
assert!(remove_schedule(&doc, secondary, Some(Uuid::new_v4()), now).is_err());
}
#[test]
fn order_requires_an_exact_permutation_and_preserves_rule_meaning() {
let mut doc = WorkspaceDocument::new("Team".into());
let first = Uuid::new_v4();
let second = Uuid::new_v4();
doc.routes = vec![
RoutingRule {
id: first,
name: "Specific".into(),
order: 10,
conditions: vec![RouteCondition {
field: "tags".into(),
operation: "contains".into(),
value: "prod".into(),
negate: false,
}],
window: None,
escalation_id: None,
},
RoutingRule {
id: second,
name: "Fallback".into(),
order: 9999,
conditions: vec![],
window: None,
escalation_id: None,
},
];
let before = serde_json::to_value(&doc.routes).unwrap();
for ids in [vec![first], vec![first, first], vec![first, Uuid::new_v4()]] {
assert!(reorder_routes(&mut doc, &ids).is_err());
assert_eq!(serde_json::to_value(&doc.routes).unwrap(), before);
}
reorder_routes(&mut doc, &[second, first]).unwrap();
assert_eq!(doc.routes[0].id, second);
assert_eq!(doc.routes[0].order, 0);
assert_eq!(doc.routes[1].order, 1);
assert_eq!(doc.routes[1].conditions[0].value, "prod");
}
}
+34
View File
@@ -0,0 +1,34 @@
country,date,name
CY,2026-01-01,New Year's Day
CY,2026-01-06,Epiphany
CY,2026-02-23,Green Monday
CY,2026-03-25,Greek Independence Day
CY,2026-04-01,Cyprus National Day
CY,2026-04-10,Orthodox Good Friday
CY,2026-04-13,Orthodox Easter Monday
CY,2026-05-01,Labour Day
CY,2026-06-01,Whit Monday
CY,2026-08-15,Assumption Day
CY,2026-10-01,Cyprus Independence Day
CY,2026-10-28,Ohi Day
CY,2026-12-24,Christmas Eve
CY,2026-12-25,Christmas Day
CY,2026-12-26,Boxing Day
JP,2026-01-01,New Year's Day
JP,2026-01-12,Coming of Age Day
JP,2026-02-11,National Foundation Day
JP,2026-02-23,Emperor's Birthday
JP,2026-03-20,Vernal Equinox Day
JP,2026-04-29,Showa Day
JP,2026-05-03,Constitution Memorial Day
JP,2026-05-04,Greenery Day
JP,2026-05-05,Children's Day
JP,2026-05-06,Substitute Holiday
JP,2026-07-20,Marine Day
JP,2026-08-11,Mountain Day
JP,2026-09-21,Respect for the Aged Day
JP,2026-09-22,Citizens' Holiday
JP,2026-09-23,Autumnal Equinox Day
JP,2026-10-12,Sports Day
JP,2026-11-03,Culture Day
JP,2026-11-23,Labour Thanksgiving Day
1 country date name
2 CY 2026-01-01 New Year's Day
3 CY 2026-01-06 Epiphany
4 CY 2026-02-23 Green Monday
5 CY 2026-03-25 Greek Independence Day
6 CY 2026-04-01 Cyprus National Day
7 CY 2026-04-10 Orthodox Good Friday
8 CY 2026-04-13 Orthodox Easter Monday
9 CY 2026-05-01 Labour Day
10 CY 2026-06-01 Whit Monday
11 CY 2026-08-15 Assumption Day
12 CY 2026-10-01 Cyprus Independence Day
13 CY 2026-10-28 Ohi Day
14 CY 2026-12-24 Christmas Eve
15 CY 2026-12-25 Christmas Day
16 CY 2026-12-26 Boxing Day
17 JP 2026-01-01 New Year's Day
18 JP 2026-01-12 Coming of Age Day
19 JP 2026-02-11 National Foundation Day
20 JP 2026-02-23 Emperor's Birthday
21 JP 2026-03-20 Vernal Equinox Day
22 JP 2026-04-29 Showa Day
23 JP 2026-05-03 Constitution Memorial Day
24 JP 2026-05-04 Greenery Day
25 JP 2026-05-05 Children's Day
26 JP 2026-05-06 Substitute Holiday
27 JP 2026-07-20 Marine Day
28 JP 2026-08-11 Mountain Day
29 JP 2026-09-21 Respect for the Aged Day
30 JP 2026-09-22 Citizens' Holiday
31 JP 2026-09-23 Autumnal Equinox Day
32 JP 2026-10-12 Sports Day
33 JP 2026-11-03 Culture Day
34 JP 2026-11-23 Labour Thanksgiving Day
+373
View File
@@ -0,0 +1,373 @@
//! Small opt-in development examples, activated through the real generator service.
use crate::{
domain::*,
error::Result,
generation::{Node, Rule, Variable},
generation_service::{self, Draft},
models::{UserRow, WorkspaceRow},
service,
store::{self, Store},
};
use chrono::{Datelike, Duration, NaiveDate, Utc};
use cot::db::{Model, query};
use serde_json::{Value, json};
use uuid::Uuid;
fn node(value: Value) -> Node {
let (kind, entries) = match &value {
Value::Array(items) => ("list", items.iter().cloned().map(node).collect()),
Value::Object(items) => (
"dictionary",
items
.iter()
.map(|(key, value)| {
let mut n = node(value.clone());
n.key = key.clone();
n
})
.collect(),
),
Value::String(s) if Uuid::parse_str(s).is_ok() || s.contains('@') => ("person", vec![]),
Value::String(_) => ("text", vec![]),
Value::Number(_) => ("number", vec![]),
_ => ("boolean", vec![]),
};
Node {
id: Uuid::new_v4(),
kind: kind.into(),
key: String::new(),
value: if entries.is_empty() {
value
} else {
Value::Null
},
entries,
}
}
fn variable(name: &str, value: Value) -> Variable {
Variable {
id: Uuid::new_v4(),
name: name.into(),
node: node(value),
}
}
fn fixed(value: impl serde::Serialize) -> Value {
json!({"kind":"literal","value":value})
}
fn reference(id: Uuid) -> Value {
json!({"kind":"variable","variable":id})
}
fn output(id: Uuid) -> Value {
json!({"kind":"output","output":id})
}
fn item(id: Uuid, suffix: &str) -> Value {
json!({"kind":"item","iteration":id,"suffix":suffix})
}
fn step(delay: u16, id: Uuid) -> Value {
json!({"delay":fixed(delay),"recipient":output(id),"condition":"if-not-acked"})
}
/// Projects keep fixed responsibility priority; one weekly rotation covers off hours.
pub fn project_matrix(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec<Variable>, Vec<Rule>) {
let projects = variable("projects", json!(["Payments", "Search", "Billing"]));
let responsible = variable(
"responsible",
json!({"Payments":[alice,bob,"sam@example.com"],"Search":[bob,alice],"Billing":[alice,"sam@example.com",bob]}),
);
let on_call = variable("on_call", json!([alice, bob]));
let duty = Uuid::new_v4();
let general = Uuid::new_v4();
let iteration = Uuid::new_v4();
let coverage = Uuid::new_v4();
let policy = Uuid::new_v4();
let rules = json!([
{"id":Uuid::new_v4(),"name":"Weekly Time off","enabled":true,"blocks":[
{"id":duty,"kind":"rotation","fields":{"name":fixed("Time off"),"participants":reference(on_call.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}},
{"id":general,"kind":"escalation","fields":{"name":fixed("Time off")},"steps":[step(0,duty)]}
]},
{"id":Uuid::new_v4(),"name":"Project owners during working hours","enabled":true,"iterations":[{"id":iteration,"name":"Project","source":reference(projects.id)}],"blocks":[
{"id":coverage,"kind":"priority","fields":{"name":item(iteration," · owners"),"participants":{"kind":"lookup","variable":responsible.id,"key":item(iteration,"")},"fallback":output(duty),"handover":fixed("11:00")}},
{"id":policy,"kind":"escalation","fields":{"name":item(iteration," · daytime")},"steps":[step(0,coverage)]},
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · working hours"),"escalation":output(policy),"working":fixed(true)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"project:"}}]}
]},
{"id":Uuid::new_v4(),"name":"Off hours and unmatched alerts","enabled":true,"blocks":[
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → Time off"),"escalation":output(general),"working":fixed(false)}}
]}
]);
(
vec![projects, responsible, on_call],
serde_json::from_value(rules).expect("typed demo rules"),
)
}
/// Main interactive demo: six services with distinct owners and five real workspace members.
pub fn engineering_matrix(monday: NaiveDate, people: [Uuid; 5]) -> (Vec<Variable>, Vec<Rule>) {
let [alice, bob, carol, dave, erin] = people;
let (mut variables, rules) = project_matrix(monday, alice, bob);
variables[0].node = node(json!([
{"name":"Payments","responsible":[alice,bob,dave]},
{"name":"Search","responsible":[bob,erin]},
{"name":"Billing","responsible":[carol,alice,erin]},
{"name":"Identity","responsible":[dave,carol]},
{"name":"Data platform","responsible":[erin,dave,bob]},
{"name":"Developer portal","responsible":[carol,erin]}
]));
variables.remove(1);
variables[1].node = node(json!(people));
let mut rules = serde_json::to_value(rules).expect("serializable demo rules");
let iteration = serde_json::from_value(rules[1]["iterations"][0]["id"].clone())
.expect("demo iteration UUID");
for block in rules[1]["blocks"].as_array_mut().expect("demo blocks") {
block["fields"]["name"]["path"] = json!(["name"]);
}
rules[1]["blocks"][0]["fields"]["participants"] = field(iteration, &["responsible"]);
rules[1]["blocks"][2]["conditions"][0]["value"]["path"] = json!(["name"]);
let rules = serde_json::from_value(rules).expect("nested project demo rules");
(variables, rules)
}
/// A separate tenant demonstrates daily coverage and a delayed escalation step.
pub fn support_queues(anchor: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variable>, Vec<Rule>) {
let queues = variable("queues", json!(["Customers", "Partners"]));
let agents = variable("agents", json!([carol, alice]));
let lead = variable("lead", json!(alice));
let backup = Uuid::new_v4();
let iteration = Uuid::new_v4();
let rotation = Uuid::new_v4();
let policy = Uuid::new_v4();
let rules = json!([
{"id":Uuid::new_v4(),"name":"Support lead","enabled":true,"blocks":[{"id":backup,"kind":"rotation","fields":{"name":fixed("Support lead"),"participants":{"kind":"list","items":[reference(lead.id)]},"anchor":fixed(anchor),"shift_days":fixed(7),"handover":fixed("09:00")}}]},
{"id":Uuid::new_v4(),"name":"Daily queue coverage","enabled":true,"iterations":[{"id":iteration,"name":"Queue","source":reference(queues.id)}],"blocks":[
{"id":rotation,"kind":"rotation","fields":{"name":item(iteration," · daily"),"participants":reference(agents.id),"anchor":fixed(anchor),"shift_days":fixed(1),"handover":fixed("09:00")}},
{"id":policy,"kind":"escalation","fields":{"name":item(iteration," · escalation")},"steps":[step(0,rotation),step(15,backup)]},
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · 24/7"),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"queue:"}}]}
]}
]);
(
vec![queues, agents, lead],
serde_json::from_value(rules).expect("typed demo rules"),
)
}
fn field(iteration: Uuid, path: &[&str]) -> Value {
json!({"kind":"item","iteration":iteration,"path":path})
}
fn lookup(variable: Uuid, key: Value, path: &[&str]) -> Value {
json!({"kind":"lookup","variable":variable,"key":key,"path":path})
}
/// Dependent iterations: each regional portfolio supplies its own services and rota settings.
pub fn regional_platform(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec<Variable>, Vec<Rule>) {
let regions = variable(
"regions",
json!({
"Europe":{"handover":"11:00","shift_days":7,"responders":[alice,bob],"services":[{"name":"EU · Gateway","tag":"eu-gateway","enabled":true},{"name":"EU · Storage","tag":"eu-storage","enabled":true}]},
"Pacific":{"handover":"05:00","shift_days":2,"responders":[bob,alice],"services":[{"name":"Pacific · Gateway","tag":"pacific-gateway","enabled":true},{"name":"Pacific · Sandbox","tag":"pacific-sandbox","enabled":false}]}
}),
);
let commander = variable("incident_commander", json!(alice));
let lead = Uuid::new_v4();
let region = Uuid::new_v4();
let service = Uuid::new_v4();
let rotation = Uuid::new_v4();
let policy = Uuid::new_v4();
let rules = json!([
{"id":Uuid::new_v4(),"name":"Shared incident commander","enabled":true,"blocks":[{"id":lead,"kind":"rotation","fields":{"name":fixed("Incident commander"),"participants":{"kind":"list","items":[reference(commander.id)]},"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}}]},
{"id":Uuid::new_v4(),"name":"Region → enabled services","enabled":true,"iterations":[{"id":region,"name":"Region","source":reference(regions.id)},{"id":service,"name":"Service","source":field(region,&["services"])}],"when":{"left":field(service,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[
{"id":rotation,"kind":"rotation","fields":{"name":field(service,&["name"]),"participants":field(region,&["responders"]),"anchor":fixed(monday),"shift_days":field(region,&["shift_days"]),"handover":field(region,&["handover"])}},
{"id":policy,"kind":"escalation","fields":{"name":field(service,&["name"])},"steps":[step(0,rotation),step(10,lead)]},
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":field(service,&["name"]),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":field(service,&["tag"])}]}
]}
]);
(
vec![regions, commander],
serde_json::from_value(rules).expect("regional demo rules"),
)
}
/// Per-product tier lookup controls coverage windows, escalation delay and repetition.
pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variable>, Vec<Rule>) {
let products = variable(
"products",
json!({
"Checkout":{"tier":"critical","owners":[carol,alice],"enabled":true},
"Analytics":{"tier":"standard","owners":[alice,carol],"enabled":true},
"Merchant portal":{"tier":"standard","owners":[carol,"lee@example.com",alice],"enabled":true},
"Legacy export":{"tier":"retired","owners":[alice],"enabled":false}
}),
);
let tiers = variable(
"service_tiers",
json!({"critical":{"working_only":false,"delay":5,"repeats":2,"wait":10},"standard":{"working_only":true,"delay":30,"repeats":0,"wait":0}}),
);
let duty = variable("shared_on_call", json!([alice, carol]));
let rotation = Uuid::new_v4();
let general = Uuid::new_v4();
let product = Uuid::new_v4();
let owners = Uuid::new_v4();
let policy = Uuid::new_v4();
let tier = |path: &str| lookup(tiers.id, field(product, &["tier"]), &[path]);
let name = || json!({"kind":"key","iteration":product});
let rules = json!([
{"id":Uuid::new_v4(),"name":"Shared weekly safety net","enabled":true,"blocks":[
{"id":rotation,"kind":"rotation","fields":{"name":fixed("Shared time off"),"participants":reference(duty.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}},
{"id":general,"kind":"escalation","fields":{"name":fixed("Shared time off")},"steps":[step(0,rotation)]}
]},
{"id":Uuid::new_v4(),"name":"Product → service tier","enabled":true,"iterations":[{"id":product,"name":"Product","source":reference(products.id)}],"when":{"left":field(product,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[
{"id":owners,"kind":"priority","fields":{"name":name(),"participants":field(product,&["owners"]),"fallback":output(rotation),"handover":fixed("11:00")}},
{"id":policy,"kind":"escalation","fields":{"name":name(),"repeat_count":tier("repeats"),"repeat_wait":tier("wait")},"steps":[step(0,owners),{"delay":tier("delay"),"recipient":output(rotation),"condition":"if-not-acked"}]},
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":name(),"escalation":output(policy),"working":tier("working_only")},"conditions":[{"field":"tags","operation":"contains","value":name()}]}
]},
{"id":Uuid::new_v4(),"name":"Unmatched and off-hours safety net","enabled":true,"blocks":[{"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → shared time off"),"escalation":output(general),"working":fixed(false)}}]}
]);
(
vec![products, tiers, duty],
serde_json::from_value(rules).expect("service tier demo rules"),
)
}
pub async fn seed(db: &Store) -> Result<()> {
let mut users = vec![];
for (username, display) in [
("alice", "Alice Morgan"),
("bob", "Bob Chen"),
("carol", "Carol Davis"),
("dave", "Dave Wilson"),
("erin", "Erin Patel"),
] {
let user = match query!(UserRow,$username==username).get(&db.db).await? {
Some(row) => store::user(row)?,
None => {
service::register(
db,
username.into(),
display.into(),
"meguri-demo-only".into(),
)
.await?
}
};
users.push(user);
}
for user in &users {
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING",
&[&user.id.to_string()],
)
.await?;
}
let alice = users[0].id;
let today = Utc::now().date_naive();
let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday()));
for (index, workspace_name, timezone, start, end) in [
(
1,
"Acme Engineering",
"Asia/Nicosia",
"11:00:00",
"19:00:00",
),
(
2,
"Customer Support",
"Europe/London",
"09:00:00",
"17:00:00",
),
(
3,
"Regional Platform",
"Asia/Nicosia",
"09:00:00",
"18:00:00",
),
(
4,
"Commerce Operations",
"Europe/London",
"09:00:00",
"17:00:00",
),
] {
let mut existing = None;
for row in WorkspaceRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_some() {
continue;
}
let candidate = store::record(row)?;
if candidate.document.name == workspace_name
&& db.owner(candidate.id, alice).await.is_ok()
{
existing = Some(candidate);
break;
}
}
if existing.is_some() {
continue;
}
let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?;
service::set_working_interval(
db,
alice,
workspace.id,
workspace.revision,
WorkingWindow {
timezone: timezone.into(),
weekdays: vec![1, 2, 3, 4, 5],
start: start.parse().unwrap(),
end: end.parse().unwrap(),
},
)
.await?;
workspace = db.workspace(workspace.id).await?;
let member_indices = if index == 1 {
vec![1, 2, 3, 4]
} else {
vec![if index % 2 == 1 { 1 } else { 2 }]
};
for member_index in member_indices {
workspace = service::set_member(
db,
alice,
workspace.id,
workspace.revision,
users[member_index].username.clone(),
Permission::member(),
)
.await?;
}
// Fixed, documented 2026 examples; use the normal scoped CSV import path.
let csv = include_str!("demo-holidays-2026.csv");
if !crate::holidays::compare(csv, &workspace.document.holidays)?
.additions
.is_empty()
{
workspace =
service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?;
}
let (variables, rules) = match index {
1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)),
2 => support_queues(monday, alice, users[2].id),
3 => regional_platform(monday, alice, users[1].id),
_ => service_tiers(monday, alice, users[2].id),
};
let mut draft = Draft {
revision: workspace.revision,
variables,
rules,
route_position: 0,
review_id: None,
};
let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?;
draft.review_id = report["review_id"].as_str().map(str::to_owned);
generation_service::apply(db, alice, workspace.id, draft).await?;
db.db
.raw_with(
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
&[&workspace.id.to_string()],
)
.await?;
}
Ok(())
}
+259
View File
@@ -0,0 +1,259 @@
//! Demo-only retention and a conservative database provenance boundary before migrations.
use crate::{
error::{Error, Result, ensure},
store::Store,
};
use chrono::{DateTime, Utc};
use cot::db::{Database, DatabaseError};
use serde::Serialize;
use std::sync::OnceLock;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
pub struct Retention {
pub workspaces: i64,
pub accounts: i64,
pub requests: i64,
pub events: i64,
pub purge_grace: i64,
}
impl Default for Retention {
fn default() -> Self {
Self {
workspaces: 86400,
accounts: 172800,
requests: 7200,
events: 21600,
purge_grace: 600,
}
}
}
impl Retention {
pub fn from_env() -> Result<Self> {
fn read(name: &str, default: i64) -> Result<i64> {
let value = std::env::var(name).map_or(Ok(default), |v| {
v.parse().map_err(|_| {
Error::Validation(format!("{name} must be an integer number of seconds"))
})
})?;
ensure(
(60..=2592000).contains(&value),
format!("{name} must be 60–2592000 seconds"),
)?;
Ok(value)
}
let d = Self::default();
Ok(Self {
workspaces: read("MEGURI_DEMO_WORKSPACE_TTL_SECONDS", d.workspaces)?,
accounts: read("MEGURI_DEMO_ACCOUNT_TTL_SECONDS", d.accounts)?,
requests: read("MEGURI_DEMO_REQUEST_TTL_SECONDS", d.requests)?,
events: read("MEGURI_DEMO_EVENT_TTL_SECONDS", d.events)?,
purge_grace: read("MEGURI_DEMO_PURGE_GRACE_SECONDS", d.purge_grace)?,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Notice {
pub isolated: bool,
pub retention: Retention,
}
static NOTICE: OnceLock<Notice> = OnceLock::new();
pub fn notice() -> Option<&'static Notice> {
NOTICE.get()
}
pub fn active() -> bool {
NOTICE.get().is_some()
}
pub fn install(notice: Notice) {
let _ = NOTICE.set(notice);
}
fn identifier(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn code(error: &DatabaseError) -> Option<String> {
if let DatabaseError::DatabaseEngineError(e) = error {
e.as_database_error()
.and_then(|e| e.code().map(|c| c.into_owned()))
} else {
None
}
}
pub fn application_url(url: &str, demo: bool) -> Result<String> {
let mut parsed =
reqwest::Url::parse(url).map_err(|_| Error::Validation("Invalid PostgreSQL URL".into()))?;
ensure(parsed.scheme() == "postgresql", "Use a postgresql:// URL")?;
let pairs: Vec<_> = parsed
.query_pairs()
.filter(|(k, _)| k != "application_name")
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
parsed.set_query(None);
parsed.query_pairs_mut().extend_pairs(pairs).append_pair(
"application_name",
&format!(
"meguri-{}-{}",
if demo { "demo" } else { "normal" },
Uuid::new_v4().simple()
),
);
Ok(parsed.to_string())
}
const CREATE_GUARD: &str = "CREATE TABLE IF NOT EXISTS public.meguri_runtime_guard(id boolean PRIMARY KEY CHECK(id),signature text NOT NULL,last_mode text NOT NULL CHECK(last_mode IN('normal','demo')),started_at timestamptz NOT NULL)";
const PROOF: &str = "DO $$ BEGIN
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
END $$;";
/// A failed proof never authorizes a write. No migration or marker update happens here.
pub async fn is_demo_database(db: &Database) -> bool {
db.raw(PROOF).await.is_ok()
}
/// Claim only a database with no user relations. A pre-existing application DB is never claimed.
pub async fn claim_empty(db: &Database) -> Result<()> {
db.raw(&format!("DO $$ BEGIN
PERFORM pg_advisory_xact_lock(726482039);
IF EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema' AND c.relkind IN('r','p','v','m','f','S')) THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
{CREATE_GUARD};
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','demo',clock_timestamp());
END $$;")).await?;
Ok(())
}
pub async fn mark_normal(db: &Database) -> Result<()> {
db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); {CREATE_GUARD};
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','normal',clock_timestamp()) ON CONFLICT(id) DO UPDATE SET last_mode='normal',started_at=clock_timestamp(); END $$;")).await?;
Ok(())
}
/// Uses the configured DB only with positive demo provenance. Otherwise creates/reuses a
/// separately named DB on the same server. Protected candidates and migration tables survive.
pub async fn select_database(configured: &str, alternate: Option<&str>) -> Result<(String, bool)> {
let configured = application_url(configured, true)?;
let source = Database::new(&configured).await?;
if is_demo_database(&source).await {
return Ok((configured, false));
}
let base = reqwest::Url::parse(alternate.unwrap_or(&configured))
.map_err(|_| Error::Validation("Invalid MEGURI_DEMO_DATABASE_URL".into()))?;
let fingerprint = crate::generation::stable_id(&serde_json::json!([
base.host_str(),
base.port(),
base.path()
]));
for attempt in 0..8 {
let mut target = base.clone();
let name = if alternate.is_some() && attempt == 0 {
base.path().trim_start_matches('/').to_owned()
} else {
format!(
"meguri_demo_{}_{}",
&fingerprint.simple().to_string()[..12],
attempt
)
};
ensure(!name.is_empty(), "A demo database name is required")?;
target.set_path(&format!("/{name}"));
let target = application_url(target.as_str(), true)?;
let database = match Database::new(&target).await {
Ok(db) => db,
Err(e) if code(&e).as_deref() == Some("3D000") => {
// Database creation is the only cluster-level write; no DROP DATABASE/schema/CASCADE.
let mut admin = base.clone();
admin.set_path("/postgres");
let admin = Database::new(application_url(admin.as_str(), true)?).await?;
if let Err(e) = admin
.raw(&format!("CREATE DATABASE {}", identifier(&name)))
.await
&& code(&e).as_deref() != Some("42P04")
{
return Err(Error::Validation("Cannot create an isolated demo database. Ask the administrator to provision an empty database and set MEGURI_DEMO_DATABASE_URL. The configured database was not changed.".into()));
}
Database::new(&target).await?
}
Err(e) => return Err(e.into()),
};
if is_demo_database(&database).await {
return Ok((target, true));
}
if claim_empty(&database).await.is_ok() {
return Ok((target, true));
}
}
Err(Error::Validation("No safe demo database is available. Protected databases were not modified; configure a separate empty MEGURI_DEMO_DATABASE_URL.".into()))
}
/// Truncate only the enumerated application state, after rechecking provenance under lock.
/// No CASCADE: an unexpected external dependency aborts the entire reset.
pub async fn reset(db: &Database) -> Result<()> {
db.raw("DO $$ DECLARE tables text; BEGIN
PERFORM pg_advisory_xact_lock(726482039);
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE;
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF;
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL;
IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF;
UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id;
END $$;").await?;
Ok(())
}
pub async fn cleanup(db: &Store, now: DateTime<Utc>, ttl: &Retention) -> Result<()> {
// The server checks provenance on every sweep too. A changed mode stops cleanup.
ensure(
is_demo_database(&db.db).await,
"Cleanup refused: database is not marked as demo",
)?;
db.db
.raw_with(
CLEANUP,
&[&now, &ttl.workspaces, &ttl.accounts, &ttl.requests],
)
.await?;
db.db
.raw_with(PURGE, &[&now, &ttl.purge_grace, &ttl.events])
.await?;
Ok(())
}
pub async fn run(db: Store, ttl: Retention) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
if let Err(error) = async {
cleanup(&db, Utc::now(), &ttl).await?;
crate::demo::seed(&db).await
}
.await
{
tracing::error!(%error,"demo maintenance failed");
}
}
}
const CLEANUP: &str = r#"
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
expired AS (UPDATE meguri__workspaces SET demo_deleted_at=$1,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$2::bigint::double precision) RETURNING id),
expired_users AS (UPDATE meguri__users SET demo_deleted_at=$1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$3::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__demo_pins p WHERE p.kind='user' AND p.id=meguri__users.id) RETURNING id),
sessions AS (DELETE FROM meguri__sessions WHERE EXISTS(SELECT 1 FROM guard) AND (user_id IN(SELECT id FROM expired_users) OR expires_at<=$1)),
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM expired)),
requests AS (
UPDATE meguri__workspaces SET document=jsonb_set(document::jsonb,'{requests}',COALESCE((SELECT jsonb_agg(r) FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz>$1-make_interval(secs=>$4::bigint::double precision)),'[]'::jsonb))::text,revision=revision+1
WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND id NOT IN(SELECT id FROM expired) AND EXISTS(SELECT 1 FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz<=$1-make_interval(secs=>$4::bigint::double precision)) RETURNING *
), history AS (INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) SELECT id||':'||revision::text,id,revision,document,$1 FROM requests),
request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',next_attempt=$1)
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests;
"#;
const PURGE: &str = r#"
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
workspaces AS MATERIALIZED (SELECT id FROM meguri__workspaces WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision)),
members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM workspaces)),
history AS (DELETE FROM meguri__workspace_history h WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND effective_at<$1-make_interval(secs=>$3::bigint::double precision) AND EXISTS(SELECT 1 FROM meguri__workspace_history newer WHERE newer.workspace_id=h.workspace_id AND newer.revision>h.revision AND newer.effective_at<$1-make_interval(secs=>$3::bigint::double precision)))),
audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND created_at<$1-make_interval(secs=>$3::bigint::double precision))),
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM workspaces)),
objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM workspaces)),
pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM workspaces)),
removed AS (DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM workspaces)),
users AS MATERIALIZED (SELECT id FROM meguri__users u WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.user_id=u.id AND m.workspace_id NOT IN(SELECT id FROM workspaces))),
sessions AS (DELETE FROM meguri__sessions WHERE user_id IN(SELECT id FROM users)),
identities AS (DELETE FROM meguri__external_identities WHERE user_id IN(SELECT id FROM users))
DELETE FROM meguri__users WHERE id IN(SELECT id FROM users);
"#;
+706
View File
@@ -0,0 +1,706 @@
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]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
#[serde(default)]
pub language: Option<String>,
pub id: Uuid,
pub username: String,
pub display_name: String,
pub country: String,
/// None follows the browser timezone; this never changes scheduling rules.
pub timezone: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub user: User,
pub permissions: Vec<Permission>,
}
/// 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>,
}
#[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,
#[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,
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 && members.contains(&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).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>,
}
+836
View File
@@ -0,0 +1,836 @@
//! Pure local-calendar scheduling. There is deliberately no database or provider access here.
use crate::{
domain::*,
error::{Error, Result, ensure},
};
use chrono::{DateTime, Days, Duration, LocalResult, NaiveDate, TimeZone, Utc};
use chrono_tz::Tz;
use std::collections::BTreeSet;
use uuid::Uuid;
pub fn handover(doc: &WorkspaceDocument, day: NaiveDate) -> Result<DateTime<Utc>> {
local_time(doc, day, default_handover())
}
pub fn schedule_handover(
doc: &WorkspaceDocument,
id: Uuid,
day: NaiveDate,
) -> Result<DateTime<Utc>> {
let schedule = doc
.schedule(id)
.ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?;
local_time(doc, day, schedule.handover)
}
pub fn local_time(
doc: &WorkspaceDocument,
day: NaiveDate,
time: chrono::NaiveTime,
) -> Result<DateTime<Utc>> {
let zone: Tz = doc
.timezone
.parse()
.map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?;
match zone.from_local_datetime(&day.and_time(time)) {
LocalResult::Single(time) => Ok(time.with_timezone(&Utc)),
_ => Err(Error::Validation(crate::i18n::message(
"cover_local_time_invalid",
&[
("date", day.format("%d-%m-%Y").to_string()),
("time", time.format("%H:%M").to_string()),
("zone", doc.timezone.clone()),
],
))),
}
}
pub fn duty_day(doc: &WorkspaceDocument, at: DateTime<Utc>) -> Result<NaiveDate> {
day_at(doc, at, default_handover())
}
pub fn schedule_duty_day(
doc: &WorkspaceDocument,
id: Uuid,
at: DateTime<Utc>,
) -> Result<NaiveDate> {
let schedule = doc
.schedule(id)
.ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?;
day_at(doc, at, schedule.handover)
}
fn day_at(
doc: &WorkspaceDocument,
at: DateTime<Utc>,
time: chrono::NaiveTime,
) -> Result<NaiveDate> {
let zone: Tz = doc
.timezone
.parse()
.map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?;
let local = at.with_timezone(&zone);
let date = local.date_naive();
if at < local_time(doc, date, time)? {
date.pred_opt().ok_or_else(|| {
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
})
} else {
Ok(date)
}
}
pub fn publication_window(
doc: &WorkspaceDocument,
now: DateTime<Utc>,
) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let day = duty_day(doc, now)?;
let mut from = handover(doc, day)?;
let mut to = handover(doc, day + Duration::days(i64::from(doc.horizon_days)))?;
for schedule in &doc.schedules {
let day = schedule_duty_day(doc, schedule.id, now)?;
from = from.min(schedule_handover(doc, schedule.id, day)?);
to = to.max(schedule_handover(
doc,
schedule.id,
day + Duration::days(i64::from(doc.horizon_days)),
)?);
}
Ok((from, to))
}
pub fn timeline(
doc: &WorkspaceDocument,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Timeline> {
ensure(
from < to && (to - from) <= Duration::days(367),
crate::i18n::text("err_calendar_range_must_be_positive_and_at_most_367_23a2e6"),
)?;
let mut bounds = BTreeSet::from([from, to]);
// Dependencies may hand over at different times. Split at every schedule's boundary.
for schedule in &doc.schedules {
let mut day = schedule_duty_day(doc, schedule.id, from)?;
let last_day = schedule_duty_day(doc, schedule.id, to)?;
while day <= last_day {
let point = schedule_handover(doc, schedule.id, day)?;
if point > from && point < to {
bounds.insert(point);
}
day = day.checked_add_days(Days::new(1)).ok_or_else(|| {
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
})?;
}
}
for request in doc
.requests
.iter()
.filter(|r| r.status == RequestStatus::Applied)
{
for leg in request.effective_legs() {
for point in [leg.start, leg.end] {
if point > from && point < to {
bounds.insert(point);
}
}
}
}
let bounds: Vec<_> = bounds.into_iter().collect();
let mut assignments = Vec::<Assignment>::new();
for schedule in &doc.schedules {
for pair in bounds.windows(2) {
let day = schedule_duty_day(doc, schedule.id, pair[0])?;
let (user_id, reason) = resolve(doc, schedule.id, pair[0], &mut BTreeSet::new())?;
let unavailable = doc.is_absent(user_id, day);
if let Some(last) = assignments.last_mut()
&& last.schedule_id == schedule.id
&& last.end == pair[0]
&& last.user_id == user_id
&& last.reason == reason
&& last.unavailable == unavailable
{
last.end = pair[1];
continue;
}
assignments.push(Assignment {
schedule_id: schedule.id,
start: pair[0],
end: pair[1],
user_id,
reason,
unavailable,
});
}
}
Ok(Timeline {
from,
to,
assignments,
})
}
fn resolve(
doc: &WorkspaceDocument,
id: Uuid,
at: DateTime<Utc>,
seen: &mut BTreeSet<Uuid>,
) -> Result<(Uuid, String)> {
ensure(
seen.insert(id),
crate::i18n::text("err_fallback_schedules_contain_a_cycle_e98539"),
)?;
let schedule = doc.schedule(id).ok_or_else(|| {
Error::Validation(crate::i18n::text("err_missing_schedule_4dc6ee").into())
})?;
let day = schedule_duty_day(doc, id, at)?;
let overrides: Vec<_> = doc
.requests
.iter()
.filter(|r| r.status == RequestStatus::Applied)
.flat_map(|r| r.effective_legs().iter())
.filter(|l| l.schedule_id == id && l.start <= at && at < l.end)
.collect();
ensure(
overrides.len() <= 1,
crate::i18n::text("err_approved_overrides_overlap_ce280c"),
)?;
if let Some(leg) = overrides.first() {
return Ok((
leg.to_user,
crate::i18n::text("err_approved_replacement_f0feba").into(),
));
}
match &schedule.kind {
ScheduleKind::Priority {
participants,
fallback,
} => {
let global_holiday = doc
.holidays
.iter()
.any(|h| h.date == day && h.country == "*");
if !global_holiday
&& let Some((index, user)) = participants.iter().enumerate().find(|(_, user)| {
!doc.is_absent(**user, day) && !doc.country_holiday(**user, day)
})
{
return Ok((
*user,
if index == 0 {
"Primary".into()
} else {
crate::i18n::message(
"err_earlier_owners_off_responsible_p0_bb11c0",
&[("p0", (index + 1).to_string())],
)
},
));
}
let (user, _) = resolve(doc, *fallback, at, seen)?;
Ok((
user,
crate::i18n::text("err_owners_off_general_on_call_9cfec2").into(),
))
}
ScheduleKind::Rotation {
participants,
anchor,
shift_days,
} => {
ensure(
!participants.is_empty() && *shift_days > 0,
crate::i18n::text("err_invalid_rotation_8f771c"),
)?;
let shift = (day - *anchor)
.num_days()
.div_euclid(i64::from(*shift_days));
let index = shift.rem_euclid(participants.len() as i64) as usize;
Ok((participants[index], "Rotation".into()))
}
}
}
/// Replace only overlapping active spans, preserving immutable original request legs.
/// Call only after ownership validation and approval, before inserting the new override.
pub fn supersede_overrides(doc: &mut WorkspaceDocument, legs: &[ReplacementLeg]) {
for request in doc
.requests
.iter_mut()
.filter(|r| r.status == RequestStatus::Applied)
{
let mut spans = request.effective_legs().to_vec();
let mut changed = false;
for leg in legs {
let mut remaining = Vec::new();
for span in spans {
if span.schedule_id != leg.schedule_id
|| span.end <= leg.start
|| leg.end <= span.start
{
remaining.push(span);
continue;
}
changed = true;
if span.start < leg.start {
remaining.push(ReplacementLeg {
end: leg.start,
..span.clone()
});
}
if leg.end < span.end {
remaining.push(ReplacementLeg {
start: leg.end,
..span
});
}
}
spans = remaining;
}
if changed {
request.effective_legs = Some(spans);
}
}
}
pub fn validate_legs(
doc: &WorkspaceDocument,
legs: &[ReplacementLeg],
members: &BTreeSet<Uuid>,
now: DateTime<Utc>,
) -> Result<()> {
let members = doc.person_ids(members);
ensure(
!legs.is_empty() && legs.len() <= 2,
"A replacement needs one interval; a swap needs two",
)?;
for (index, leg) in legs.iter().enumerate() {
ensure(
doc.schedule(leg.schedule_id).is_some(),
crate::i18n::text("err_schedule_does_not_exist_637210"),
)?;
ensure(
leg.start >= now && leg.start < leg.end && leg.end - leg.start <= Duration::days(90),
crate::i18n::text("err_replacement_must_be_future_facing_and_at_most_90_c483a9"),
)?;
ensure(
leg.end <= now + Duration::days(366),
crate::i18n::text("err_replacement_must_be_within_the_next_year_2cab72"),
)?;
ensure(
leg.from_user != leg.to_user
&& members.contains(&leg.to_user)
&& members.contains(&leg.from_user),
crate::i18n::text("err_choose_two_different_team_members_38153c"),
)?;
for other in &legs[..index] {
ensure(
leg.schedule_id != other.schedule_id
|| leg.end <= other.start
|| other.end <= leg.start,
crate::i18n::text("err_swap_intervals_overlap_740eb3"),
)?;
}
let plan = timeline(doc, leg.start, leg.end)?;
let segments: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.schedule_id == leg.schedule_id)
.collect();
ensure(
!segments.is_empty() && segments.iter().all(|a| a.user_id == leg.from_user),
crate::i18n::text("err_the_original_employee_no_longer_owns_the_entire_c36051"),
)?;
let mut day = schedule_duty_day(doc, leg.schedule_id, leg.start)?;
let end_day = schedule_duty_day(doc, leg.schedule_id, leg.end - Duration::nanoseconds(1))?;
while day <= end_day {
ensure(
!doc.is_absent(leg.to_user, day),
crate::i18n::text("err_the_replacement_employee_is_absent_during_this_i_3b5cc2"),
)?;
day = day.succ_opt().ok_or_else(|| {
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
})?;
}
}
if legs.len() == 2 {
ensure(
legs[0].from_user == legs[1].to_user && legs[0].to_user == legs[1].from_user,
crate::i18n::text("err_a_swap_must_exchange_shifts_between_the_same_two_dd579e"),
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveTime;
#[test]
fn holidays_skip_only_people_in_the_affected_country_and_keep_rotation_running() {
let (mut doc, people, rotation, coverage) = fixture();
doc.people = people
.iter()
.enumerate()
.map(|(i, id)| Person {
id: *id,
user_id: Some(*id),
display_name: format!("Person {i}"),
email: None,
country: Some(if i == 0 { "AM" } else { "CY" }.into()),
})
.collect();
let day = "2026-03-30".parse().unwrap();
doc.holidays.push(Holiday {
country: "AM".into(),
date: day,
name: "Armenia only".into(),
});
let from = handover(&doc, day).unwrap();
let owner = |doc: &WorkspaceDocument, id| {
timeline(doc, from, from + Duration::hours(1))
.unwrap()
.assignments
.into_iter()
.find(|a| a.schedule_id == id)
.unwrap()
};
assert_eq!(owner(&doc, coverage).user_id, people[1]);
assert_eq!(owner(&doc, rotation).user_id, people[2]);
doc.holidays.push(Holiday {
country: "CY".into(),
date: day,
name: "Cyprus only".into(),
});
assert_eq!(owner(&doc, coverage).user_id, people[2]);
assert!(
!owner(&doc, rotation).unavailable,
"General on-call continues on public holidays"
);
doc.holidays.retain(|h| h.country == "CY");
assert_eq!(
owner(&doc, coverage).user_id,
people[0],
"A Cyprus holiday does not exclude the Armenian primary"
);
}
#[test]
fn fallback_uses_its_own_handover_instead_of_parent_duty_day() {
let (mut doc, people, rotation, coverage) = fixture();
doc.timezone = "UTC".into();
doc.schedules[0].handover = "09:00:00".parse().unwrap();
for id in &people[..2] {
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: *id,
start: "2026-03-22".parse().unwrap(),
end: "2026-03-24".parse().unwrap(),
});
}
let plan = timeline(
&doc,
"2026-03-23T08:00:00Z".parse().unwrap(),
"2026-03-23T12:00:00Z".parse().unwrap(),
)
.unwrap();
for id in [rotation, coverage] {
let shifts: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.schedule_id == id)
.collect();
assert_eq!(shifts.len(), 2);
assert_eq!(shifts[0].user_id, people[0]);
assert_eq!(shifts[1].user_id, people[1]);
assert_eq!(
shifts[0].end,
"2026-03-23T09:00:00Z".parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(shifts[0].end, shifts[1].start);
}
}
fn fixture() -> (WorkspaceDocument, [Uuid; 3], Uuid, Uuid) {
let people = [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)];
let rotation = Uuid::from_u128(10);
let coverage = Uuid::from_u128(11);
let mut doc = WorkspaceDocument::new("Platform".into());
doc.schedules = vec![
Schedule {
handover: default_handover(),
id: rotation,
name: "General".into(),
kind: ScheduleKind::Rotation {
participants: people.to_vec(),
anchor: "2026-03-16".parse().unwrap(),
shift_days: 7,
},
},
Schedule {
handover: default_handover(),
id: coverage,
name: "Payments".into(),
kind: ScheduleKind::Priority {
participants: vec![people[0], people[1]],
fallback: rotation,
},
},
];
(doc, people, rotation, coverage)
}
#[test]
fn weekly_rotation_keeps_local_handover_across_dst_and_wraps_backwards() {
let (doc, people, rotation, _) = fixture();
let start = handover(&doc, "2026-03-23".parse().unwrap()).unwrap();
let end = handover(&doc, "2026-03-30".parse().unwrap()).unwrap();
assert_eq!((end - start).num_hours(), 167);
let plan = timeline(&doc, start, end).unwrap();
let shifts: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.schedule_id == rotation)
.collect();
assert_eq!(shifts.len(), 1);
assert_eq!(shifts[0].user_id, people[1]);
let previous = handover(&doc, "2026-03-09".parse().unwrap()).unwrap();
assert_eq!(
timeline(&doc, previous, previous + Duration::hours(1))
.unwrap()
.assignments[0]
.user_id,
people[2]
);
}
#[test]
fn coverage_uses_secondary_then_effective_fallback_and_holiday_precedence() {
let (mut doc, people, rotation, coverage) = fixture();
let day = "2026-03-30".parse().unwrap();
let start = handover(&doc, day).unwrap();
let end = start + Duration::hours(24);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: people[0],
start: day,
end: day,
});
let get = |doc: &WorkspaceDocument| {
timeline(doc, start, end)
.unwrap()
.assignments
.into_iter()
.find(|a| a.schedule_id == coverage)
.unwrap()
.user_id
};
assert_eq!(get(&doc), people[1]);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: people[1],
start: day,
end: day,
});
assert_eq!(get(&doc), people[2]);
doc.absences.clear();
doc.holidays.push(Holiday {
country: "*".into(),
date: day,
name: "Holiday".into(),
});
assert_eq!(get(&doc), people[2]);
doc.requests.push(ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: people[2],
created_at: start - Duration::days(1),
reason: "Swap".into(),
legs: vec![ReplacementLeg {
schedule_id: rotation,
start,
end,
from_user: people[2],
to_user: people[0],
}],
required_approvers: BTreeSet::new(),
approvals: BTreeSet::new(),
status: RequestStatus::Applied,
applied_at: Some(start),
forced_by: None,
force_reason: None,
});
assert_eq!(get(&doc), people[0]);
}
#[test]
fn ambiguous_handover_and_cycles_are_errors() {
let (mut doc, people, rotation, coverage) = fixture();
doc.schedules[0].handover = NaiveTime::from_hms_opt(3, 30, 0).unwrap();
assert!(schedule_handover(&doc, rotation, "2026-03-29".parse().unwrap()).is_err());
assert!(schedule_handover(&doc, rotation, "2026-10-25".parse().unwrap()).is_err());
doc.schedules[0].kind = ScheduleKind::Priority {
participants: vec![people[0], people[1]],
fallback: coverage,
};
assert!(doc.validate(&people.into_iter().collect()).is_err());
assert!(
timeline(
&doc,
"2026-04-01T09:00:00Z".parse().unwrap(),
"2026-04-02T09:00:00Z".parse().unwrap()
)
.is_ok()
);
doc.holidays.push(Holiday {
country: "*".into(),
date: "2026-04-01".parse().unwrap(),
name: "Holiday".into(),
});
assert!(
timeline(
&doc,
"2026-04-01T09:00:00Z".parse().unwrap(),
"2026-04-02T09:00:00Z".parse().unwrap()
)
.is_err()
);
assert_eq!(doc.schedules[0].id, rotation);
}
#[test]
fn request_validation_rejects_changed_owner_and_absent_replacement() {
let (mut doc, people, rotation, _) = fixture();
let start = handover(&doc, "2026-03-30".parse().unwrap()).unwrap();
let leg = ReplacementLeg {
schedule_id: rotation,
start,
end: start + Duration::hours(24),
from_user: people[0],
to_user: people[1],
};
let members = people.into_iter().collect();
assert!(
validate_legs(
&doc,
std::slice::from_ref(&leg),
&members,
start - Duration::days(1)
)
.is_err()
);
let leg = ReplacementLeg {
from_user: people[2],
..leg
};
assert!(
validate_legs(
&doc,
std::slice::from_ref(&leg),
&members,
start - Duration::days(1)
)
.is_ok()
);
doc.absences.push(Absence {
id: Uuid::new_v4(),
user_id: people[1],
start: "2026-03-30".parse().unwrap(),
end: "2026-03-30".parse().unwrap(),
});
assert!(validate_legs(&doc, &[leg], &members, start - Duration::days(1)).is_err());
}
#[test]
fn generated_rotations_cover_every_instant_once_across_zones_and_shift_lengths() {
for zone in [
"UTC",
"Europe/Bucharest",
"America/New_York",
"Australia/Sydney",
"Asia/Kathmandu",
] {
for length in [1, 2, 7, 14, 31] {
let (mut doc, people, rotation, _) = fixture();
doc.timezone = zone.into();
doc.schedules.truncate(1);
let anchor = "2026-01-01".parse().unwrap();
doc.schedules[0].kind = ScheduleKind::Rotation {
participants: people.to_vec(),
anchor,
shift_days: length,
};
let from = handover(&doc, anchor).unwrap();
let to = handover(&doc, anchor + Duration::days(365)).unwrap();
let plan = timeline(&doc, from, to).unwrap();
assert_eq!(plan.assignments.first().unwrap().start, from);
assert_eq!(plan.assignments.last().unwrap().end, to);
for pair in plan.assignments.windows(2) {
assert_eq!(
pair[0].end, pair[1].start,
"No gaps or overlaps: {zone}, {length}"
);
}
for offset in 0..365 {
let at = handover(&doc, anchor + Duration::days(offset)).unwrap();
let owners: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.start <= at && at < a.end)
.collect();
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].schedule_id, rotation);
assert_eq!(
owners[0].user_id,
people[(offset / i64::from(length)) as usize % 3]
);
}
}
}
}
#[test]
fn autumn_shift_is_169_hours_and_half_open_handover_changes_owner_once() {
let (mut doc, people, rotation, _) = fixture();
let anchor = "2026-10-19".parse().unwrap();
doc.schedules[0].kind = ScheduleKind::Rotation {
participants: people.to_vec(),
anchor,
shift_days: 7,
};
let from = handover(&doc, anchor).unwrap();
let boundary = handover(&doc, anchor + Duration::days(7)).unwrap();
assert_eq!((boundary - from).num_hours(), 169);
let plan = timeline(
&doc,
boundary - Duration::nanoseconds(1),
boundary + Duration::nanoseconds(1),
)
.unwrap();
let shifts: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.schedule_id == rotation)
.collect();
assert_eq!(shifts.len(), 2);
assert_eq!(shifts[0].user_id, people[0]);
assert_eq!(shifts[0].end, boundary);
assert_eq!(shifts[1].user_id, people[1]);
}
#[test]
fn partial_replacement_does_not_move_base_rotation_and_overlaps_are_rejected() {
let (mut doc, people, rotation, _) = fixture();
let day = "2026-03-16".parse().unwrap();
let start = handover(&doc, day).unwrap();
let end = handover(&doc, day + Duration::days(8)).unwrap();
let leg = ReplacementLeg {
schedule_id: rotation,
start: start + Duration::hours(3),
end: start + Duration::hours(6),
from_user: people[0],
to_user: people[2],
};
let mut request = ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: people[0],
created_at: start - Duration::days(1),
reason: "Appointment".into(),
legs: vec![leg.clone()],
required_approvers: BTreeSet::from([people[2]]),
approvals: BTreeSet::new(),
status: RequestStatus::Pending,
applied_at: None,
forced_by: None,
force_reason: None,
};
doc.requests.push(request.clone());
assert_eq!(
timeline(&doc, start, end)
.unwrap()
.assignments
.iter()
.filter(|a| a.schedule_id == rotation)
.count(),
2
);
request.status = RequestStatus::Applied;
doc.requests[0] = request.clone();
let plan = timeline(&doc, start, end).unwrap();
let shifts: Vec<_> = plan
.assignments
.iter()
.filter(|a| a.schedule_id == rotation)
.collect();
assert_eq!(
shifts.iter().map(|a| a.user_id).collect::<Vec<_>>(),
vec![people[0], people[2], people[0], people[1]]
);
let next = ReplacementLeg {
start: leg.end,
end: leg.end + Duration::hours(1),
..leg.clone()
};
assert!(
validate_legs(&doc, &[next], &people.into_iter().collect(), start).is_ok(),
"Adjacent replacements are legal"
);
assert!(validate_legs(&doc, &[leg], &people.into_iter().collect(), start).is_err());
doc.requests.push(request);
assert!(
timeline(&doc, start, end).is_err(),
"Corrupt overlapping state must never silently choose an owner"
);
}
#[test]
fn superseding_cover_preserves_swap_partner_history_and_legacy_requests() {
let (mut doc, people, rotation, coverage) = fixture();
let start = handover(&doc, "2026-03-16".parse().unwrap()).unwrap();
let original = ReplacementLeg {
schedule_id: rotation,
start,
end: start + Duration::hours(12),
from_user: people[0],
to_user: people[1],
};
let partner = ReplacementLeg {
schedule_id: coverage,
from_user: people[1],
to_user: people[0],
..original.clone()
};
let request = crate::absence::synthetic_request(
Uuid::new_v4(),
vec![original.clone(), partner.clone()],
start,
);
let json = serde_json::to_value(&request).unwrap();
assert!(json.get("effective_legs").is_none());
doc.requests.push(serde_json::from_value(json).unwrap());
let correction = ReplacementLeg {
start: start + Duration::hours(3),
end: start + Duration::hours(6),
from_user: people[1],
to_user: people[2],
..original.clone()
};
supersede_overrides(&mut doc, &[correction]);
let spans = doc.requests[0].effective_legs();
assert_eq!(spans.len(), 3);
assert_eq!(
(spans[0].start, spans[0].end),
(start, start + Duration::hours(3))
);
assert_eq!(
(spans[1].start, spans[1].end),
(start + Duration::hours(6), original.end)
);
assert_eq!(spans[2].schedule_id, partner.schedule_id);
assert_eq!(doc.requests[0].legs.len(), 2);
assert_eq!(doc.requests[0].legs[0].end, original.end);
supersede_overrides(&mut doc, &[original, partner]);
assert!(doc.requests[0].effective_legs().is_empty());
let restored: ReplacementRequest =
serde_json::from_value(serde_json::to_value(&doc.requests[0]).unwrap()).unwrap();
assert!(restored.effective_legs().is_empty());
assert_eq!(restored.legs.len(), 2);
}
}
+64
View File
@@ -0,0 +1,64 @@
use cot::{
StatusCode,
json::Json,
response::{IntoResponse, Response},
};
use serde_json::json;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Validation(String),
#[error("{}", crate::i18n::text("err_sign_in_to_continue_9a7843"))]
Unauthorized,
#[error(
"{}",
crate::i18n::text("err_you_do_not_have_permission_for_this_action_6306e9")
)]
Forbidden,
#[error("{}", crate::i18n::text("err_resource_not_found_2d1d4c"))]
NotFound,
#[error("{0}")]
Conflict(String),
#[error("{0}")]
Provider(String),
#[error(transparent)]
Database(#[from] cot::db::DatabaseError),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("{0}")]
Internal(String),
}
impl IntoResponse for Error {
fn into_response(self) -> cot::Result<Response> {
let (status, message) = match &self {
Self::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Provider(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
_ => {
tracing::error!(error = %self, "request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
crate::i18n::text("err_an_internal_error_occurred_96c51c").into(),
)
}
};
Json(json!({"error": message}))
.with_status(status)
.into_response()
}
}
pub fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
if condition {
Ok(())
} else {
Err(Error::Validation(message.into()))
}
}
+1299
View File
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
//! 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()),
});
}
}
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}))
}
+179
View File
@@ -0,0 +1,179 @@
//! Pure RFC 4180 CSV parsing and comparison. Preview never changes state.
use crate::{
domain::{Holiday, valid_country},
error::{Result, ensure},
};
use chrono::{Datelike, NaiveDate};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Serialize)]
pub struct ImportPreview {
pub additions: Vec<Holiday>,
pub existing: usize,
pub duplicates: usize,
pub conflicts: Vec<String>,
pub errors: Vec<String>,
}
pub fn compare(input: &str, saved: &[Holiday]) -> Result<ImportPreview> {
ensure(
input.len() <= 262_144,
crate::i18n::text("err_csv_is_limited_to_256_kib_e30936"),
)?;
let mut preview = ImportPreview {
additions: vec![],
existing: 0,
duplicates: 0,
conflicts: vec![],
errors: vec![],
};
let mut reader = csv::ReaderBuilder::new()
.trim(csv::Trim::All)
.from_reader(input.trim_start_matches('\u{feff}').as_bytes());
let headers = match reader.headers() {
Ok(h) => h.iter().map(str::to_ascii_lowercase).collect::<Vec<_>>(),
Err(_) => {
preview
.errors
.push(crate::i18n::text("err_could_not_read_csv_headers_2bbd8d").into());
return Ok(preview);
}
};
let positions = ["country", "date", "name"].map(|key| headers.iter().position(|h| h == key));
let [Some(country), Some(date), Some(name)] = positions else {
preview
.errors
.push(crate::i18n::text("err_use_the_csv_headers_country_date_name_7d9639").into());
return Ok(preview);
};
if headers.len() != 3 {
preview.errors.push(
crate::i18n::text("err_csv_must_have_exactly_three_columns_country_date_723e07").into(),
);
return Ok(preview);
}
let existing: BTreeMap<_, _> = saved
.iter()
.map(|h| ((h.country.clone(), h.date), h.name.clone()))
.collect();
let mut seen = BTreeMap::new();
for (index, row) in reader.records().enumerate() {
if index >= 2000 {
preview
.errors
.push(crate::i18n::text("err_import_is_limited_to_2_000_rows_6e1c86").into());
break;
}
let line = index + 2;
let row = match row {
Ok(row) => row,
Err(_) => {
preview.errors.push(crate::i18n::message(
"err_row_line_invalid_csv_or_column_count_be7745",
&[("line", (line).to_string())],
));
continue;
}
};
let country = row[country].to_ascii_uppercase();
let parsed = ["%Y-%m-%d", "%d-%m-%Y"].into_iter().find_map(|format| {
NaiveDate::parse_from_str(&row[date], format)
.ok()
.filter(|d| {
(2000..=2100).contains(&d.year()) && d.format(format).to_string() == row[date]
})
});
if !valid_country(&country)
|| parsed.is_none()
|| row[name].is_empty()
|| row[name].len() > 120
{
preview.errors.push(crate::i18n::message(
"err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e",
&[("line", (line).to_string())],
));
continue;
}
let holiday = Holiday {
country,
date: parsed.unwrap(),
name: row[name].into(),
};
let key = (holiday.country.clone(), holiday.date);
if let Some(previous) = seen.insert(key.clone(), holiday.name.clone()) {
if previous == holiday.name {
preview.duplicates += 1;
} else {
preview.errors.push(crate::i18n::message(
"err_row_line_two_different_names_for_p0_on_p1_4c127b",
&[
("line", (line).to_string()),
("p0", (holiday.country).to_string()),
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
],
));
}
continue;
}
if let Some(name) = existing.get(&key) {
if name == &holiday.name {
preview.existing += 1;
} else {
preview.conflicts.push(crate::i18n::message(
"err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0",
&[
("p0", (holiday.country).to_string()),
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
("p2", (name).to_string()),
("p3", (holiday.name).to_string()),
],
));
}
} else {
preview.additions.push(holiday);
}
}
if saved.len() + preview.additions.len() > 2000 {
preview
.errors
.push(crate::i18n::text("err_the_team_would_exceed_2_000_holidays_496996").into());
}
preview
.additions
.sort_by(|a, b| (&a.country, a.date).cmp(&(&b.country, b.date)));
Ok(preview)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csv_handles_bom_quotes_countries_duplicates_and_preserves_existing_names() {
let saved = vec![Holiday {
country: "CY".into(),
date: "2026-01-01".parse().unwrap(),
name: "New Year".into(),
}];
let p=compare("\u{feff}date,name,country\r\n2026-01-01,New Year,CY\r\n2026-01-01,\"New Year, holiday\",am\r\n2026-01-01,\"New Year, holiday\",AM\r\n2026-12-25,Christmas,CY",&saved).unwrap();
assert!(p.errors.is_empty());
assert_eq!(p.existing, 1);
assert_eq!(p.duplicates, 1);
assert_eq!(p.additions.len(), 2);
let p = compare("country,date,name\nCY,2026-01-01,Changed", &saved).unwrap();
assert!(p.additions.is_empty());
assert_eq!(p.conflicts.len(), 1);
assert_eq!(saved[0].name, "New Year");
}
#[test]
fn invalid_rows_and_conflicting_input_duplicates_block_import() {
let p=compare("country,date,name\nXX,2026-01-01,Invalid country\nCY,2026-02-30,Invalid date\nAM,2026-01-01,One\nAM,2026-01-01,Two\nCY,2026-01-01",&[]).unwrap();
assert_eq!(p.errors.len(), 4);
assert!(
!compare("date,label\n2026-01-01,Holiday", &[])
.unwrap()
.errors
.is_empty()
);
}
}
+71
View File
@@ -0,0 +1,71 @@
//! One in-memory catalog shared with the browser. Domain messages retain stable English
//! diagnostics for API compatibility; presentation uses the same catalog and parameters.
use serde::Deserialize;
use std::{collections::BTreeMap, sync::LazyLock};
#[derive(Deserialize)]
pub struct Message {
pub en: String,
pub ru: Option<String>,
pub fr: Option<String>,
}
pub static MESSAGES: LazyLock<BTreeMap<String, Message>> = LazyLock::new(|| {
serde_json::from_str(include_str!("../static/locales/messages.json"))
.expect("the bundled localization catalog must be valid")
});
pub fn text(key: &str) -> &'static str {
&MESSAGES.get(key).expect("localization key must exist").en
}
pub fn message(key: &str, parameters: &[(&str, String)]) -> String {
let pattern = text(key);
let mut output = String::new();
let mut remaining = pattern;
while let Some(start) = remaining.find('{') {
output.push_str(&remaining[..start]);
let tail = &remaining[start + 1..];
let Some(end) = tail.find('}') else {
output.push_str(&remaining[start..]);
return output;
};
let name = &tail[..end];
if let Some((_, value)) = parameters.iter().find(|(key, _)| *key == name) {
output.push_str(value);
} else {
output.push_str(&remaining[start..start + end + 2]);
}
remaining = &tail[end + 1..];
}
output.push_str(remaining);
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_is_complete_and_parameters_are_not_reinterpreted() {
for (key, value) in MESSAGES.iter() {
assert!(!value.en.is_empty(), "{key}: English missing");
assert!(
value.ru.as_ref().is_some_and(|s| !s.is_empty()),
"{key}: Russian missing"
);
assert!(
value.fr.as_ref().is_some_and(|s| !s.is_empty()),
"{key}: French missing"
);
}
let output = message(
"publication_absent_detail",
&[
("person", "{schedule}<script>".into()),
("schedule", "Payments".into()),
("start", "a".into()),
("end", "b".into()),
],
);
assert!(output.contains("{schedule}<script> is absent"));
assert!(output.contains("“Payments”, a → b"));
}
}
+19
View File
@@ -0,0 +1,19 @@
pub mod absence;
pub mod audit;
pub mod configuration;
pub mod demo;
pub mod demo_runtime;
pub mod domain;
pub mod engine;
pub mod error;
pub mod generation;
pub mod generation_service;
pub mod holidays;
pub mod i18n;
pub mod migrations;
pub mod models;
pub mod providers;
pub mod service;
pub mod store;
pub mod web;
pub mod worker;
+106
View File
@@ -0,0 +1,106 @@
use cot::{
Bootstrapper,
config::{DatabaseConfig, ProjectConfig},
};
use meguri::{
demo_runtime, service,
store::Store,
web::{MeguriProject, enabled},
worker,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "meguri=info,cot=warn".into()),
)
.init();
let mut database_url = std::env::var("DATABASE_URL")
.map_err(|_| "Set DATABASE_URL to a PostgreSQL connection URL; see .env.example")?;
if !database_url.starts_with("postgresql://") {
return Err("DATABASE_URL must use the postgresql:// scheme".into());
}
let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into());
let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
let public_url =
std::env::var("MEGURI_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".into());
let public = reqwest::Url::parse(&public_url)?;
let loopback = matches!(public.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"));
if public.scheme() != "https" && !(public.scheme() == "http" && loopback) {
return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into());
}
if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) {
return Err("Demo accounts require a loopback bind and URL".into());
}
if enabled("MEGURI_SEED_DEMO") {
if role != "all" && role != "web" {
return Err("Demo mode runs as a single web/all process; migrate and worker roles cannot reset a database".into());
}
let retention = demo_runtime::Retention::from_env()?;
let alternate = std::env::var("MEGURI_DEMO_DATABASE_URL").ok();
let (selected, isolated) =
demo_runtime::select_database(&database_url, alternate.as_deref()).await?;
database_url = selected;
let connection = cot::db::Database::new(&database_url).await?;
demo_runtime::reset(&connection).await?;
demo_runtime::install(demo_runtime::Notice {
isolated,
retention,
});
if isolated {
tracing::warn!(
"Demo mode refused the configured database. Running in an isolated demo database; the administrator must review DATABASE_URL and MEGURI_SEED_DEMO."
);
}
} else {
database_url = demo_runtime::application_url(&database_url, false)?;
demo_runtime::mark_normal(&cot::db::Database::new(&database_url).await?).await?;
}
let db = if enabled("MEGURI_SKIP_MIGRATIONS") && !demo_runtime::active() {
Store {
db: cot::db::Database::new(database_url.clone()).await?,
}
} else {
Store::connect(&database_url).await?
};
if role == "migrate" {
tracing::info!("Cot migrations completed");
return Ok(());
}
if enabled("MEGURI_SEED_DEMO") {
service::seed_demo(&db).await?;
}
let demo_maintenance = demo_runtime::notice()
.map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone())));
if role == "worker" {
worker::run(db).await;
return Ok(());
}
if role != "web" && role != "all" {
return Err("MEGURI_ROLE must be all, web, worker, or migrate".into());
}
let worker = if role == "all" {
Some(tokio::spawn(worker::run(db)))
} else {
None
};
let config = ProjectConfig::builder()
.debug(false)
.database(DatabaseConfig::builder().url(database_url).build())
.build();
let project = Bootstrapper::new(MeguriProject)
.with_config(config)
.boot()
.await?;
tracing::info!(%bind,"Meguri is ready");
cot::project::run(project, &bind).await?;
if let Some(worker) = worker {
worker.abort();
}
if let Some(maintenance) = demo_maintenance {
maintenance.abort();
}
Ok(())
}
+18
View File
@@ -0,0 +1,18 @@
pub mod m_0001_initial;
pub mod m_0002_constraints;
pub mod m_0003_calendars;
pub mod m_0004_user_preferences;
pub mod m_0005_variables;
pub mod m_0006_demo_lifecycle;
pub mod m_0007_language;
pub mod m_0008_workspaces;
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration,
&m_0002_constraints::Migration,
&m_0003_calendars::Migration,
&m_0004_user_preferences::Migration,
&m_0005_variables::Migration,
&m_0006_demo_lifecycle::Migration,
&m_0007_language::Migration,
&m_0008_workspaces::Migration,
];
+425
View File
@@ -0,0 +1,425 @@
//! Initial Cot ORM schema. Historical model snapshots are intentionally frozen.
use chrono::{DateTime, Utc};
use cot::db::migrations::{Field, MigrationDependency, Operation};
use cot::db::{Auto, DatabaseField, Identifier, model};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0001_initial";
const DEPENDENCIES: &'static [MigrationDependency] = &[];
const OPERATIONS: &'static [Operation] = &[
Operation::create_model()
.table_name(Identifier::new("meguri__users"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(Identifier::new("username"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.unique(),
Field::new(
Identifier::new("display_name"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("password_hash"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("created_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__external_identities"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(Identifier::new("issuer"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("subject"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__sessions"))
.fields(&[
Field::new(
Identifier::new("token_hash"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("csrf"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("expires_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__tenants"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("overwrite_drift"),
<bool as DatabaseField>::TYPE,
)
.set_null(<bool as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("created_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__tenant_members"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("is_owner"), <bool as DatabaseField>::TYPE)
.set_null(<bool as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__teams"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("document"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("revision"), <i64 as DatabaseField>::TYPE)
.set_null(<i64 as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("created_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__team_members"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("permissions"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__team_history"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("revision"), <i64 as DatabaseField>::TYPE)
.set_null(<i64 as DatabaseField>::NULLABLE),
Field::new(Identifier::new("document"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("effective_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("actor_id"),
<Option<String> as DatabaseField>::TYPE,
)
.set_null(<Option<String> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__audit_events"))
.fields(&[
Field::new(Identifier::new("id"), <Auto<i64> as DatabaseField>::TYPE)
.set_null(<Auto<i64> as DatabaseField>::NULLABLE)
.primary_key()
.auto(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("team_id"),
<Option<String> as DatabaseField>::TYPE,
)
.set_null(<Option<String> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("actor_id"),
<Option<String> as DatabaseField>::TYPE,
)
.set_null(<Option<String> as DatabaseField>::NULLABLE),
Field::new(Identifier::new("action"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("details"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("created_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__sync_jobs"))
.fields(&[
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("requested_revision"),
<i64 as DatabaseField>::TYPE,
)
.set_null(<i64 as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("published_revision"),
<Option<i64> as DatabaseField>::TYPE,
)
.set_null(<Option<i64> as DatabaseField>::NULLABLE),
Field::new(Identifier::new("state"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("attempts"), <i32 as DatabaseField>::TYPE)
.set_null(<i32 as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("next_attempt"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("last_error"),
<Option<String> as DatabaseField>::TYPE,
)
.set_null(<Option<String> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("last_success"),
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
)
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("window_end"),
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
)
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("lease_token"),
<Option<String> as DatabaseField>::TYPE,
)
.set_null(<Option<String> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("lease_until"),
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
)
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("meguri__provider_objects"))
.fields(&[
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE)
.primary_key(),
Field::new(
Identifier::new("tenant_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("object_key"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("remote_id"),
<String as DatabaseField>::TYPE,
)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("content"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("updated_at"),
<DateTime<Utc> as DatabaseField>::TYPE,
)
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
])
.build(),
];
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "users")]
struct _UserRow {
#[model(primary_key)]
id: String,
#[model(unique)]
username: String,
display_name: String,
password_hash: String,
created_at: DateTime<Utc>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "external_identities")]
struct _ExternalIdentityRow {
#[model(primary_key)]
id: String,
issuer: String,
subject: String,
user_id: String,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "sessions")]
struct _SessionRow {
#[model(primary_key)]
token_hash: String,
user_id: String,
csrf: String,
expires_at: DateTime<Utc>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "tenants")]
struct _TenantRow {
#[model(primary_key)]
id: String,
name: String,
overwrite_drift: bool,
created_at: DateTime<Utc>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "tenant_members")]
struct _TenantMemberRow {
#[model(primary_key)]
id: String,
tenant_id: String,
user_id: String,
is_owner: bool,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "teams")]
struct _TeamRow {
#[model(primary_key)]
id: String,
tenant_id: String,
document: String,
revision: i64,
created_at: DateTime<Utc>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "team_members")]
struct _TeamMemberRow {
#[model(primary_key)]
id: String,
tenant_id: String,
team_id: String,
user_id: String,
permissions: String,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "team_history")]
struct _HistoryRow {
#[model(primary_key)]
id: String,
tenant_id: String,
team_id: String,
revision: i64,
document: String,
effective_at: DateTime<Utc>,
actor_id: Option<String>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "audit_events")]
struct _AuditRow {
#[model(primary_key)]
id: Auto<i64>,
tenant_id: String,
team_id: Option<String>,
actor_id: Option<String>,
action: String,
details: String,
created_at: DateTime<Utc>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "sync_jobs")]
struct _SyncJobRow {
#[model(primary_key)]
team_id: String,
tenant_id: String,
requested_revision: i64,
published_revision: Option<i64>,
state: String,
attempts: i32,
next_attempt: DateTime<Utc>,
last_error: Option<String>,
last_success: Option<DateTime<Utc>>,
window_end: Option<DateTime<Utc>>,
lease_token: Option<String>,
lease_until: Option<DateTime<Utc>>,
}
#[derive(Debug)]
#[model(model_type = "migration", table_name = "provider_objects")]
struct _ProviderObjectRow {
#[model(primary_key)]
id: String,
tenant_id: String,
team_id: String,
object_key: String,
remote_id: String,
content: String,
updated_at: DateTime<Utc>,
}
+44
View File
@@ -0,0 +1,44 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0002_constraints";
const DEPENDENCIES: &'static [MigrationDependency] =
&[MigrationDependency::migration("meguri", "m_0001_initial")];
const OPERATIONS: &'static [Operation] = &[Operation::custom(constraints).build()];
}
#[migration_op]
async fn constraints(context: MigrationContext<'_>) -> cot::db::Result<()> {
let statements = [
"CREATE UNIQUE INDEX IF NOT EXISTS tenant_member_identity ON meguri__tenant_members(tenant_id,user_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS team_identity ON meguri__teams(tenant_id,id)",
"CREATE UNIQUE INDEX IF NOT EXISTS team_member_identity ON meguri__team_members(tenant_id,team_id,user_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS history_revision ON meguri__team_history(team_id,revision)",
"CREATE UNIQUE INDEX IF NOT EXISTS provider_object_identity ON meguri__provider_objects(team_id,object_key)",
"CREATE UNIQUE INDEX IF NOT EXISTS external_identity ON meguri__external_identities(issuer,subject)",
"CREATE INDEX IF NOT EXISTS sync_due ON meguri__sync_jobs(next_attempt)",
"CREATE INDEX IF NOT EXISTS audit_team ON meguri__audit_events(tenant_id,team_id,id DESC)",
"CREATE INDEX IF NOT EXISTS history_time ON meguri__team_history(team_id,effective_at)",
"ALTER TABLE meguri__team_members ADD CONSTRAINT team_member_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
"ALTER TABLE meguri__team_members ADD CONSTRAINT team_member_tenant FOREIGN KEY(tenant_id,user_id) REFERENCES meguri__tenant_members(tenant_id,user_id)",
"ALTER TABLE meguri__tenant_members ADD CONSTRAINT tenant_member_tenant FOREIGN KEY(tenant_id) REFERENCES meguri__tenants(id)",
"ALTER TABLE meguri__tenant_members ADD CONSTRAINT tenant_member_user FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
"ALTER TABLE meguri__sessions ADD CONSTRAINT session_user_fk FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
"ALTER TABLE meguri__external_identities ADD CONSTRAINT external_identity_user_fk FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
"ALTER TABLE meguri__teams ADD CONSTRAINT team_tenant FOREIGN KEY(tenant_id) REFERENCES meguri__tenants(id)",
"ALTER TABLE meguri__team_history ADD CONSTRAINT history_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
"ALTER TABLE meguri__audit_events ADD CONSTRAINT audit_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
"ALTER TABLE meguri__sync_jobs ADD CONSTRAINT sync_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
"ALTER TABLE meguri__provider_objects ADD CONSTRAINT object_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
"ALTER TABLE meguri__teams ADD CONSTRAINT valid_document CHECK (jsonb_typeof(document::jsonb)='object')",
"CREATE UNIQUE INDEX IF NOT EXISTS jsm_destination ON meguri__teams((document::jsonb->'integration'->>'cloud_id'),(document::jsonb->'integration'->>'team_id')) WHERE document::jsonb->'integration'->>'provider'='jsm'",
];
// One PostgreSQL statement: a failed constraint rolls back the entire operation.
context
.db
.raw(&format!("DO $$ BEGIN {}; END $$;", statements.join(";\n")))
.await?;
Ok(())
}
+38
View File
@@ -0,0 +1,38 @@
//! Preserve old handovers and global holidays in both current state and historical snapshots.
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0003_calendars";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0002_constraints",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"
DO $$ DECLARE row record; doc jsonb; BEGIN
ALTER TABLE meguri__tenants ADD COLUMN working_interval text NOT NULL DEFAULT '{"timezone":"Europe/Bucharest","weekdays":[1,2,3,4,5],"start":"11:00:00","end":"19:00:00"}';
ALTER TABLE meguri__tenants ADD COLUMN settings_revision bigint NOT NULL DEFAULT 1;
FOR row IN SELECT id,document,false AS historical FROM meguri__teams UNION ALL SELECT id,document,true FROM meguri__team_history LOOP
doc := row.document::jsonb;
doc := jsonb_set(doc, '{schedules}', COALESCE((SELECT jsonb_agg(s || jsonb_build_object('handover',COALESCE(s->'handover',doc->'handover','"11:00:00"'::jsonb))) FROM jsonb_array_elements(doc->'schedules') s),'[]'::jsonb));
doc := doc - 'handover';
IF jsonb_typeof(doc->'holidays')='object' THEN
doc := jsonb_set(doc,'{holidays}',COALESCE((SELECT jsonb_agg(jsonb_build_object('date',key,'name',value,'country','*')) FROM jsonb_each(doc->'holidays')),'[]'::jsonb));
END IF;
doc := jsonb_set(doc,'{routes}',COALESCE((SELECT jsonb_agg(CASE WHEN r->'window' IS NOT NULL AND r->'window'<>'null'::jsonb THEN jsonb_set(r,'{window}',r->'window'||jsonb_build_object('timezone',doc->'timezone')) ELSE r END) FROM jsonb_array_elements(doc->'routes') r),'[]'::jsonb));
IF row.historical THEN UPDATE meguri__team_history SET document=doc::text WHERE id=row.id;
ELSE UPDATE meguri__teams SET document=doc::text WHERE id=row.id; END IF;
END LOOP;
-- Existing route snapshots retain their historical times. The earliest configured
-- interval initializes the workspace setting used for new or edited rules.
UPDATE meguri__tenants t SET working_interval=COALESCE((SELECT r->>'window' FROM meguri__teams team CROSS JOIN LATERAL jsonb_array_elements(team.document::jsonb->'routes') r WHERE team.tenant_id=t.id AND r->'window'<>'null'::jsonb ORDER BY team.created_at,team.id LIMIT 1),t.working_interval);
END $$;
"#).await?;
Ok(())
}
+15
View File
@@ -0,0 +1,15 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0004_user_preferences";
const DEPENDENCIES: &'static [MigrationDependency] =
&[MigrationDependency::migration("meguri", "m_0003_calendars")];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw("DO $$ BEGIN ALTER TABLE meguri__users ADD COLUMN country text NOT NULL DEFAULT 'CY'; ALTER TABLE meguri__users ADD COLUMN timezone text; END $$;").await?;
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0005_variables";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0004_user_preferences",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw("DO $$ BEGIN ALTER TABLE meguri__tenants ADD COLUMN variables text NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(variables::jsonb)='array'); ALTER TABLE meguri__tenants ADD COLUMN variables_revision bigint NOT NULL DEFAULT 1; END $$;").await?;
Ok(())
}
+28
View File
@@ -0,0 +1,28 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0006_demo_lifecycle";
const DEPENDENCIES: &'static [MigrationDependency] =
&[MigrationDependency::migration("meguri", "m_0005_variables")];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context
.db
.raw(
"DO $$ BEGIN
ALTER TABLE meguri__users ADD COLUMN demo_deleted_at timestamptz;
ALTER TABLE meguri__tenants ADD COLUMN demo_deleted_at timestamptz;
ALTER TABLE meguri__teams ADD COLUMN demo_deleted_at timestamptz;
CREATE TABLE meguri__demo_pins(kind text NOT NULL,id text NOT NULL,PRIMARY KEY(kind,id));
CREATE INDEX demo_users_age ON meguri__users(created_at);
CREATE INDEX demo_tenants_age ON meguri__tenants(created_at);
CREATE INDEX demo_teams_age ON meguri__teams(created_at);
END $$;",
)
.await?;
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0007_language";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0006_demo_lifecycle",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw("ALTER TABLE meguri__users ADD COLUMN language text CHECK (language IN ('en','ru','fr'))").await?;
Ok(())
}
+30
View File
@@ -0,0 +1,30 @@
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
#[derive(Debug, Copy, Clone)]
pub struct Migration;
impl cot::db::migrations::Migration for Migration {
const APP_NAME: &'static str = "meguri";
const MIGRATION_NAME: &'static str = "m_0008_workspaces";
const DEPENDENCIES: &'static [MigrationDependency] =
&[MigrationDependency::migration("meguri", "m_0007_language")];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"DO $$ BEGIN
IF EXISTS(SELECT 1 FROM meguri__tenants) OR EXISTS(SELECT 1 FROM meguri__teams) THEN
RAISE EXCEPTION 'Workspace schema requires a fresh database. Existing tenant/team data was not changed.';
END IF;
DROP TABLE meguri__provider_objects,meguri__sync_jobs,meguri__audit_events,meguri__team_history,meguri__team_members,meguri__tenant_members,meguri__teams,meguri__tenants;
CREATE TABLE meguri__workspaces(id text PRIMARY KEY,document text NOT NULL CHECK(jsonb_typeof(document::jsonb)='object'),revision bigint NOT NULL,created_at timestamptz NOT NULL,demo_deleted_at timestamptz);
CREATE TABLE meguri__workspace_members(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),user_id text NOT NULL REFERENCES meguri__users(id),permissions text NOT NULL CHECK(jsonb_typeof(permissions::jsonb)='array'),is_owner boolean NOT NULL,UNIQUE(workspace_id,user_id));
CREATE TABLE meguri__workspace_history(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),revision bigint NOT NULL,document text NOT NULL,effective_at timestamptz NOT NULL,actor_id text,UNIQUE(workspace_id,revision));
CREATE TABLE meguri__audit_events(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),actor_id text,action text NOT NULL,details text NOT NULL,created_at timestamptz NOT NULL);
CREATE TABLE meguri__sync_jobs(workspace_id text PRIMARY KEY REFERENCES meguri__workspaces(id),requested_revision bigint NOT NULL,published_revision bigint,state text NOT NULL,attempts integer NOT NULL,next_attempt timestamptz NOT NULL,last_error text,last_success timestamptz,window_end timestamptz,lease_token text,lease_until timestamptz);
CREATE TABLE meguri__provider_objects(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),object_key text NOT NULL,remote_id text NOT NULL,content text NOT NULL,updated_at timestamptz NOT NULL,UNIQUE(workspace_id,object_key));
CREATE INDEX workspace_sync_due ON meguri__sync_jobs(next_attempt);
CREATE INDEX workspace_audit ON meguri__audit_events(workspace_id,id DESC);
CREATE INDEX workspace_history_time ON meguri__workspace_history(workspace_id,effective_at);
CREATE UNIQUE INDEX workspace_jsm_destination ON meguri__workspaces((document::jsonb->'integration'->>'cloud_id'),(document::jsonb->'integration'->>'team_id')) WHERE document::jsonb->'integration'->>'provider'='jsm';
END $$;"#).await?;
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
//! Cot ORM models. UUIDs and typed documents use portable string storage in Cot 0.7.
use chrono::{DateTime, Utc};
use cot::db::{Auto, model};
#[derive(Debug, Clone)]
#[model(table_name = "users")]
pub struct UserRow {
pub language: Option<String>,
pub demo_deleted_at: Option<DateTime<Utc>>,
#[model(primary_key)]
pub id: String,
#[model(unique)]
pub username: String,
pub display_name: String,
pub country: String,
pub timezone: Option<String>,
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "external_identities")]
pub struct ExternalIdentityRow {
#[model(primary_key)]
pub id: String,
pub issuer: String,
pub subject: String,
pub user_id: String,
}
#[derive(Debug, Clone)]
#[model(table_name = "sessions")]
pub struct SessionRow {
#[model(primary_key)]
pub token_hash: String,
pub user_id: String,
pub csrf: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "workspaces")]
pub struct WorkspaceRow {
pub demo_deleted_at: Option<DateTime<Utc>>,
#[model(primary_key)]
pub id: String,
pub document: String,
pub revision: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "workspace_members")]
pub struct WorkspaceMemberRow {
#[model(primary_key)]
pub id: String,
pub workspace_id: String,
pub user_id: String,
pub permissions: String,
pub is_owner: bool,
}
#[derive(Debug, Clone)]
#[model(table_name = "workspace_history")]
pub struct HistoryRow {
#[model(primary_key)]
pub id: String,
pub workspace_id: String,
pub revision: i64,
pub document: String,
pub effective_at: DateTime<Utc>,
pub actor_id: Option<String>,
}
#[derive(Debug, Clone)]
#[model(table_name = "audit_events")]
pub struct AuditRow {
#[model(primary_key)]
pub id: Auto<i64>,
pub workspace_id: String,
pub actor_id: Option<String>,
pub action: String,
pub details: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
#[model(table_name = "sync_jobs")]
pub struct SyncJobRow {
#[model(primary_key)]
pub workspace_id: String,
pub requested_revision: i64,
pub published_revision: Option<i64>,
pub state: String,
pub attempts: i32,
pub next_attempt: DateTime<Utc>,
pub last_error: Option<String>,
pub last_success: Option<DateTime<Utc>>,
pub window_end: Option<DateTime<Utc>>,
pub lease_token: Option<String>,
pub lease_until: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
#[model(table_name = "provider_objects")]
pub struct ProviderObjectRow {
#[model(primary_key)]
pub id: String,
pub workspace_id: String,
pub object_key: String,
pub remote_id: String,
pub content: String,
pub updated_at: DateTime<Utc>,
}
+690
View File
@@ -0,0 +1,690 @@
use crate::{
domain::*,
error::{Error, Result, ensure},
models::ProviderObjectRow,
providers::Publication,
store::{Store, WorkspaceRecord},
};
use chrono::{DateTime, Timelike, Utc};
use reqwest::{Client, Method, StatusCode};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[derive(Clone)]
pub struct Jsm {
client: Client,
base: String,
email: String,
token: String,
team: String,
users: BTreeMap<Uuid, String>,
directory_base: Option<String>,
}
impl Jsm {
pub fn from_environment(record: &WorkspaceRecord) -> Result<Self> {
let Integration::Jsm {
cloud_id,
team_id,
users,
..
} = &record.document.integration
else {
return Err(Error::Validation(
crate::i18n::text("err_jsm_is_not_configured_4e6594").into(),
));
};
let prefix = format!(
"MEGURI_JSM_{}",
record.id.simple().to_string().to_uppercase()
);
let email = std::env::var(format!("{prefix}_EMAIL")).map_err(|_| {
Error::Provider(crate::i18n::message(
"err_missing_prefix_email_deployment_secret_6e5753",
&[("prefix", (prefix).to_string())],
))
})?;
let token = std::env::var(format!("{prefix}_TOKEN")).map_err(|_| {
Error::Provider(crate::i18n::message(
"err_missing_prefix_token_deployment_secret_1f0352",
&[("prefix", (prefix).to_string())],
))
})?;
Ok(Self::new(
format!("https://api.atlassian.com/jsm/ops/api/{cloud_id}/v1"),
email,
token,
team_id.clone(),
users.clone(),
)?
.with_directory_base(format!(
"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3"
)))
}
pub fn new(
base: String,
email: String,
token: String,
team: String,
users: BTreeMap<Uuid, String>,
) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| {
Error::Provider(
crate::i18n::text("err_could_not_initialize_jsm_client_09bad0").into(),
)
})?;
Ok(Self {
client,
base,
email,
token,
team,
users,
directory_base: None,
})
}
/// Explicit injection for the fake HTTP server; production always uses Atlassian's host.
pub fn with_directory_base(mut self, base: String) -> Self {
self.directory_base = Some(base);
self
}
async fn resolve_email(&self, email: &str) -> Result<String> {
let base = self.directory_base.as_ref().ok_or_else(|| {
Error::Provider(
crate::i18n::text("err_configure_the_atlassian_directory_or_provide_acc_266b32")
.into(),
)
})?;
let mut matches = BTreeSet::new();
for offset in (0..1000).step_by(100) {
let response = self
.client
.get(format!("{base}/user/search"))
.basic_auth(&self.email, Some(&self.token))
.query(&[
("query", email.to_owned()),
("startAt", offset.to_string()),
("maxResults", "100".into()),
])
.send()
.await
.map_err(|_| {
Error::Provider(
crate::i18n::text("err_atlassian_user_lookup_could_not_connect_6f312b")
.into(),
)
})?;
if !response.status().is_success() {
return Err(Error::Provider(crate::i18n::message(
"err_atlassian_user_lookup_returned_http_p0_grant_bro_a0f887",
&[("p0", (response.status().as_u16()).to_string())],
)));
}
let users: Vec<Value> = response.json().await.map_err(|_| {
Error::Provider(
crate::i18n::text("err_invalid_atlassian_directory_response_1c1889").into(),
)
})?;
for user in &users {
if user["active"].as_bool() == Some(true)
&& user["emailAddress"]
.as_str()
.is_some_and(|e| e.trim().eq_ignore_ascii_case(email))
&& let Some(id) = user["accountId"].as_str().filter(|id| !id.is_empty())
{
matches.insert(id.to_owned());
}
}
if users.len() < 100 {
break;
}
if offset == 900 {
return Err(Error::Provider(
crate::i18n::text(
"err_atlassian_search_limit_reached_provide_an_explic_d1fe8a",
)
.into(),
));
}
}
if matches.len() != 1 {
return Err(Error::Provider(crate::i18n::message(
"err_could_not_find_one_active_atlassian_account_with_39e9a9",
&[("email", (email).to_string())],
)));
}
Ok(matches.into_iter().next().unwrap())
}
async fn resolve_people(&self, record: &WorkspaceRecord, plan: &Publication) -> Result<Self> {
let mut resolved = self.clone();
let mut ids: BTreeSet<_> = plan
.schedules
.iter()
.flat_map(|s| s.periods.iter().map(|p| p.user_id))
.collect();
ids.extend(
plan.escalations
.iter()
.flat_map(|e| e.steps.iter())
.filter_map(|s| {
if let Recipient::User { id } = s.recipient {
Some(id)
} else {
None
}
}),
);
for id in ids {
if resolved.users.contains_key(&id) {
continue;
}
let email = record
.document
.person(id)
.and_then(|p| p.email.as_deref())
.ok_or_else(|| {
Error::Provider(crate::i18n::message(
"err_person_id_needs_an_email_or_an_atlassian_account_360521",
&[("id", (id).to_string())],
))
})?;
resolved.users.insert(id, self.resolve_email(email).await?);
}
Ok(resolved)
}
async fn request(
&self,
method: Method,
path: &str,
body: Option<&Value>,
) -> Result<Option<Value>> {
let mut req = self
.client
.request(method.clone(), format!("{}{path}", self.base))
.basic_auth(&self.email, Some(&self.token))
.header("Accept", "application/json");
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await.map_err(|_| {
Error::Provider(
crate::i18n::text("err_jsm_request_timed_out_or_could_not_connect_5ce428").into(),
)
})?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(Error::Provider(crate::i18n::message(
"err_jsm_returned_http_p0_for_p1_check_credentials_pl_b31caf",
&[
("p0", (response.status().as_u16()).to_string()),
("p1", (method).to_string()),
],
)));
}
if response.status() == StatusCode::NO_CONTENT {
return Ok(Some(Value::Null));
}
Ok(Some(response.json().await.map_err(|_| {
Error::Provider(
crate::i18n::text("err_jsm_returned_an_invalid_json_response_af8daf").into(),
)
})?))
}
async fn list(&self, path: &str) -> Result<Vec<Value>> {
let mut all = vec![];
let mut offset = 0;
loop {
let sep = if path.contains('?') { '&' } else { '?' };
let page = self
.request(
Method::GET,
&format!("{path}{sep}offset={offset}&size=100"),
None,
)
.await?
.ok_or_else(|| {
Error::Provider(
crate::i18n::text("err_jsm_collection_was_not_found_60d46c").into(),
)
})?;
let values = page
.get("values")
.and_then(Value::as_array)
.ok_or_else(|| {
Error::Provider(crate::i18n::text("err_jsm_collection_has_no_values").into())
})?;
let count = values.len();
all.extend(values.iter().cloned());
if page
.pointer("/links/next")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
&& count < 100
{
break;
}
if count == 0 {
break;
}
offset += count;
ensure(
offset <= 20000,
crate::i18n::text("err_jsm_collection_exceeds_the_supported_pagination_a1136e"),
)?;
}
Ok(all)
}
fn mapped(&self, id: Uuid) -> Result<String> {
self.users.get(&id).cloned().ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_employee_id_has_no_jsm_account_mapping_7b6db1",
&[("id", (id).to_string())],
))
})
}
fn collection(&self, kind: &str) -> String {
match kind {
"schedule" => "/schedules".into(),
"escalation" => format!("/teams/{}/escalations", self.team),
_ => format!("/teams/{}/routing-rules", self.team),
}
}
async fn read(&self, kind: &str, id: &str) -> Result<Option<Value>> {
let Some(mut object) = self
.request(
Method::GET,
&format!("{}/{id}", self.collection(kind)),
None,
)
.await?
else {
return Ok(None);
};
if kind == "schedule" {
object["rotations"] =
Value::Array(self.list(&format!("/schedules/{id}/rotations")).await?);
}
Ok(Some(object))
}
#[allow(clippy::too_many_arguments)]
async fn synchronize(
&self,
db: &Store,
record: &WorkspaceRecord,
kind: &str,
key: &str,
desired: &Value,
previous: Option<&ProviderObjectRow>,
overwrite: bool,
) -> Result<String> {
let name = desired["name"].as_str().unwrap_or_default();
let existing_id = if let Some(previous) = previous {
Some(previous.remote_id.clone())
} else {
let found: Vec<_> = self
.list(&self.collection(kind))
.await?
.into_iter()
.filter(|v| v["name"].as_str() == Some(name))
.collect();
ensure(
found.len() <= 1,
crate::i18n::text("err_multiple_jsm_resources_have_the_same_meguri_owne_889887"),
)?;
found
.first()
.and_then(|v| v["id"].as_str())
.map(str::to_string)
};
let current = if let Some(id) = &existing_id {
self.read(kind, id).await?
} else {
None
};
let expected = previous
.map(|p| serde_json::from_str::<Value>(&p.content))
.transpose()?;
if previous.is_some() && current.is_none() && !overwrite {
return Err(Error::Conflict(crate::i18n::message(
"err_jsm_kind_was_deleted_externally_publication_paus_88543d",
&[("kind", (kind).to_string())],
)));
}
let matches_desired = current.as_ref().is_some_and(|c| equivalent(c, desired));
if let Some(current) = &current {
let known = expected
.as_ref()
.is_some_and(|last| equivalent(current, last));
if !matches_desired && !known && !overwrite {
return Err(Error::Conflict(crate::i18n::message(
"err_jsm_kind_changed_outside_meguri_publication_paus_ac8220",
&[("kind", (kind).to_string())],
)));
}
}
if kind == "schedule"
&& let Some(id) = &existing_id
{
let overrides = self.list(&format!("/schedules/{id}/overrides")).await?;
if !overrides.is_empty() && !overwrite {
return Err(Error::Conflict(
crate::i18n::text(
"err_jsm_schedule_contains_external_overrides_publica_ff6a5d",
)
.into(),
));
}
for item in overrides {
let alias = item["alias"].as_str().ok_or_else(|| {
Error::Provider(crate::i18n::text("err_override_has_no_alias_0bab5d").into())
})?;
ensure(
safe_id(alias),
crate::i18n::text("err_unexpected_jsm_override_identifier_896f12"),
)?;
self.request(
Method::DELETE,
&format!("/schedules/{id}/overrides/{alias}"),
None,
)
.await?;
}
}
let id = if matches_desired {
existing_id.unwrap()
} else if current.is_some() {
let id = existing_id.unwrap();
self.request(
Method::PATCH,
&format!("{}/{id}", self.collection(kind)),
Some(desired),
)
.await?
.ok_or_else(|| {
Error::Conflict(
crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(),
)
})?;
id
} else {
let response = self
.request(Method::POST, &self.collection(kind), Some(desired))
.await?
.ok_or_else(|| {
Error::Provider(
crate::i18n::text("err_jsm_create_endpoint_was_not_found_1be86a").into(),
)
})?;
response["id"]
.as_str()
.ok_or_else(|| {
Error::Provider(
crate::i18n::text("err_jsm_created_resource_without_an_id").into(),
)
})?
.to_string()
};
ensure(
safe_id(&id),
crate::i18n::text("err_unexpected_jsm_resource_identifier_dedd70"),
)?;
let actual = self.read(kind, &id).await?.ok_or_else(|| {
Error::Provider(
crate::i18n::text("err_jsm_resource_is_missing_after_publication_3448ad").into(),
)
})?;
if !equivalent(&actual, desired) {
return Err(Error::Provider(crate::i18n::message(
"err_jsm_kind_read_back_does_not_match_the_requested_63440e",
&[("kind", (kind).to_string())],
)));
}
db.checkpoint(record.id, key, &id, desired).await?;
Ok(id)
}
pub async fn publish(
&self,
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
overwrite: bool,
) -> Result<()> {
// Preflight every recipient before creating or modifying any external object.
self.resolve_people(record, plan)
.await?
.publish_resolved(db, record, plan, overwrite)
.await
}
async fn publish_resolved(
&self,
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
overwrite: bool,
) -> Result<()> {
let objects = db.objects(record.id).await?;
let previous: BTreeMap<_, _> = objects.iter().map(|p| (p.object_key.as_str(), p)).collect();
let mut used = BTreeSet::new();
let mut schedules = BTreeMap::new();
let mut policies = BTreeMap::new();
for s in &plan.schedules {
let key = format!("schedule:{}", s.id);
used.insert(key.clone());
let rotations=s.periods.iter().map(|p|Ok(json!({"name":format!("shift-{}",p.start.timestamp()),"startDate":p.start,"endDate":p.end,"type":"daily","length":1,"participants":[{"type":"user","id":self.mapped(p.user_id)?}]}))).collect::<Result<Vec<_>>>()?;
let body = json!({"name":owned_name(record.id,s.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations});
let remote = self
.synchronize(
db,
record,
"schedule",
&key,
&body,
previous.get(key.as_str()).copied(),
overwrite,
)
.await?;
schedules.insert(s.id, remote);
}
for e in &plan.escalations {
let key = format!("escalation:{}", e.id);
used.insert(key.clone());
let mut rules = vec![];
for step in &e.steps {
let (kind, id, notify) = match step.recipient {
Recipient::Schedule { id } => (
"schedule",
schedules.get(&id).cloned().ok_or_else(|| {
Error::Validation(
crate::i18n::text(
"err_escalation_references_an_empty_schedule_2a0f3c",
)
.into(),
)
})?,
"default",
),
Recipient::User { id } => ("user", self.mapped(id)?, "default"),
Recipient::TeamAll => ("team", self.team.clone(), "all"),
Recipient::TeamRandom => ("team", self.team.clone(), "random"),
};
rules.push(json!({"condition":step.condition,"notifyType":notify,"delay":step.delay_minutes,"recipient":{"type":kind,"id":id}}));
}
let body = json!({"name":owned_name(record.id,e.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (e.name).to_string())]),"enabled":true,"rules":rules,"repeat":{"count":e.repeat.count.max(1),"waitInterval":if e.repeat.count==0 {0}else{e.repeat.wait_minutes},"resetRecipientStates":e.repeat.reset_recipient_states,"closeAlertAfterAll":e.repeat.close_alert_after_all}});
let remote = self
.synchronize(
db,
record,
"escalation",
&key,
&body,
previous.get(key.as_str()).copied(),
overwrite,
)
.await?;
policies.insert(e.id, remote);
}
let mut routes = plan.routes.clone();
routes.sort_by_key(|r| r.order);
for (order, route) in routes.iter().enumerate() {
let key = format!("route:{}", route.id);
used.insert(key.clone());
let notify = match route.escalation_id {
Some(id) => {
json!({"type":"escalation","id":policies.get(&id).ok_or_else(||Error::Validation(crate::i18n::text("err_missing_escalation_mapping_3fd412").into()))?})
}
None => json!({"type":"none"}),
};
let conditions:Vec<_>=route.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"expectedValue":c.value,"not":c.negate})).collect();
let criteria = if conditions.is_empty() {
json!({"type":"match-all"})
} else {
json!({"type":"match-all-conditions","conditions":conditions})
};
let mut body = json!({"name":owned_name(record.id,route.id),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify});
if let Some(w) = &route.window {
body["timezone"] = json!(w.timezone);
let days = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
let restrictions:Vec<_>=w.weekdays.iter().map(|d|json!({"startDay":days[usize::from(*d)-1],"endDay":days[usize::from(*d)-1],"startHour":w.start.hour(),"startMin":w.start.minute(),"endHour":w.end.hour(),"endMin":w.end.minute()})).collect();
body["timeRestriction"] =
json!({"type":"weekday-and-time-of-day","restrictions":restrictions});
} else {
body["timeRestriction"] = Value::Null;
}
self.synchronize(
db,
record,
"route",
&key,
&body,
previous.get(key.as_str()).copied(),
overwrite,
)
.await?;
}
// Delete only checkpointed, owned resources, in reverse dependency order.
for kind in ["route", "escalation", "schedule"] {
for old in objects.iter().filter(|o| {
o.object_key.starts_with(&format!("{kind}:")) && !used.contains(&o.object_key)
}) {
if let Some(current) = self.read(kind, &old.remote_id).await? {
let expected = serde_json::from_str(&old.content)?;
if !equivalent(&current, &expected) && !overwrite {
return Err(Error::Conflict(
crate::i18n::text(
"err_an_obsolete_jsm_resource_changed_externally_dele_a26bda",
)
.into(),
));
}
if kind == "schedule"
&& !overwrite
&& !self
.list(&format!("/schedules/{}/overrides", old.remote_id))
.await?
.is_empty()
{
return Err(Error::Conflict(
crate::i18n::text(
"err_an_obsolete_jsm_schedule_has_external_overrides_c6de63",
)
.into(),
));
}
self.request(
Method::DELETE,
&format!("{}/{}", self.collection(kind), old.remote_id),
None,
)
.await?;
}
db.forget(record.id, &old.object_key).await?;
}
}
Ok(())
}
}
fn owned_name(team: Uuid, id: Uuid) -> String {
format!("meguri-{}-{}", team.simple(), id.simple())
}
fn safe_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 200
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_".contains(c))
}
/// Ignore server-generated metadata, while comparing every managed field and array entry.
pub fn equivalent(actual: &Value, expected: &Value) -> bool {
match expected {
Value::Object(expected) => {
// A restriction added to a finite rotation changes coverage, not metadata.
if expected.contains_key("participants")
&& expected.contains_key("startDate")
&& !expected.contains_key("timeRestriction")
&& actual.get("timeRestriction").is_some_and(|v| !v.is_null())
{
return false;
}
expected
.iter()
.all(|(k, v)| equivalent(actual.get(k).unwrap_or(&Value::Null), v))
}
Value::Array(expected) => actual.as_array().is_some_and(|a| {
a.len() == expected.len() && a.iter().zip(expected).all(|(a, e)| equivalent(a, e))
}),
Value::String(expected) => {
if let (Ok(a), Ok(b)) = (
actual.as_str().unwrap_or_default().parse::<DateTime<Utc>>(),
expected.parse::<DateTime<Utc>>(),
) {
a == b
} else {
actual.as_str() == Some(expected)
}
}
_ => actual == expected,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalization_ignores_ids_but_detects_extra_rotations_and_changed_owners() {
let expected = json!({"rotations":[{"startDate":"2026-09-23T08:00:00Z","participants":[{"id":"alice","type":"user"}]}]});
let mut remote = json!({"id":"remote","rotations":[{"id":"rotation","startDate":"2026-09-23T08:00:00.000Z","participants":[{"id":"alice","type":"user"}]}]});
assert!(equivalent(&remote, &expected));
remote["rotations"][0]["timeRestriction"] =
json!({"type":"time-of-day","restriction":{"startHour":9,"endHour":17}});
assert!(!equivalent(&remote, &expected));
remote["rotations"][0]
.as_object_mut()
.unwrap()
.remove("timeRestriction");
remote["rotations"][0]["participants"][0]["id"] = json!("bob");
assert!(!equivalent(&remote, &expected));
remote["rotations"].as_array_mut().unwrap().clear();
assert!(!equivalent(&remote, &expected));
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Provider-independent publication contract. Providers never calculate rotations.
pub mod jsm;
use crate::{
domain::*,
error::{Result, ensure},
store::{Store, WorkspaceRecord},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishedSchedule {
pub id: Uuid,
pub name: String,
pub timezone: String,
pub periods: Vec<Assignment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Publication {
pub workspace_id: Uuid,
pub revision: i64,
pub from: DateTime<Utc>,
pub to: DateTime<Utc>,
pub schedules: Vec<PublishedSchedule>,
pub escalations: Vec<Escalation>,
pub routes: Vec<RoutingRule>,
}
pub fn validate_capabilities(doc: &WorkspaceDocument, now: DateTime<Utc>) -> Result<()> {
if let Integration::Jsm {
advanced_escalations,
users,
..
} = &doc.integration
{
for policy in &doc.escalations {
ensure(
*advanced_escalations
|| (policy.repeat.count == 0
&& !policy
.steps
.iter()
.any(|s| matches!(s.recipient, Recipient::TeamRandom))),
crate::i18n::text("err_random_team_recipients_and_repeats_require_jsm_p_69c0b1"),
)?;
}
let mut needed = std::collections::BTreeSet::new();
for s in &doc.schedules {
match &s.kind {
ScheduleKind::Rotation { participants, .. }
| ScheduleKind::Priority { participants, .. } => needed.extend(participants),
}
}
for r in doc
.requests
.iter()
.filter(|r| r.status == RequestStatus::Applied)
{
for l in r.effective_legs() {
if l.end > now {
needed.insert(&l.to_user);
}
}
}
for e in &doc.escalations {
for s in &e.steps {
if let Recipient::User { id } = &s.recipient {
needed.insert(id);
}
}
}
ensure(
needed.iter().all(|id| {
users.contains_key(id) || doc.person(**id).is_some_and(|p| p.email.is_some())
}),
crate::i18n::text("err_add_an_email_or_atlassian_account_id_for_every_s_7d378f"),
)?;
}
Ok(())
}
pub fn compile(
record: &WorkspaceRecord,
timeline: Timeline,
now: DateTime<Utc>,
) -> Result<Publication> {
validate_capabilities(&record.document, now)?;
if let Some(gap) = timeline
.assignments
.iter()
.find(|a| a.end > now && a.unavailable)
{
let person = record
.document
.person(gap.user_id)
.map(|p| p.display_name.clone())
.unwrap_or_else(|| gap.user_id.to_string());
let schedule = record
.document
.schedules
.iter()
.find(|s| s.id == gap.schedule_id)
.map(|s| s.name.clone())
.unwrap_or_else(|| gap.schedule_id.to_string());
return Err(crate::error::Error::Validation(crate::i18n::message(
"publication_absent_detail",
&[
("person", person),
("schedule", schedule),
(
"start",
gap.start.max(now).format("%d-%m-%Y %H:%M UTC").to_string(),
),
("end", gap.end.format("%d-%m-%Y %H:%M UTC").to_string()),
],
)));
}
let mut schedules = vec![];
for schedule in &record.document.schedules {
let mut periods: Vec<Assignment> = vec![];
let mut intervals: Vec<_> = timeline
.assignments
.iter()
.filter(|a| a.schedule_id == schedule.id)
.cloned()
.collect();
intervals.sort_by_key(|a| a.start);
for assignment in intervals {
if let Some(last) = periods.last_mut()
&& last.end == assignment.start
&& last.user_id == assignment.user_id
{
last.end = assignment.end;
continue;
}
periods.push(assignment);
}
if matches!(record.document.integration, Integration::Jsm { .. }) {
ensure(
periods.len() <= 500,
crate::i18n::text(
"err_jsm_supports_at_most_500_rotations_per_schedule_shorten_the_publication_horizon",
),
)?;
}
if !periods.is_empty() {
schedules.push(PublishedSchedule {
id: schedule.id,
name: schedule.name.clone(),
timezone: record.document.timezone.clone(),
periods,
});
}
}
Ok(Publication {
workspace_id: record.id,
revision: record.revision,
from: timeline.from,
to: timeline.to,
schedules,
escalations: record.document.escalations.clone(),
routes: record.document.routes.clone(),
})
}
/// Statically linked provider plugins share the canonical plan and checkpoint store.
/// No vendor-specific scheduling belongs in an implementation of this contract.
pub trait Provider: Send + Sync {
fn publish(
&self,
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
overwrite: bool,
) -> impl std::future::Future<Output = Result<()>> + Send;
}
pub struct Mock;
impl Provider for Mock {
async fn publish(
&self,
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
_overwrite: bool,
) -> Result<()> {
db.checkpoint(record.id, "mock", "mock", &serde_json::to_value(plan)?)
.await
}
}
impl Provider for jsm::Jsm {
async fn publish(
&self,
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
overwrite: bool,
) -> Result<()> {
jsm::Jsm::publish(self, db, record, plan, overwrite).await
}
}
pub async fn publish(
db: &Store,
record: &WorkspaceRecord,
plan: &Publication,
overwrite: bool,
) -> Result<()> {
ensure(
!crate::demo_runtime::active() || matches!(record.document.integration, Integration::Mock),
crate::i18n::text("err_external_publication_is_disabled_in_demo_mode_712662"),
)?;
match &record.document.integration {
Integration::Mock => Provider::publish(&Mock, db, record, plan, overwrite).await,
Integration::Jsm { .. } => {
Provider::publish(
&jsm::Jsm::from_environment(record)?,
db,
record,
plan,
overwrite,
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_escalation_is_not_silently_dropped() {
let mut doc = WorkspaceDocument::new("Platform".into());
doc.integration = Integration::Jsm {
cloud_id: Uuid::new_v4().to_string(),
team_id: Uuid::new_v4().to_string(),
advanced_escalations: false,
users: Default::default(),
};
doc.escalations.push(Escalation {
id: Uuid::new_v4(),
name: "Urgent".into(),
steps: vec![EscalationStep {
delay_minutes: 30,
condition: EscalationCondition::IfNotAcked,
recipient: Recipient::TeamRandom,
}],
repeat: Default::default(),
});
assert!(validate_capabilities(&doc, Utc::now()).is_err());
}
#[test]
fn absent_backup_and_rotation_block_future_publication() {
let user = Uuid::new_v4();
let id = Uuid::new_v4();
let day = "2026-09-23".parse().unwrap();
let mut document = WorkspaceDocument::new("Platform".into());
document.schedules.push(Schedule {
handover: default_handover(),
id,
name: "Backup".into(),
kind: ScheduleKind::Rotation {
participants: vec![user],
anchor: day,
shift_days: 7,
},
});
document.absences.push(Absence {
id: Uuid::new_v4(),
user_id: user,
start: day,
end: day,
});
let mut record = WorkspaceRecord {
id: Uuid::new_v4(),
revision: 1,
document,
};
let start = crate::engine::handover(&record.document, day).unwrap();
let end = start + chrono::Duration::hours(24);
let timeline = crate::engine::timeline(&record.document, start, end).unwrap();
assert!(timeline.assignments[0].unavailable);
let error = compile(&record, timeline.clone(), start)
.unwrap_err()
.to_string();
assert!(
error.contains("Backup")
&& error.contains(&user.to_string())
&& error.contains("23-09-2026")
);
record.document.schedules[0].kind = ScheduleKind::Rotation {
participants: vec![user],
anchor: day,
shift_days: 7,
};
assert!(compile(&record, timeline.clone(), start).is_err());
assert!(
compile(&record, timeline, end).is_ok(),
"An absence that has ended cannot block a future publication"
);
}
}
+975
View File
@@ -0,0 +1,975 @@
use crate::{
domain::*,
engine,
error::{Error, Result, ensure},
models::*,
store::{self, Store, WorkspaceRecord},
};
use argon2::{
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
password_hash::{SaltString, rand_core::OsRng},
};
use chrono::{Duration, Utc};
use cot::db::{Model, query};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use uuid::Uuid;
pub fn token_hash(token: &str) -> String {
format!("{:x}", Sha256::digest(token.as_bytes()))
}
pub async fn hash_password(password: String) -> Result<String> {
ensure(
(12..=256).contains(&password.len()),
crate::i18n::text("err_password_must_contain_12_256_characters_3169d0"),
)?;
tokio::task::spawn_blocking(move || {
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.map(|hash| hash.to_string())
.map_err(|_| {
Error::Internal(crate::i18n::text("err_could_not_hash_password_212449").into())
})
})
.await
.map_err(|e| Error::Internal(e.to_string()))?
}
pub async fn register(
db: &Store,
username: String,
display_name: String,
password: String,
) -> Result<User> {
let username = username.trim().to_ascii_lowercase();
ensure(
(3..=64).contains(&username.len())
&& username
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)),
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
)?;
validate_name(&display_name)?;
let hash = hash_password(password).await?;
let user = User {
language: None,
id: Uuid::new_v4(),
username,
display_name,
country: "CY".into(),
timezone: None,
};
let result = UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
timezone: None,
password_hash: hash,
created_at: Utc::now(),
}
.insert(&db.db)
.await;
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
return Err(Error::Conflict(
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
));
}
result?;
Ok(user)
}
pub async fn login(
db: &Store,
username: String,
password: String,
) -> Result<(User, String, String)> {
ensure(
password.len() <= 256 && username.len() <= 64,
crate::i18n::text("err_invalid_credentials_e41784"),
)?;
let username = username.trim().to_ascii_lowercase();
let row = query!(UserRow,$username==username).get(&db.db).await?;
let row = row.filter(|r| r.demo_deleted_at.is_none());
// Perform the expensive KDF for an unknown username too.
let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c";
let hash: String = row
.as_ref()
.map(|r| r.password_hash.clone())
.unwrap_or(fallback.into());
let valid = tokio::task::spawn_blocking(move || {
PasswordHash::new(&hash).is_ok_and(|h| {
Argon2::default()
.verify_password(password.as_bytes(), &h)
.is_ok()
})
})
.await
.map_err(|e| Error::Internal(e.to_string()))?;
if !valid || row.is_none() {
return Err(Error::Unauthorized);
}
let row = row.unwrap();
let user = store::user(row)?;
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let csrf = Uuid::new_v4().to_string();
SessionRow {
token_hash: token_hash(&token),
user_id: user.id.to_string(),
csrf: csrf.clone(),
expires_at: Utc::now() + Duration::hours(12),
}
.insert(&db.db)
.await?;
Ok((user, token, csrf))
}
pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result<WorkspaceRecord> {
validate_name(&name)?;
let id = Uuid::new_v4();
let doc = WorkspaceDocument::new(name);
db.db
.raw_with(
CREATE_WORKSPACE,
&[
&id.to_string(),
&serde_json::to_string(&doc)?,
&actor.to_string(),
&serde_json::to_string(&Permission::all())?,
],
)
.await?;
db.workspace(id).await
}
pub async fn update_document(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
mut document: WorkspaceDocument,
) -> Result<WorkspaceRecord> {
let mut record = db
.prepare(workspace, revision, actor, Permission::EditSchedules)
.await?;
document.requests = record.document.requests.clone();
for request in &mut document.requests {
if request.status == RequestStatus::Pending
&& request
.absence_id
.is_some_and(|id| !document.absences.iter().any(|a| a.id == id))
{
request.status = RequestStatus::Cancelled;
}
}
document.integration = record.document.integration.clone();
document.working_interval = record.document.working_interval.clone();
document.overwrite_drift = record.document.overwrite_drift;
crate::generation::validate_manual_edit(&record.document, &mut document)?;
let members = db.member_ids(workspace).await?;
for person in &mut document.people {
person.email = person
.email
.take()
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty());
person.country = person
.country
.take()
.map(|c| c.trim().to_ascii_uppercase())
.filter(|c| !c.is_empty());
if person.country.is_none() {
person.country = Some("CY".into());
}
if let Some(old) = record.document.person(person.id) {
ensure(
old.user_id == person.user_id,
crate::i18n::text("err_a_contact_cannot_be_linked_to_a_login_through_an_68dd72"),
)?;
} else if person.user_id.is_none() {
let id = person.id.to_string();
ensure(
!query!(UserRow,$id==id).exists(&db.db).await?,
crate::i18n::text("err_select_the_workspace_user_from_the_directory_725423"),
)?;
}
}
let interval = db.working_interval(workspace).await?;
document.timezone = interval.timezone.clone();
for route in &mut document.routes {
if route.window.is_some() {
route.window = Some(interval.clone());
}
}
document.validate(&members)?;
let members = document.person_ids(&members);
for request in document
.requests
.iter()
.filter(|r| r.status == RequestStatus::Applied)
{
for leg in request
.effective_legs()
.iter()
.filter(|l| l.end > Utc::now())
{
ensure(
document.schedule(leg.schedule_id).is_some(),
crate::i18n::text("err_cannot_remove_a_schedule_with_an_active_approved_a83aa7"),
)?;
ensure(
members.contains(&leg.to_user),
crate::i18n::text("err_an_approved_replacement_references_a_missing_mem_fd0f2a"),
)?;
}
}
engine::timeline(
&document,
Utc::now(),
Utc::now() + Duration::days(i64::from(document.horizon_days)),
)?;
let before = serde_json::to_value(&record.document)?;
record.document = document;
let after = serde_json::to_value(&record.document)?;
db.commit(
&mut record,
actor,
Permission::EditSchedules,
"configuration.updated",
json!({"before":before,"after":after}),
None,
)
.await?;
Ok(record)
}
#[derive(Debug, Deserialize)]
pub struct RemoveSchedule {
pub revision: i64,
pub redirect_to: Option<Uuid>,
}
pub async fn remove_schedule(
db: &Store,
actor: Uuid,
workspace: Uuid,
schedule: Uuid,
input: RemoveSchedule,
) -> Result<WorkspaceRecord> {
let mut record = db
.prepare(workspace, input.revision, actor, Permission::EditSchedules)
.await?;
let now = Utc::now();
let plan = crate::configuration::schedule_removal_plan(&record.document, schedule, now)?;
let document =
crate::configuration::remove_schedule(&record.document, schedule, input.redirect_to, now)?;
document.validate(&db.member_ids(workspace).await?)?;
engine::timeline(
&document,
now,
now + Duration::days(i64::from(document.horizon_days)),
)?;
record.document = document;
db.commit(
&mut record,
actor,
Permission::EditSchedules,
"schedule.removed",
json!({"impact":plan,"redirect_to":input.redirect_to}),
None,
)
.await?;
Ok(record)
}
#[derive(Debug, Deserialize)]
pub struct ReorderRoutes {
pub revision: i64,
pub ids: Vec<Uuid>,
}
pub async fn reorder_routes(
db: &Store,
actor: Uuid,
workspace: Uuid,
input: ReorderRoutes,
) -> Result<WorkspaceRecord> {
let mut record = db
.prepare(workspace, input.revision, actor, Permission::EditSchedules)
.await?;
let mut previous = record.document.routes.clone();
previous.sort_by_key(|r| r.order);
let before: Vec<_> = previous.iter().map(|r| r.id).collect();
crate::configuration::reorder_routes(&mut record.document, &input.ids)?;
crate::generation::validate_route_block(&mut record.document)?;
db.commit(
&mut record,
actor,
Permission::EditSchedules,
"routing.reordered",
json!({"before":before,"after":input.ids}),
None,
)
.await?;
Ok(record)
}
pub async fn update_integration(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
integration: Integration,
) -> Result<WorkspaceRecord> {
ensure(
!crate::demo_runtime::active() || matches!(integration, Integration::Mock),
crate::i18n::text("err_demo_mode_uses_the_local_preview_provider_extern_e125f3"),
)?;
let mut record = db
.prepare(workspace, revision, actor, Permission::ManageIntegration)
.await?;
let old = &record.document.integration;
let same_destination = match (old, &integration) {
(Integration::Mock, _) => true,
(
Integration::Jsm {
cloud_id: a,
team_id: b,
..
},
Integration::Jsm {
cloud_id: c,
team_id: d,
..
},
) => a == c && b == d,
_ => false,
};
let published = db
.objects(workspace)
.await?
.iter()
.any(|o| o.object_key != "mock");
ensure(
!published || same_destination,
crate::i18n::text("err_a_published_jsm_destination_cannot_be_changed_in_7bc9a5"),
)?;
record.document.integration = integration;
record.document.validate(&db.member_ids(workspace).await?)?;
crate::providers::validate_capabilities(&record.document, Utc::now())?;
let details = serde_json::to_value(&record.document.integration)?;
db.commit(
&mut record,
actor,
Permission::ManageIntegration,
"integration.updated",
details,
None,
)
.await?;
Ok(record)
}
pub async fn set_member(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
username: String,
permissions: Vec<Permission>,
) -> Result<WorkspaceRecord> {
ensure(
permissions.contains(&Permission::View),
crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"),
)?;
let mut record = db
.prepare(workspace, revision, actor, Permission::ManageMembers)
.await?;
let username = username.trim().to_ascii_lowercase();
let user = query!(UserRow,$username==username)
.get(&db.db)
.await?
.ok_or(Error::Validation(
crate::i18n::text("err_ask_this_person_to_create_an_account_first_897eb2").into(),
))?;
let user = store::uuid(&user.id)?;
db.commit(
&mut record,
actor,
Permission::ManageMembers,
"member.permissions_changed",
json!({"user_id":user,"permissions":permissions}),
Some((user, permissions)),
)
.await?;
Ok(record)
}
#[derive(Debug, Deserialize)]
pub struct NewRequest {
#[serde(default)]
pub remaining: bool,
pub revision: i64,
#[serde(default)]
pub reason: String,
pub legs: Vec<ReplacementLeg>,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize)]
pub struct NewAbsence {
pub revision: i64,
#[serde(flatten)]
pub draft: crate::absence::Draft,
#[serde(default)]
pub people: Vec<Person>,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub reason: String,
}
async fn prepare_absence(
db: &Store,
actor: Uuid,
workspace: Uuid,
input: &NewAbsence,
now: chrono::DateTime<Utc>,
) -> Result<(WorkspaceRecord, Permission, crate::absence::Plan)> {
let grants = db.require(workspace, actor, Permission::View).await?;
let permission = if input.force {
Permission::ForceOverrides
} else if actor != input.draft.absence.user_id || grants.contains(&Permission::EditSchedules) {
Permission::EditSchedules
} else {
Permission::RequestOverrides
};
let mut record = db
.prepare(workspace, input.revision, actor, permission)
.await?;
ensure(
input.people.len() <= 100,
crate::i18n::text("err_add_at_most_100_contacts_at_once_e720fc"),
)?;
let participants = db.member_ids(workspace).await?;
for new in &input.people {
if let Some(old) = record.document.person(new.id) {
ensure(
serde_json::to_value(old)? == serde_json::to_value(new)?,
crate::i18n::text("err_edit_an_existing_contact_in_team_settings_ad289b"),
)?;
continue;
}
let mut person = new.clone();
person.email = person
.email
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty());
person.country = Some(
person
.country
.unwrap_or_else(|| "CY".into())
.trim()
.to_ascii_uppercase(),
);
if person.user_id.is_none() {
let key = person.id.to_string();
ensure(
!query!(UserRow,$id==key).exists(&db.db).await?,
crate::i18n::text("err_select_an_existing_workspace_user_from_the_direc_a09a03"),
)?;
}
record.document.people.push(person);
}
record.document.validate(&participants)?;
let mut plan = crate::absence::plan(&record.document, &input.draft, &participants, now)?;
if !input.force {
let members = db.member_ids(workspace).await?;
for id in plan
.groups
.iter()
.flatten()
.map(|l| l.to_user)
.collect::<BTreeSet<_>>()
{
if !members.contains(&id) {
plan.errors.push(
crate::i18n::text(
"err_a_selected_replacement_has_no_team_access_to_con_dd538a",
)
.into(),
);
}
}
}
Ok((record, permission, plan))
}
pub async fn preview_absence(
db: &Store,
actor: Uuid,
workspace: Uuid,
input: NewAbsence,
) -> Result<serde_json::Value> {
let (record, _, plan) = prepare_absence(db, actor, workspace, &input, Utc::now()).await?;
Ok(json!({"revision":record.revision,"plan":plan}))
}
pub async fn create_absence(
db: &Store,
actor: Uuid,
workspace: Uuid,
input: NewAbsence,
) -> Result<WorkspaceRecord> {
let now = Utc::now();
let (mut record, permission, plan) = prepare_absence(db, actor, workspace, &input, now).await?;
ensure(plan.errors.is_empty(), plan.errors.join("; "))?;
ensure(
input.reason.len() <= 1000,
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
)?;
ensure(
record.document.requests.len() + plan.groups.len() <= 5000,
crate::i18n::text("err_replacement_history_limit_reached_3b1168"),
)?;
record.document.absences.push(input.draft.absence.clone());
for legs in &plan.groups {
let mut request =
crate::absence::synthetic_request(input.draft.absence.id, legs.clone(), now);
request.id = Uuid::new_v4();
request.requested_by = actor;
request.reason = input.reason.trim().into();
request.required_approvers = legs
.iter()
.flat_map(|l| [l.from_user, l.to_user])
.filter(|id| *id != actor)
.collect();
request.status = if input.force {
RequestStatus::Applied
} else {
RequestStatus::Pending
};
request.applied_at = input.force.then_some(now);
request.forced_by = input.force.then_some(actor);
request.force_reason = input.force.then_some(input.reason.clone());
if input.force {
engine::supersede_overrides(&mut record.document, &request.legs);
}
record.document.requests.push(request);
}
db.commit(&mut record,actor,permission,if input.force {"absence.forced"} else {"absence.created"},json!({"absence":input.draft.absence,"ranges":input.draft.ranges,"impact":plan,"force":input.force,"reason":input.reason}),None).await?;
Ok(record)
}
pub async fn request_replacement(
db: &Store,
actor: Uuid,
workspace: Uuid,
mut input: NewRequest,
) -> Result<WorkspaceRecord> {
ensure(
input.reason.len() <= 1000,
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
)?;
let permission = if input.force {
Permission::ForceOverrides
} else {
Permission::RequestOverrides
};
let mut record = db
.prepare(workspace, input.revision, actor, permission)
.await?;
let members = db.member_ids(workspace).await?;
let now = Utc::now();
clip_requested_interval(&mut input, now);
engine::validate_legs(&record.document, &input.legs, &members, now)?;
ensure(
record.document.requests.len() < 5000,
crate::i18n::text("err_replacement_history_limit_reached_3b1168"),
)?;
validate_request_participation(actor, &input)?;
validate_request_access(&input, &members)?;
let required_approvers = input
.legs
.iter()
.flat_map(|l| [l.from_user, l.to_user])
.filter(|id| *id != actor)
.collect();
let request = ReplacementRequest {
effective_legs: None,
id: Uuid::new_v4(),
absence_id: None,
requested_by: actor,
created_at: now,
reason: input.reason.clone(),
legs: input.legs,
required_approvers,
approvals: BTreeSet::new(),
status: if input.force {
RequestStatus::Applied
} else {
RequestStatus::Pending
},
applied_at: input.force.then_some(now),
forced_by: input.force.then_some(actor),
force_reason: input.force.then_some(input.reason),
};
let details = serde_json::to_value(&request)?;
if input.force {
engine::supersede_overrides(&mut record.document, &request.legs);
}
record.document.requests.push(request);
db.commit(
&mut record,
actor,
permission,
if input.force {
"replacement.forced"
} else {
"replacement.requested"
},
details,
None,
)
.await?;
Ok(record)
}
fn validate_request_participation(actor: Uuid, input: &NewRequest) -> Result<()> {
ensure(
input.force
|| input
.legs
.iter()
.all(|leg| leg.from_user == actor || leg.to_user == actor),
crate::i18n::text("err_you_can_give_your_own_shift_or_offer_to_cover_a_5e6f57"),
)
}
fn validate_request_access(input: &NewRequest, members: &BTreeSet<Uuid>) -> Result<()> {
ensure(
input.force
|| input
.legs
.iter()
.all(|l| members.contains(&l.from_user) && members.contains(&l.to_user)),
crate::i18n::text("err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5"),
)
}
fn clip_requested_interval(input: &mut NewRequest, now: chrono::DateTime<Utc>) {
if input.remaining
&& let Some(leg) = input.legs.first_mut()
{
leg.start = leg.start.max(now);
}
}
/// Read-only validation of the same ownership, availability and permission checks used on save.
pub async fn preview_replacement(
db: &Store,
actor: Uuid,
workspace: Uuid,
mut input: NewRequest,
) -> Result<serde_json::Value> {
let permission = if input.force {
Permission::ForceOverrides
} else {
Permission::RequestOverrides
};
let record = db
.prepare(workspace, input.revision, actor, permission)
.await?;
validate_request_participation(actor, &input)?;
let members = db.member_ids(workspace).await?;
validate_request_access(&input, &members)?;
ensure(
input.reason.len() <= 1000,
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
)?;
let now = Utc::now();
clip_requested_interval(&mut input, now);
engine::validate_legs(&record.document, &input.legs, &members, now)?;
Ok(serde_json::json!({"legs": input.legs}))
}
#[derive(Debug, Deserialize)]
pub struct RequestDecision {
pub revision: i64,
pub action: String,
#[serde(default)]
pub reason: String,
}
pub async fn decide_request(
db: &Store,
actor: Uuid,
workspace: Uuid,
id: Uuid,
input: RequestDecision,
) -> Result<WorkspaceRecord> {
let permission = if input.action == "force" {
Permission::ForceOverrides
} else {
Permission::View
};
let mut record = db
.prepare(workspace, input.revision, actor, permission)
.await?;
let index = record
.document
.requests
.iter()
.position(|r| r.id == id)
.ok_or(Error::NotFound)?;
let mut request = record.document.requests[index].clone();
if request.status != RequestStatus::Pending {
return Err(Error::Conflict(
crate::i18n::text("err_this_request_has_already_been_resolved_26b69e").into(),
));
}
let mut publish = false;
match input.action.as_str() {
"approve" => {
if !request.required_approvers.contains(&actor) {
return Err(Error::Forbidden);
}
request.approvals.insert(actor);
publish = request.required_approvers.is_subset(&request.approvals);
}
"force" => {
ensure(
input.reason.len() <= 1000,
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
)?;
request.forced_by = Some(actor);
request.force_reason = Some(input.reason);
publish = true;
}
"reject" => {
if !request.required_approvers.contains(&actor) {
return Err(Error::Forbidden);
}
request.status = RequestStatus::Rejected;
}
"cancel" => {
if request.requested_by != actor {
return Err(Error::Forbidden);
}
request.status = RequestStatus::Cancelled;
}
_ => {
return Err(Error::Validation(
crate::i18n::text("err_unknown_request_action_0db000").into(),
));
}
}
if publish {
let members = db.member_ids(workspace).await?;
let now = Utc::now();
if request.absence_id.is_some() {
request.legs.retain(|l| l.end > now);
for leg in &mut request.legs {
leg.start = leg.start.max(now);
}
crate::absence::validate_request(&record.document, &request, &members, now)?;
} else {
for leg in &mut request.legs {
leg.start = leg.start.max(now);
}
engine::validate_legs(&record.document, &request.legs, &members, now)?;
}
engine::supersede_overrides(&mut record.document, &request.legs);
request.status = RequestStatus::Applied;
request.applied_at = Some(Utc::now());
}
let details = serde_json::to_value(&request)?;
record.document.requests[index] = request;
db.commit(
&mut record,
actor,
permission,
&format!("replacement.{}", input.action),
details,
None,
)
.await?;
Ok(record)
}
pub async fn set_drift_policy(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
overwrite: bool,
) -> Result<()> {
db.owner(workspace, actor).await?;
let mut record = db
.prepare(workspace, revision, actor, Permission::ManageIntegration)
.await?;
record.document.overwrite_drift = overwrite;
db.commit(
&mut record,
actor,
Permission::ManageIntegration,
"workspace.drift_policy_changed",
json!({"overwrite_drift":overwrite}),
None,
)
.await
}
pub async fn import_holidays(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
csv: &str,
) -> Result<WorkspaceRecord> {
let mut record = db
.prepare(workspace, revision, actor, Permission::EditSchedules)
.await?;
let preview = crate::holidays::compare(csv, &record.document.holidays)?;
ensure(
preview.errors.is_empty(),
crate::i18n::text("err_fix_the_csv_errors_before_importing_fe5a4e"),
)?;
ensure(
!preview.additions.is_empty(),
crate::i18n::text("err_there_are_no_new_holidays_to_import_f77467"),
)?;
let details = json!({"added":preview.additions,"existing":preview.existing,"conflicts":preview.conflicts});
record.document.holidays.extend(preview.additions);
record.document.validate(&db.member_ids(workspace).await?)?;
db.commit(
&mut record,
actor,
Permission::EditSchedules,
"holidays.imported",
details,
None,
)
.await?;
Ok(record)
}
pub async fn set_working_interval(
db: &Store,
actor: Uuid,
workspace: Uuid,
revision: i64,
interval: WorkingWindow,
) -> Result<()> {
db.owner(workspace, actor).await?;
interval.validate()?;
let mut record = db
.prepare(workspace, revision, actor, Permission::EditSchedules)
.await?;
record.document.timezone = interval.timezone.clone();
record.document.working_interval = interval.clone();
for route in &mut record.document.routes {
if route.window.is_some() {
route.window = Some(interval.clone());
}
}
engine::timeline(
&record.document,
Utc::now(),
Utc::now() + Duration::days(i64::from(record.document.horizon_days)),
)?;
db.commit(
&mut record,
actor,
Permission::EditSchedules,
"working_interval.updated",
serde_json::to_value(interval)?,
None,
)
.await
}
pub async fn save_profile(db: &Store, user: &UserRow) -> Result<()> {
// Country changes become new scheduling snapshots. Display timezone never enters
// a workspace document and therefore cannot move a shift or enqueue publication.
db.db
.raw_with(
SAVE_PROFILE,
&[
&user.id,
&user.display_name,
&user.country,
&user.timezone,
&user.password_hash,
&user.language,
],
)
.await?;
Ok(())
}
pub async fn seed_demo(db: &Store) -> Result<()> {
crate::demo::seed(db).await
}
pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
let actor = user.to_string();
let mut workspaces = vec![];
for membership in query!(WorkspaceMemberRow,$user_id==actor)
.all(&db.db)
.await?
{
let id = membership.workspace_id;
let row = query!(WorkspaceRow,$id==id)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
if row.demo_deleted_at.is_some() {
continue;
}
let record = store::record(row)?;
if db.require(record.id, user, Permission::View).await.is_ok() {
workspaces.push(
json!({"id":record.id,"name":record.document.name,"is_owner":membership.is_owner}),
);
}
}
Ok(json!({"workspaces":workspaces}))
}
const CREATE_WORKSPACE: &str = r#"
WITH workspace AS (
INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now()) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT id||':'||$3,id,$3,$4,true FROM workspace
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),$3 FROM workspace
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$3,'workspace.created','{}',now() FROM workspace
"#;
const SAVE_PROFILE: &str = r#"
WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS (
UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING *
), changed_workspaces AS (
UPDATE meguri__workspaces t SET revision=t.revision+1,document=jsonb_set(t.document::jsonb,'{people}',
COALESCE((SELECT jsonb_agg(CASE WHEN p->>'user_id'=$1 THEN p||jsonb_build_object('country',$3::text) ELSE p END) FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p),'[]'::jsonb)
|| CASE WHEN EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p WHERE p->>'id'=$1) THEN '[]'::jsonb ELSE jsonb_build_array(jsonb_build_object('id',$1::text,'user_id',$1::text,'display_name',$2::text,'email',null,'country',$3::text)) END)::text
WHERE EXISTS(SELECT 1 FROM previous JOIN changed USING(id) WHERE previous.country<>changed.country)
AND (EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=t.id AND user_id=$1) OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p WHERE p->>'user_id'=$1)) RETURNING t.*
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$1 FROM changed_workspaces RETURNING workspace_id,effective_at
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM changed_workspaces
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at)
SELECT id,$1,'person.country_changed',jsonb_build_object('user_id',$1::text,'country',$3::text)::text,(SELECT effective_at FROM history WHERE workspace_id=changed_workspaces.id) FROM changed_workspaces
"#;
+398
View File
@@ -0,0 +1,398 @@
use crate::{
domain::*,
error::{Error, Result},
models::*,
};
use chrono::{DateTime, Utc};
use cot::db::{Database, Model, query};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeSet;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct Store {
pub db: Database,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceRecord {
pub id: Uuid,
pub revision: i64,
pub document: WorkspaceDocument,
}
pub fn uuid(value: &str) -> Result<Uuid> {
Uuid::parse_str(value).map_err(|_| {
Error::Internal(crate::i18n::text("err_invalid_stored_identifier_7c6a5b").into())
})
}
pub fn user(row: UserRow) -> Result<User> {
Ok(User {
language: row.language,
id: uuid(&row.id)?,
username: row.username,
display_name: row.display_name,
country: row.country,
timezone: row.timezone,
})
}
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
if row.demo_deleted_at.is_some() {
return Err(Error::NotFound);
}
Ok(WorkspaceRecord {
id: uuid(&row.id)?,
revision: row.revision,
document: serde_json::from_str(&row.document)?,
})
}
impl Store {
pub async fn connect(url: &str) -> Result<Self> {
let db = Database::new(url).await?;
cot::db::migrations::MigrationEngine::new(crate::migrations::MIGRATIONS.iter().copied())?
.run(&db)
.await?;
Ok(Self { db })
}
pub async fn workspace(&self, workspace: Uuid) -> Result<WorkspaceRecord> {
let id = workspace.to_string();
record(
query!(WorkspaceRow,$id==id)
.get(&self.db)
.await?
.ok_or(Error::NotFound)?,
)
}
pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result<Vec<Permission>> {
self.workspace(workspace).await?;
let workspace = workspace.to_string();
let actor = actor.to_string();
let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor)
.get(&self.db)
.await?
.ok_or(Error::NotFound)?;
if row.is_owner {
Ok(Permission::all())
} else {
Ok(serde_json::from_str(&row.permissions)?)
}
}
pub async fn require(
&self,
workspace: Uuid,
actor: Uuid,
permission: Permission,
) -> Result<Vec<Permission>> {
let grants = self.permissions(workspace, actor).await?;
if grants.contains(&permission) {
Ok(grants)
} else {
Err(Error::Forbidden)
}
}
pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> {
self.workspace(workspace).await?;
let workspace = workspace.to_string();
let actor = actor.to_string();
if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true)
.exists(&self.db)
.await?
{
Ok(())
} else {
Err(Error::Forbidden)
}
}
pub async fn members(&self, workspace: Uuid) -> Result<Vec<Member>> {
let workspace = workspace.to_string();
let mut members = vec![];
for m in query!(WorkspaceMemberRow,$workspace_id==workspace)
.all(&self.db)
.await?
{
let id = m.user_id;
let row = query!(UserRow,$id==id)
.get(&self.db)
.await?
.ok_or(Error::NotFound)?;
members.push(Member {
user: user(row)?,
permissions: if m.is_owner {
Permission::all()
} else {
serde_json::from_str(&m.permissions)?
},
});
}
members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name));
Ok(members)
}
pub async fn member_ids(&self, workspace: Uuid) -> Result<BTreeSet<Uuid>> {
Ok(self
.members(workspace)
.await?
.into_iter()
.map(|m| m.user.id)
.collect())
}
pub async fn directory(&self, workspace: Uuid) -> Result<Vec<User>> {
Ok(self
.members(workspace)
.await?
.into_iter()
.map(|m| m.user)
.collect())
}
pub async fn working_interval(&self, workspace: Uuid) -> Result<WorkingWindow> {
Ok(self.workspace(workspace).await?.document.working_interval)
}
pub async fn prepare(
&self,
workspace: Uuid,
revision: i64,
actor: Uuid,
permission: Permission,
) -> Result<WorkspaceRecord> {
self.require(workspace, actor, permission).await?;
let record = self.workspace(workspace).await?;
if record.revision != revision {
return Err(Error::Conflict(
crate::i18n::text("err_this_team_changed_refresh_before_saving_7f93ad").into(),
));
}
Ok(record)
}
/// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS,
/// membership (if any), history, audit, and durable job as one PostgreSQL statement.
#[allow(clippy::too_many_arguments)]
pub async fn commit(
&self,
record: &mut WorkspaceRecord,
actor: Uuid,
permission: Permission,
action: &str,
details: Value,
member: Option<(Uuid, Vec<Permission>)>,
) -> Result<()> {
let workspace = record.id.to_string();
let actor = actor.to_string();
let document = serde_json::to_string(&record.document)?;
let details = details.to_string();
let required = serde_json::to_string(&vec![permission])?;
let member_id = member.as_ref().map(|(id, _)| id.to_string());
let grants = serde_json::to_string(&member.map(|(_, g)| g).unwrap_or_default())?;
let result = self
.db
.raw_with(
COMMIT_WORKSPACE,
&[
&workspace,
&record.revision,
&document,
&actor,
&action,
&details,
&required,
&member_id,
&grants,
],
)
.await?;
if result.rows_affected().0 == 0 {
return Err(Error::Conflict(
crate::i18n::text("err_this_team_or_your_permissions_changed_refresh_be_c1e4c2")
.into(),
));
}
record.revision += 1;
Ok(())
}
pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> {
let mut names = crate::audit::Names::new();
for person in self.directory(workspace).await? {
names.insert(person.id.to_string(), person.display_name);
}
let current = self.workspace(workspace).await?;
crate::audit::remember(&mut names, &current.document);
let history_team = workspace.to_string();
let mut history = query!(HistoryRow, $workspace_id == history_team)
.all(&self.db)
.await?;
history.sort_by_key(|h| (h.effective_at, h.revision));
let mut history = history.into_iter().peekable();
let workspace = workspace.to_string();
let mut events = query!(AuditRow,$workspace_id==workspace)
.all(&self.db)
.await?;
events.sort_by_key(|a| std::cmp::Reverse(a.id.unwrap()));
events.truncate(100);
events.sort_by_key(|a| (a.created_at, a.id.unwrap()));
let mut output = vec![];
for e in events {
while history
.peek()
.is_some_and(|h| h.effective_at <= e.created_at)
{
let snapshot = history.next().unwrap();
let document: WorkspaceDocument = serde_json::from_str(&snapshot.document)?;
crate::audit::remember(&mut names, &document);
}
let actor_name = e.actor_id.as_ref().and_then(|id| names.get(id)).cloned();
let actor = if let Some(id) = e.actor_id {
query!(UserRow,$id==id)
.get(&self.db)
.await?
.map(|u| u.username)
.unwrap_or("Unknown".into())
} else {
"System".into()
};
let actor_name = actor_name
.map(|name| format!("{name} (@{actor})"))
.unwrap_or_else(|| actor.clone());
let details: Value = serde_json::from_str(&e.details)?;
let resolved_details = crate::audit::resolve(&details, &names);
output.push(json!({"id":e.id.unwrap(),"action":e.action,"details":details,"resolved_details":resolved_details,"created_at":e.created_at,"actor":actor,"actor_name":actor_name}));
}
output.sort_by_key(|e| std::cmp::Reverse(e["id"].as_i64().unwrap_or_default()));
Ok(output)
}
pub async fn sync_status(&self, workspace: Uuid) -> Result<Value> {
let workspace = workspace.to_string();
Ok(
match query!(SyncJobRow,$workspace_id==workspace)
.get(&self.db)
.await?
{
None => json!({"state":"pending"}),
Some(r) => {
json!({"requested_revision":r.requested_revision,"published_revision":r.published_revision,"state":r.state,"attempts":r.attempts,"last_error":r.last_error,"last_success":r.last_success,"window_end":r.window_end})
}
},
)
}
pub async fn historical_timeline(
&self,
workspace: &WorkspaceRecord,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Timeline> {
crate::error::ensure(
from < to && (to - from) <= chrono::Duration::days(367),
crate::i18n::text("err_invalid_calendar_range_c177b5"),
)?;
let id = workspace.id.to_string();
let revision = workspace.revision;
let mut history =
query!(HistoryRow,$workspace_id==id && $effective_at<to && $revision<=revision)
.all(&self.db)
.await?;
history.sort_by_key(|r| (r.effective_at, r.revision));
let mut result = Timeline {
from,
to,
assignments: vec![],
};
for (index, row) in history.iter().enumerate() {
let start = row.effective_at.max(from);
let end = history
.get(index + 1)
.map(|r| r.effective_at)
.unwrap_or(to)
.min(to);
if start < end {
result.assignments.extend(
crate::engine::timeline(&serde_json::from_str(&row.document)?, start, end)?
.assignments,
);
}
}
// History records actions as well as rule changes. Adjacent identical
// assignments remain one interval in the calendar.
result.assignments.sort_by_key(|a| (a.schedule_id, a.start));
let mut merged: Vec<Assignment> = vec![];
for assignment in result.assignments {
if let Some(last) = merged.last_mut()
&& last.schedule_id == assignment.schedule_id
&& last.end == assignment.start
&& last.user_id == assignment.user_id
&& last.reason == assignment.reason
&& last.unavailable == assignment.unavailable
{
last.end = assignment.end;
} else {
merged.push(assignment);
}
}
result.assignments = merged;
Ok(result)
}
pub async fn objects(&self, workspace: Uuid) -> Result<Vec<ProviderObjectRow>> {
let workspace = workspace.to_string();
Ok(query!(ProviderObjectRow,$workspace_id==workspace)
.all(&self.db)
.await?)
}
pub async fn checkpoint(
&self,
workspace: Uuid,
key: &str,
remote_id: &str,
content: &Value,
) -> Result<()> {
ProviderObjectRow {
id: format!("{workspace}:{key}"),
workspace_id: workspace.to_string(),
object_key: key.into(),
remote_id: remote_id.into(),
content: content.to_string(),
updated_at: Utc::now(),
}
.save(&self.db)
.await?;
Ok(())
}
pub async fn forget(&self, workspace: Uuid, key: &str) -> Result<()> {
let id = format!("{workspace}:{key}");
query!(ProviderObjectRow,$id==id).delete(&self.db).await?;
Ok(())
}
pub async fn retry(&self, workspace: Uuid, actor: Uuid) -> Result<()> {
self.require(workspace, actor, Permission::ManageIntegration)
.await?;
let mut record = self.workspace(workspace).await?;
self.commit(
&mut record,
actor,
Permission::ManageIntegration,
"publication.requested",
json!({}),
None,
)
.await
}
}
const COMMIT_WORKSPACE: &str = r#"
WITH updated AS (
UPDATE meguri__workspaces SET document=$3,revision=revision+1
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL
AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb)) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner)
SELECT $1||':'||$8,$1,$8,$9,false FROM updated WHERE $8::text IS NOT NULL
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at
), jobs AS (
INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt)
SELECT id,revision,'pending',0,now() FROM updated
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL
)
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at)
SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated
"#;
+841
View File
@@ -0,0 +1,841 @@
use crate::{
domain::*,
engine,
error::{Error, Result, ensure},
models::*,
service,
store::{self, Store},
};
use chrono::{Duration, NaiveDate, Utc};
use cot::{
App, AppBuilder, Method, Project, StatusCode,
db::query,
html::Html,
json::Json,
project::RegisterAppsContext,
request::{Request, RequestExt},
response::{IntoResponse, Response},
router::{Route, Router},
};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
pub struct MeguriApp;
impl App for MeguriApp {
fn name(&self) -> &str {
"meguri"
}
fn migrations(&self) -> Vec<Box<cot::db::migrations::SyncDynMigration>> {
if enabled("MEGURI_SKIP_MIGRATIONS") {
vec![]
} else {
cot::db::migrations::wrap_migrations(crate::migrations::MIGRATIONS)
}
}
fn router(&self) -> Router {
let mut routes = vec![
Route::with_handler("/", index),
Route::with_handler("/assets/app.css", css),
Route::with_handler("/assets/app.js", js),
Route::with_handler("/assets/generation.js", generation_js),
Route::with_handler("/assets/generation-map.js", generation_map_js),
Route::with_handler("/assets/cytoscape.js", cytoscape_js),
Route::with_handler("/assets/i18n.js", i18n_js),
Route::with_handler("/assets/alpine.js", alpine),
Route::with_handler("/health/live", live),
Route::with_handler("/health/ready", ready),
];
for path in [
"/api/session",
"/api/register",
"/api/login",
"/api/logout",
"/api/account",
"/api/workspaces",
"/api/workspaces/{workspace}",
"/api/workspaces/{workspace}/working-interval",
"/api/workspaces/{workspace}/calendar",
"/api/workspaces/{workspace}/config",
"/api/workspaces/{workspace}/schedules/{schedule}/removal",
"/api/workspaces/{workspace}/schedules/{schedule}/remove",
"/api/workspaces/{workspace}/routes/order",
"/api/workspaces/{workspace}/absence-preview",
"/api/workspaces/{workspace}/absences",
"/api/workspaces/{workspace}/generation-preview",
"/api/workspaces/{workspace}/generation",
"/api/workspaces/{workspace}/members",
"/api/workspaces/{workspace}/requests",
"/api/workspaces/{workspace}/request-preview",
"/api/workspaces/{workspace}/requests/{request}",
"/api/workspaces/{workspace}/integration",
"/api/workspaces/{workspace}/sync",
"/api/workspaces/{workspace}/preview",
"/api/workspaces/{workspace}/holiday-preview",
"/api/workspaces/{workspace}/holiday-import",
] {
routes.push(Route::with_handler(path, api));
}
Router::with_urls(routes)
}
}
pub struct MeguriProject;
impl Project for MeguriProject {
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
apps.register_with_views(MeguriApp, "");
}
}
pub fn enabled(name: &str) -> bool {
std::env::var(name).is_ok_and(|v| v == "1" || v == "true")
}
fn origin() -> String {
std::env::var("MEGURI_PUBLIC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:3000".into())
.trim_end_matches('/')
.to_owned()
}
fn response(value: Value) -> Result<Response> {
Json(value)
.into_response()
.map_err(|e| Error::Internal(e.to_string()))
}
fn secured(response: Response) -> cot::Result<Response> {
response
.with_header("X-Content-Type-Options", "nosniff")
.with_header("X-Frame-Options", "DENY")
.with_header("Referrer-Policy", "same-origin")
.with_header("Cache-Control", "no-store")
.into_response()
}
async fn index() -> cot::Result<Response> {
let html = include_str!("../static/index.html")
.replace(
"<!-- GENERATION_MAP -->",
include_str!("../static/generation-map.html"),
)
.replace(
"<!-- RESOURCE_INVENTORY -->",
include_str!("../static/resource-inventory.html"),
)
.replace(
"<!-- RESOURCE_DETAILS -->",
include_str!("../static/resource-details.html"),
)
.replace(
"<!-- GENERATION_PAGE -->",
include_str!("../static/generation-page.html"),
)
.replace(
"<!-- GENERATION_MODALS -->",
include_str!("../static/generation-modals.html"),
)
.replace(
"<!-- GENERATION_PREVIEW -->",
include_str!("../static/generation-preview.html"),
)
.replace(
"<!-- BINDING_POPOVER -->",
include_str!("../static/binding-popover.html"),
)
.replace(
"<!-- VALIDATION_STATUS -->",
include_str!("../static/validation-status.html"),
);
secured(Html::new(html).with_header("Content-Security-Policy","default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'").into_response()?)
}
async fn generation_map_js() -> cot::Result<Response> {
include_str!("../static/generation-map.js")
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn cytoscape_js() -> cot::Result<Response> {
include_str!("../static/vendor/cytoscape.js")
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn generation_js() -> cot::Result<Response> {
include_str!("../static/generation.js")
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn i18n_js() -> cot::Result<Response> {
format!(
"const MEGURI_MESSAGES = Object.freeze({});\n{}",
include_str!("../static/locales/messages.json"),
include_str!("../static/i18n.js")
)
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn css() -> cot::Result<Response> {
include_str!("../static/app.css")
.with_content_type("text/css; charset=utf-8")
.into_response()
}
async fn js() -> cot::Result<Response> {
include_str!("../static/app.js")
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn alpine() -> cot::Result<Response> {
include_str!("../static/vendor/alpine.js")
.with_content_type("text/javascript; charset=utf-8")
.into_response()
}
async fn live() -> Json<Value> {
Json(json!({"status":"ok"}))
}
async fn ready(request: Request) -> cot::Result<Response> {
match request.context().database().raw("SELECT 1").await {
Ok(_) => Json(json!({"status":"ready"})).into_response(),
Err(_) => Json(json!({"status":"unavailable"}))
.with_status(StatusCode::SERVICE_UNAVAILABLE)
.into_response(),
}
}
#[derive(Clone)]
pub struct Identity {
pub user: User,
pub csrf: String,
pub token_hash: String,
}
async fn authenticate(db: &Store, request: &Request) -> Result<Identity> {
let cookie = request
.headers()
.get("cookie")
.and_then(|h| h.to_str().ok())
.unwrap_or_default();
let token = cookie
.split(';')
.map(str::trim)
.find_map(|c| c.strip_prefix("meguri_session="))
.ok_or(Error::Unauthorized)?;
if token.len() != 64 {
return Err(Error::Unauthorized);
}
let hash = service::token_hash(token);
let now = Utc::now();
let session = query!(SessionRow,$token_hash==hash.clone() && $expires_at>now)
.get(&db.db)
.await?
.ok_or(Error::Unauthorized)?;
let id = session.user_id;
let user = query!(UserRow,$id==id)
.get(&db.db)
.await?
.ok_or(Error::Unauthorized)?;
if user.demo_deleted_at.is_some() {
return Err(Error::Unauthorized);
}
Ok(Identity {
user: store::user(user)?,
csrf: session.csrf,
token_hash: hash,
})
}
async fn api(request: Request) -> cot::Result<Response> {
let result = dispatch(request).await;
secured(match result {
Ok(response) => response,
Err(error) => error.into_response()?,
})
}
async fn dispatch(request: Request) -> Result<Response> {
let db = Store {
db: request.context().database().clone(),
};
let method = request.method().clone();
let path = request.uri().path().to_owned();
let query_string = request.uri().query().unwrap_or_default().to_owned();
let mutation = method != Method::GET && method != Method::HEAD;
if mutation {
if request
.headers()
.get("origin")
.and_then(|v| v.to_str().ok())
!= Some(origin().as_str())
{
return Err(Error::Forbidden);
}
ensure(
request
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|s| s.starts_with("application/json")),
crate::i18n::text("err_send_a_json_request_34adc9"),
)?;
}
let identity = authenticate(&db, &request).await;
if path == "/api/session" && method == Method::GET {
return match identity {
Ok(i) => response(
json!({"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
),
Err(Error::Unauthorized) => response(
json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
),
Err(e) => Err(e),
};
}
let anonymous = path == "/api/login" || path == "/api/register";
if mutation && !anonymous {
let i = identity.as_ref().map_err(|_| Error::Unauthorized)?;
if request
.headers()
.get("x-csrf-token")
.and_then(|v| v.to_str().ok())
!= Some(&i.csrf)
{
return Err(Error::Forbidden);
}
}
let body: Value = if mutation {
let bytes = request
.into_body()
.into_bytes_limited(1_048_576)
.await
.map_err(|_| {
Error::Validation(crate::i18n::text("err_request_body_is_too_large_fda0d1").into())
})?;
serde_json::from_slice(&bytes).map_err(|_| {
Error::Validation(crate::i18n::text("err_invalid_json_request_650225").into())
})?
} else {
Value::Null
};
if anonymous && method == Method::POST {
#[derive(Deserialize)]
struct Credentials {
username: String,
password: String,
#[serde(default)]
display_name: String,
}
let credentials: Credentials = decode(body)?;
check_login_rate(&credentials.username)?;
if path == "/api/register" {
if !enabled("MEGURI_ALLOW_SIGNUP") {
return Err(Error::Forbidden);
}
service::register(
&db,
credentials.username.clone(),
credentials.display_name,
credentials.password.clone(),
)
.await?;
}
let (user, token, csrf) =
service::login(&db, credentials.username, credentials.password).await?;
let secure = if origin().starts_with("https://") {
"; Secure"
} else {
""
};
return response(json!({"user":user,"csrf":csrf}))?
.with_header(
"Set-Cookie",
format!(
"meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}"
),
)
.into_response()
.map_err(|e| Error::Internal(e.to_string()));
}
let identity = identity?;
let actor = identity.user.id;
if path == "/api/logout" && method == Method::POST {
let token = identity.token_hash;
query!(SessionRow,$token_hash==token).delete(&db.db).await?;
return response(json!({"ok":true}))?
.with_header(
"Set-Cookie",
"meguri_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
)
.into_response()
.map_err(|e| Error::Internal(e.to_string()));
}
if path == "/api/workspaces" && method == Method::GET {
return response(service::list_workspaces(&db, actor).await?);
}
if path == "/api/account" && method == Method::PUT {
let name = body["display_name"].as_str().unwrap_or_default();
validate_name(name)?;
let id = actor.to_string();
let mut user = query!(UserRow,$id==id)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) {
let current = body["current_password"]
.as_str()
.unwrap_or_default()
.to_owned();
let (_, temporary, _) = service::login(&db, user.username.clone(), current).await?;
let hash = service::token_hash(&temporary);
query!(SessionRow,$token_hash==hash).delete(&db.db).await?;
user.password_hash = service::hash_password(password.to_owned()).await?;
}
user.display_name = name.into();
if let Some(language) = body.get("language") {
ensure(
language.is_null() || language.is_string(),
crate::i18n::text("language_invalid"),
)?;
user.language = language
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned);
ensure(
user.language
.as_deref()
.is_none_or(|l| ["en", "ru", "fr"].contains(&l)),
crate::i18n::text("language_invalid"),
)?;
}
user.country = text(&body, "country")?.to_ascii_uppercase();
user.timezone = body["timezone"]
.as_str()
.filter(|z| !z.is_empty())
.map(str::to_owned);
ensure(
valid_country(&user.country),
crate::i18n::text("choose_a_country_7843c8"),
)?;
ensure(
user.timezone
.as_ref()
.is_none_or(|z| z.parse::<chrono_tz::Tz>().is_ok()),
crate::i18n::text("err_choose_a_valid_iana_timezone_176bd9"),
)?;
service::save_profile(&db, &user).await?;
return response(json!({"user":store::user(user)?}));
}
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
if parts == ["api", "workspaces"] && method == Method::POST {
return response(serde_json::to_value(
service::create_workspace(&db, actor, text(&body, "name")?).await?,
)?);
}
if parts.len() < 3 || parts[1] != "workspaces" {
return Err(Error::NotFound);
}
let workspace = parse_id(parts[2])?;
if parts.len() == 4 && parts[3] == "working-interval" && method == Method::PUT {
service::set_working_interval(
&db,
actor,
workspace,
revision(&body)?,
decode(body["interval"].clone())?,
)
.await?;
return response(json!({"ok":true}));
}
if parts.len() == 3 && method == Method::PATCH {
service::set_drift_policy(
&db,
actor,
workspace,
revision(&body)?,
body["overwrite_drift"].as_bool().ok_or_else(|| {
Error::Validation(crate::i18n::text("err_choose_a_drift_policy_5a1cd6").into())
})?,
)
.await?;
return response(json!({"ok":true}));
}
let grants = db.require(workspace, actor, Permission::View).await?;
if parts.len() == 3 && method == Method::GET {
let record = db.workspace(workspace).await?;
let audit = if grants.contains(&Permission::ViewAudit) {
db.audit(workspace).await?
} else {
vec![]
};
let now = Utc::now();
let coverage = if record
.document
.absences
.iter()
.any(|a| a.end >= now.date_naive() - Duration::days(2))
{
engine::publication_window(&record.document, now)
.and_then(|(from, to)| engine::timeline(&record.document, from, to))
.map(|timeline| {
let gaps: Vec<_> = timeline.assignments.into_iter().filter(|a| a.unavailable && a.end > now).collect();
json!({"total":gaps.len(),"issues":gaps.into_iter().take(50).collect::<Vec<_>>()})
}).unwrap_or_else(|e| json!({"total":0,"issues":[],"error":e.to_string()}))
} else {
json!({"total":0,"issues":[]})
};
return response(
json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}),
);
}
let action = parts.get(3).copied().unwrap_or_default();
if parts.len() == 4 && method == Method::POST && action == "generation-preview" {
return response(
crate::generation_service::preview(&db, actor, workspace, decode(body)?).await?,
);
}
if parts.len() == 4 && method == Method::POST && action == "generation" {
return response(
crate::generation_service::apply(&db, actor, workspace, decode(body)?).await?,
);
}
if parts.len() == 4 && method == Method::POST && action == "absence-preview" {
return response(service::preview_absence(&db, actor, workspace, decode(body)?).await?);
}
if parts.len() == 4 && method == Method::POST && action == "absences" {
return response(serde_json::to_value(
service::create_absence(&db, actor, workspace, decode(body)?).await?,
)?);
}
if action == "schedules" && parts.len() == 6 {
let schedule = parse_id(parts[4])?;
if parts[5] == "removal" && method == Method::GET {
db.require(workspace, actor, Permission::EditSchedules)
.await?;
let record = db.workspace(workspace).await?;
return response(
json!({"revision":record.revision,"plan":crate::configuration::schedule_removal_plan(&record.document,schedule,Utc::now())?}),
);
}
if parts[5] == "remove" && method == Method::POST {
return response(serde_json::to_value(
service::remove_schedule(&db, actor, workspace, schedule, decode(body)?).await?,
)?);
}
}
if action == "routes" && parts.len() == 5 && parts[4] == "order" && method == Method::PUT {
return response(serde_json::to_value(
service::reorder_routes(&db, actor, workspace, decode(body)?).await?,
)?);
}
if action == "holiday-preview" && method == Method::POST {
db.require(workspace, actor, Permission::EditSchedules)
.await?;
let record = db.workspace(workspace).await?;
return response(
json!({"revision":record.revision,"preview":crate::holidays::compare(&text(&body,"csv")?,&record.document.holidays)?}),
);
}
if action == "holiday-import" && method == Method::POST {
return response(serde_json::to_value(
service::import_holidays(
&db,
actor,
workspace,
revision(&body)?,
&text(&body, "csv")?,
)
.await?,
)?);
}
if action == "calendar" && method == Method::GET {
let record = db.workspace(workspace).await?;
let url =
reqwest::Url::parse(&format!("http://localhost/?{query_string}")).map_err(|_| {
Error::Validation(crate::i18n::text("err_invalid_calendar_query_feced6").into())
})?;
let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect();
let day: NaiveDate = params
.get("from")
.map(String::as_str)
.unwrap_or_default()
.parse()
.map_err(|_| {
Error::Validation(crate::i18n::text("err_choose_a_calendar_date_e25e18").into())
})?;
ensure(
(2000..=2100).contains(&chrono::Datelike::year(&day)),
crate::i18n::text("err_calendar_year_must_be_2000_2100_8681fc"),
)?;
let days: i64 = params
.get("days")
.and_then(|v| v.parse().ok())
.unwrap_or(28);
ensure(
(1..=366).contains(&days),
crate::i18n::text("err_calendar_range_must_be_1_366_days_8e177e"),
)?;
let schedule = params
.get("schedule")
.filter(|s| !s.is_empty())
.map(|s| parse_id(s))
.transpose()?
.or_else(|| record.document.schedules.first().map(|s| s.id));
let boundary = |date| match schedule {
Some(id) => engine::schedule_handover(&record.document, id, date),
None => engine::handover(&record.document, date),
};
let from = boundary(day)?;
let to = boundary(day + Duration::days(days))?;
let mut result = serde_json::to_value(db.historical_timeline(&record, from, to).await?)?;
let mut duty_days = vec![];
for offset in 0..days {
let date = day + Duration::days(offset);
duty_days.push(json!({"date":date,"start":boundary(date)?,"end":boundary(date+Duration::days(1))?}));
}
result["days"] = json!(duty_days);
return response(result);
}
if action == "preview" && method == Method::GET {
db.require(workspace, actor, Permission::ManageIntegration)
.await?;
let record = db.workspace(workspace).await?;
let (from, to) = engine::publication_window(&record.document, Utc::now())?;
return response(serde_json::to_value(crate::providers::compile(
&record,
db.historical_timeline(&record, from, to).await?,
Utc::now(),
)?)?);
}
if action == "config" && method == Method::PUT {
return response(serde_json::to_value(
service::update_document(
&db,
actor,
workspace,
revision(&body)?,
decode(body["document"].clone())?,
)
.await?,
)?);
}
if action == "integration" && method == Method::PUT {
return response(serde_json::to_value(
service::update_integration(
&db,
actor,
workspace,
revision(&body)?,
decode(body["integration"].clone())?,
)
.await?,
)?);
}
if action == "members" && method == Method::PUT {
return response(serde_json::to_value(
service::set_member(
&db,
actor,
workspace,
revision(&body)?,
text(&body, "username")?,
decode(body["permissions"].clone())?,
)
.await?,
)?);
}
if ["requests", "request-preview"].contains(&action)
&& parts.len() == 4
&& method == Method::POST
{
let mut body = body;
if body.get("legs").is_none() {
let record = db.workspace(workspace).await?;
let from = parse_id(&text(&body, "from_user")?)?;
let to = parse_id(&text(&body, "to_user")?)?;
let schedule = parse_id(&text(&body, "schedule_id")?)?;
let leg = |start: &str,
end: &str,
start_clock: &str,
end_clock: &str,
from,
to|
-> Result<ReplacementLeg> {
let start: NaiveDate = text(&body, start)?.parse().map_err(|_| {
Error::Validation(crate::i18n::text("err_invalid_start_date_92aea1").into())
})?;
let end: NaiveDate = text(&body, end)?.parse().map_err(|_| {
Error::Validation(crate::i18n::text("err_invalid_end_date_68eb79").into())
})?;
Ok(ReplacementLeg {
schedule_id: schedule,
start: replacement_boundary(
&record.document,
schedule,
start,
body.get(start_clock),
)?,
end: replacement_boundary(
&record.document,
schedule,
end,
body.get(end_clock),
)?,
from_user: from,
to_user: to,
})
};
let mut legs = vec![leg(
"start_day",
"end_day",
"start_time",
"end_time",
from,
to,
)?];
if body["swap"].as_bool() == Some(true) {
legs.push(leg(
"return_start",
"return_end",
"return_start_time",
"return_end_time",
to,
from,
)?);
}
body["legs"] = serde_json::to_value(legs)?;
}
let input: service::NewRequest = decode(body)?;
if action == "request-preview" {
return response(service::preview_replacement(&db, actor, workspace, input).await?);
}
return response(serde_json::to_value(
service::request_replacement(&db, actor, workspace, input).await?,
)?);
}
if action == "requests" && parts.len() == 5 && method == Method::POST {
return response(serde_json::to_value(
service::decide_request(&db, actor, workspace, parse_id(parts[4])?, decode(body)?)
.await?,
)?);
}
if action == "sync" && method == Method::POST {
db.retry(workspace, actor).await?;
return response(json!({"ok":true}));
}
Err(Error::NotFound)
}
fn replacement_boundary(
doc: &WorkspaceDocument,
schedule: Uuid,
date: NaiveDate,
clock: Option<&Value>,
) -> Result<chrono::DateTime<Utc>> {
match clock {
None => engine::schedule_handover(doc, schedule, date),
Some(value) => {
let time = value
.as_str()
.filter(|s| s.len() == 5)
.and_then(|s| chrono::NaiveTime::parse_from_str(s, "%H:%M").ok())
.ok_or_else(|| Error::Validation(crate::i18n::text("cover_invalid_time").into()))?;
engine::local_time(doc, date, time)
}
}
}
fn parse_id(value: &str) -> Result<Uuid> {
value.parse().map_err(|_| Error::NotFound)
}
fn text(body: &Value, key: &str) -> Result<String> {
body[key].as_str().map(str::to_string).ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_missing_key_364001",
&[("key", (key).to_string())],
))
})
}
fn revision(body: &Value) -> Result<i64> {
body["revision"].as_i64().ok_or_else(|| {
Error::Validation(crate::i18n::text("err_missing_configuration_revision_07cf43").into())
})
}
fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
serde_json::from_value(value).map_err(|e| {
Error::Validation(crate::i18n::message(
"err_invalid_request_e_2228c5",
&[("e", (e).to_string())],
))
})
}
fn check_login_rate(username: &str) -> Result<()> {
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
static ATTEMPTS: OnceLock<Mutex<HashMap<String, (Instant, u16)>>> = OnceLock::new();
let mut attempts = ATTEMPTS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| {
Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into())
})?;
attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300));
if attempts.len() > 2000 {
return Err(Error::Validation(
crate::i18n::text("err_too_many_sign_in_attempts_try_again_later_abceb2").into(),
));
}
let entry = attempts
.entry(username.trim().to_ascii_lowercase())
.or_insert((Instant::now(), 0));
entry.1 += 1;
ensure(
entry.1 <= 30,
crate::i18n::text("err_too_many_sign_in_attempts_try_again_in_five_minu_cc6ccd"),
)
}
#[cfg(test)]
mod time_input_tests {
use super::*;
#[test]
fn exact_cover_times_use_workspace_zone_and_reject_dst_gaps_and_folds() {
let mut doc = WorkspaceDocument::new("Test".into());
doc.timezone = "Europe/Paris".into();
let schedule = Uuid::new_v4();
let day = "2026-09-24".parse().unwrap();
let start = replacement_boundary(&doc, schedule, day, Some(&json!("13:15"))).unwrap();
let end = replacement_boundary(&doc, schedule, day, Some(&json!("15:45"))).unwrap();
assert_eq!(start.to_rfc3339(), "2026-09-24T11:15:00+00:00");
assert_eq!(end - start, Duration::minutes(150));
for date in ["2026-03-29", "2026-10-25"] {
assert!(
replacement_boundary(&doc, schedule, date.parse().unwrap(), Some(&json!("02:30")))
.is_err()
);
}
for bad in [
json!(""),
json!("24:01"),
json!("13:00:01"),
json!(null),
json!(7),
] {
assert!(replacement_boundary(&doc, schedule, day, Some(&bad)).is_err());
}
}
#[test]
fn one_calendar_day_across_dst_uses_wall_clock_endpoints() {
let mut doc = WorkspaceDocument::new("Test".into());
doc.timezone = "Europe/Paris".into();
let schedule = Uuid::new_v4();
let clock = json!("11:00");
for (from, to, hours) in [
("2026-03-28", "2026-03-29", 23),
("2026-10-24", "2026-10-25", 25),
] {
let start =
replacement_boundary(&doc, schedule, from.parse().unwrap(), Some(&clock)).unwrap();
let end =
replacement_boundary(&doc, schedule, to.parse().unwrap(), Some(&clock)).unwrap();
assert_eq!((end - start).num_hours(), hours);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
use crate::{
engine,
error::{Error, Result},
models::SyncJobRow,
providers,
store::{Store, uuid},
};
use chrono::{Duration, Utc};
use cot::db::query;
use serde_json::json;
use uuid::Uuid;
pub async fn run(db: Store) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
if let Err(error) = tick(&db).await {
tracing::error!(%error,"publication worker iteration failed");
}
}
}
pub async fn tick(db: &Store) -> Result<()> {
let now = Utc::now();
let mut jobs = query!(SyncJobRow,$next_attempt<=now).all(&db.db).await?;
jobs.retain(|job| job.lease_until.is_none_or(|until| until <= now));
jobs.sort_by_key(|job| job.next_attempt);
for job in jobs.into_iter().take(20) {
let token = Uuid::new_v4().to_string();
// Publication is bounded to four minutes; a five-minute lease survives a lost worker.
let claimed=db.db.raw_with("UPDATE meguri__sync_jobs SET lease_token=$1,lease_until=now()+interval '5 minutes',state='publishing' WHERE workspace_id=$2 AND (lease_until IS NULL OR lease_until<now()) AND next_attempt<=now()",&[&token,&job.workspace_id]).await?;
if claimed.rows_affected().0 == 0 {
continue;
}
let workspace = uuid(&job.workspace_id)?;
let result = tokio::time::timeout(
std::time::Duration::from_secs(240),
publish_one(db, workspace),
)
.await
.unwrap_or_else(|_| {
Err(Error::Provider(
crate::i18n::text("err_publication_time_limit_reached_completed_steps_a_e404c5")
.into(),
))
});
match result {
Ok((revision, end)) => {
db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,state=CASE WHEN requested_revision=$1 THEN 'synced' ELSE 'pending' END,attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=CASE WHEN requested_revision=$1 THEN now()+interval '15 minutes' ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$3 AND lease_token=$4 RETURNING *) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT workspace_id,'publication.succeeded',$5,now() FROM published",&[&revision,&end,&job.workspace_id,&token,&json!({"revision":revision,"window_end":end}).to_string()]).await?;
}
Err(error) => {
let conflict = matches!(error, Error::Conflict(_));
let state = if conflict { "conflict" } else { "failed" };
let delay = if conflict {
3600
} else {
(5_i64 * 2_i64.pow(job.attempts.clamp(0, 9) as u32)).min(1800)
};
let retry = Utc::now() + Duration::seconds(delay);
db.db.raw_with("UPDATE meguri__sync_jobs SET state=CASE WHEN requested_revision=$6 THEN $1 ELSE 'pending' END,last_error=CASE WHEN requested_revision=$6 THEN $2 ELSE NULL END,attempts=CASE WHEN requested_revision=$6 THEN attempts+1 ELSE 0 END,next_attempt=CASE WHEN requested_revision=$6 THEN $3 ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token,&job.requested_revision]).await?;
tracing::warn!(workspace=%job.workspace_id,%error,"publication did not complete");
}
}
}
Ok(())
}
pub async fn publish_one(db: &Store, workspace: Uuid) -> Result<(i64, chrono::DateTime<Utc>)> {
let record = db.workspace(workspace).await?;
let now = Utc::now();
let (from, to) = engine::publication_window(&record.document, now)?;
let timeline = db.historical_timeline(&record, from, to).await?;
let plan = providers::compile(&record, timeline, now)?;
providers::publish(db, &record, &plan, record.document.overwrite_drift).await?;
Ok((record.revision, to))
}