commit
a47c4a0398
96 files changed
+25721
No files matched your search
+714
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
+373
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
@@ -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) = ¤t {
|
||||
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(¤t, &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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, ¤t.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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user