1 parent
e37d583179
commit
5260c2828b
65 files changed
+8469
-380
No files matched your search
@@ -242,6 +242,7 @@ pub async fn seed(db: &Store) -> Result<()> {
|
||||
db,
|
||||
username.into(),
|
||||
display.into(),
|
||||
format!("{username}@example.com"),
|
||||
"meguri-demo-only".into(),
|
||||
)
|
||||
.await?
|
||||
@@ -353,6 +354,7 @@ pub async fn seed(db: &Store) -> Result<()> {
|
||||
_ => service_tiers(monday, alice, users[2].id),
|
||||
};
|
||||
let mut draft = Draft {
|
||||
shared_revision: None,
|
||||
revision: workspace.revision,
|
||||
variables,
|
||||
rules,
|
||||
|
||||
+2
-2
@@ -191,7 +191,7 @@ pub async fn reset(db: &Database) -> Result<()> {
|
||||
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;
|
||||
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__site_access','meguri__site_audit','meguri__workspace_drafts','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?;
|
||||
@@ -239,7 +239,7 @@ 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)
|
||||
request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision)
|
||||
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests;
|
||||
"#;
|
||||
const PURGE: &str = r#"
|
||||
|
||||
+21
-4
@@ -37,14 +37,21 @@ impl Permission {
|
||||
pub struct User {
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
pub id: Uuid,
|
||||
/// Canonical account-owned person; email is required by account persistence.
|
||||
#[serde(flatten)]
|
||||
pub person: Person,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub country: String,
|
||||
/// None follows the browser timezone; this never changes scheduling rules.
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for User {
|
||||
type Target = Person;
|
||||
fn deref(&self) -> &Person {
|
||||
&self.person
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Member {
|
||||
pub user: User,
|
||||
@@ -428,7 +435,8 @@ impl WorkspaceDocument {
|
||||
} = &self.integration
|
||||
{
|
||||
ensure(
|
||||
Uuid::parse_str(cloud_id).is_ok() && Uuid::parse_str(team_id).is_ok(),
|
||||
Uuid::parse_str(cloud_id).is_ok()
|
||||
&& Uuid::parse_str(team_id.strip_prefix("og-").unwrap_or(team_id)).is_ok(),
|
||||
crate::i18n::text("err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f"),
|
||||
)?;
|
||||
ensure(
|
||||
@@ -704,3 +712,12 @@ pub struct Timeline {
|
||||
pub to: DateTime<Utc>,
|
||||
pub assignments: Vec<Assignment>,
|
||||
}
|
||||
|
||||
/// A compact representation of a verified calendar-day cycle, independent of providers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecurringRotation {
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
pub shift_days: u16,
|
||||
pub participants: Vec<Uuid>,
|
||||
}
|
||||
+113
@@ -170,6 +170,63 @@ pub fn timeline(
|
||||
})
|
||||
}
|
||||
|
||||
/// Compress only coverage identical to the base cycle. History or approved
|
||||
/// replacements that change any effective span must retain their exact timeline.
|
||||
pub fn recurring_rotation(
|
||||
doc: &WorkspaceDocument,
|
||||
schedule: &Schedule,
|
||||
periods: &[Assignment],
|
||||
) -> Result<Option<RecurringRotation>> {
|
||||
let ScheduleKind::Rotation {
|
||||
participants,
|
||||
anchor,
|
||||
shift_days,
|
||||
} = &schedule.kind
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (Some(first), Some(last)) = (periods.first(), periods.last()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut base = doc.clone();
|
||||
base.schedules = vec![schedule.clone()];
|
||||
base.requests.clear();
|
||||
let expected = timeline(&base, first.start, last.end)?;
|
||||
// Compare coverage at every interval boundary; reason labels and redundant
|
||||
// adjacent spans do not change who is on call.
|
||||
if periods.windows(2).any(|p| p[0].end != p[1].start)
|
||||
|| periods.iter().any(|p| p.start >= p.end || p.unavailable)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
for p in &expected.assignments {
|
||||
if periods
|
||||
.iter()
|
||||
.filter(|a| a.start < p.end && p.start < a.end)
|
||||
.any(|a| a.user_id != p.user_id || a.unavailable != p.unavailable)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let day = schedule_duty_day(doc, schedule.id, first.start)?;
|
||||
let shift = (day - *anchor)
|
||||
.num_days()
|
||||
.div_euclid(i64::from(*shift_days));
|
||||
let start = local_time(
|
||||
doc,
|
||||
*anchor + Duration::days(shift * i64::from(*shift_days)),
|
||||
schedule.handover,
|
||||
)?;
|
||||
let mut ordered = participants.clone();
|
||||
ordered.rotate_left(shift.rem_euclid(participants.len() as i64) as usize);
|
||||
Ok(Some(RecurringRotation {
|
||||
start,
|
||||
end: last.end,
|
||||
shift_days: *shift_days,
|
||||
participants: ordered,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
@@ -833,4 +890,60 @@ mod tests {
|
||||
assert!(restored.effective_legs().is_empty());
|
||||
assert_eq!(restored.legs.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn compact_cycle_preserves_phase_calendar_days_and_rejects_changed_coverage() {
|
||||
let (mut doc, people, id, _) = fixture();
|
||||
doc.timezone = "Europe/Bucharest".into();
|
||||
doc.schedules.truncate(1);
|
||||
for (start, end, expected_start, first_person) in [
|
||||
(
|
||||
"2026-03-10T12:00:00Z",
|
||||
"2026-04-20T08:00:00Z",
|
||||
"2026-03-09T09:00:00Z",
|
||||
people[2],
|
||||
),
|
||||
(
|
||||
"2026-10-20T12:00:00Z",
|
||||
"2026-11-23T09:00:00Z",
|
||||
"2026-10-19T08:00:00Z",
|
||||
people[1],
|
||||
),
|
||||
] {
|
||||
let from = start.parse().unwrap();
|
||||
let to = end.parse().unwrap();
|
||||
let periods = timeline(&doc, from, to).unwrap().assignments;
|
||||
let compact = recurring_rotation(&doc, &doc.schedules[0], &periods)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
compact.start,
|
||||
expected_start.parse::<DateTime<Utc>>().unwrap()
|
||||
);
|
||||
assert_eq!(compact.participants[0], first_person);
|
||||
let zone: chrono_tz::Tz = doc.timezone.parse().unwrap();
|
||||
for p in &periods {
|
||||
let days = (p.start.with_timezone(&zone).date_naive()
|
||||
- compact.start.with_timezone(&zone).date_naive())
|
||||
.num_days();
|
||||
assert_eq!(p.user_id, compact.participants[(days / 7) as usize % 3]);
|
||||
}
|
||||
assert!(
|
||||
periods
|
||||
.windows(2)
|
||||
.any(|p| (p[0].end - p[0].start).num_hours() != 168)
|
||||
);
|
||||
let mut changed = periods.clone();
|
||||
changed[1].user_id = people
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|p| *p != changed[1].user_id)
|
||||
.unwrap();
|
||||
assert!(
|
||||
recurring_rotation(&doc, &doc.schedules[0], &changed)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(periods[0].schedule_id, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-6
@@ -84,6 +84,10 @@ pub struct Step {
|
||||
#[serde(default)]
|
||||
pub user: bool,
|
||||
#[serde(default)]
|
||||
pub team_all: bool,
|
||||
#[serde(default)]
|
||||
pub team_random: bool,
|
||||
#[serde(default)]
|
||||
pub condition: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -97,6 +101,8 @@ pub struct Condition {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(from = "BlockWire")]
|
||||
pub struct Block {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub portable_key: Option<String>,
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
pub fields: BTreeMap<String, Binding>,
|
||||
@@ -107,6 +113,8 @@ pub struct Block {
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct BlockWire {
|
||||
#[serde(default)]
|
||||
pub portable_key: Option<String>,
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
pub fields: BTreeMap<String, Binding>,
|
||||
@@ -133,6 +141,7 @@ impl From<BlockWire> for Block {
|
||||
);
|
||||
}
|
||||
Self {
|
||||
portable_key: value.portable_key,
|
||||
id: value.id,
|
||||
kind: value.kind,
|
||||
fields: value.fields,
|
||||
@@ -506,6 +515,38 @@ impl Resolver<'_> {
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
fn provenance(&self, b: &Binding, iterations: &[Iteration], depth: usize) -> Value {
|
||||
if depth > 16 {
|
||||
return Value::Null;
|
||||
}
|
||||
let mut source = b.clone();
|
||||
source.path.clear();
|
||||
source.prefix.clear();
|
||||
source.suffix.clear();
|
||||
let snapshot = |binding: &Binding| match self.eval(binding, 0) {
|
||||
Ok(node) => json!({"value":node.json(),"node_id":node.id}),
|
||||
Err(error) => json!({"error":error.to_string()}),
|
||||
};
|
||||
let mut stages = vec![json!({"path":[],"result":snapshot(&source)})];
|
||||
for segment in &b.path {
|
||||
source.path.push(segment.clone());
|
||||
stages.push(json!({"path":source.path,"result":snapshot(&source)}));
|
||||
}
|
||||
let mut parents = vec![];
|
||||
if matches!(b.kind.as_str(), "item" | "key")
|
||||
&& let Some(iteration) = iterations.iter().find(|i| Some(i.id) == b.iteration)
|
||||
{
|
||||
parents.push(self.provenance(&iteration.source, iterations, depth + 1));
|
||||
}
|
||||
if let Some(key) = &b.key {
|
||||
parents.push(self.provenance(key, iterations, depth + 1));
|
||||
}
|
||||
for item in &b.items {
|
||||
parents.push(self.provenance(item, iterations, depth + 1));
|
||||
}
|
||||
json!({"binding":b,"stages":stages,"result":snapshot(b),"parents":parents})
|
||||
}
|
||||
|
||||
fn value(&self, b: &Binding) -> Result<Value> {
|
||||
Ok(self.eval(b, 0)?.json())
|
||||
}
|
||||
@@ -729,7 +770,14 @@ fn render(
|
||||
&[("p0", (i + 1).to_string())],
|
||||
))
|
||||
})?;
|
||||
let target = resolver.value(&step.recipient)?;
|
||||
ensure(
|
||||
[step.user, step.team_all, step.team_random]
|
||||
.into_iter()
|
||||
.filter(|v| *v)
|
||||
.count()
|
||||
<= 1,
|
||||
crate::i18n::text("generation_recipient_conflict"),
|
||||
)?;
|
||||
steps.push(EscalationStep {
|
||||
delay_minutes: delay as u16,
|
||||
condition: if step.condition == "if-not-closed" {
|
||||
@@ -737,13 +785,17 @@ fn render(
|
||||
} else {
|
||||
EscalationCondition::IfNotAcked
|
||||
},
|
||||
recipient: if step.user {
|
||||
recipient: if step.team_all {
|
||||
Recipient::TeamAll
|
||||
} else if step.team_random {
|
||||
Recipient::TeamRandom
|
||||
} else if step.user {
|
||||
Recipient::User {
|
||||
id: recipient(&target, workspace, doc, members)?,
|
||||
id: recipient(&resolver.value(&step.recipient)?, workspace, doc, members)?,
|
||||
}
|
||||
} else {
|
||||
Recipient::Schedule {
|
||||
id: id_value(target)?,
|
||||
id: id_value(resolver.value(&step.recipient)?)?,
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1013,7 +1065,20 @@ pub fn expand(
|
||||
} else {
|
||||
format!("{path} / {}", context.labels.join(" / "))
|
||||
};
|
||||
let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::<BTreeMap<_,_>>(),"status":"included","resources":[],"blocks":[]});
|
||||
let bindings: Vec<_> = rule
|
||||
.iterations
|
||||
.iter()
|
||||
.map(|i| &i.source)
|
||||
.chain(rule.when.iter().flat_map(|p| [&p.left, &p.right]))
|
||||
.chain(rule.blocks.iter().flat_map(|b| {
|
||||
b.fields
|
||||
.values()
|
||||
.chain(b.steps.iter().flat_map(|s| [&s.delay, &s.recipient]))
|
||||
.chain(b.conditions.iter().map(|c| &c.value))
|
||||
}))
|
||||
.map(|b| resolver.provenance(b, &rule.iterations, 0))
|
||||
.collect();
|
||||
let mut row_trace = json!({"bindings":bindings,"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::<BTreeMap<_,_>>(),"status":"included","resources":[],"blocks":[]});
|
||||
let managed_start = managed.len();
|
||||
let row_issue_start = issues.len();
|
||||
if let Some(predicate) = &rule.when {
|
||||
@@ -1050,7 +1115,7 @@ pub fn expand(
|
||||
row_trace["blocks"].as_array_mut().unwrap().push(json!({
|
||||
"block":block.id,
|
||||
"fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::<BTreeMap<_,_>>(),
|
||||
"steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::<Vec<_>>(),
|
||||
"steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":if s.team_all {json!(crate::i18n::text("everyone_on_the_team_235b55"))} else if s.team_random {json!(crate::i18n::text("a_random_team_member_8f35f3"))} else {resolved(&s.recipient)},"condition":s.condition})).collect::<Vec<_>>(),
|
||||
"conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::<Vec<_>>()
|
||||
}));
|
||||
if manual_ids.contains(&id) {
|
||||
|
||||
+112
-10
@@ -8,12 +8,14 @@ use crate::{
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Draft {
|
||||
pub revision: i64,
|
||||
#[serde(default)]
|
||||
pub shared_revision: Option<i64>,
|
||||
pub variables: Vec<Variable>,
|
||||
pub rules: Vec<Rule>,
|
||||
#[serde(default)]
|
||||
@@ -32,6 +34,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res
|
||||
.prepare(workspace, draft.revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let directory = db.directory(workspace).await?;
|
||||
crate::store::account_people(&mut record.document, &directory)?;
|
||||
let members = directory.iter().map(|u| u.id).collect();
|
||||
let now = Utc::now();
|
||||
let mut expanded = generation::expand(
|
||||
@@ -61,13 +64,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res
|
||||
.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()),
|
||||
});
|
||||
expanded.document.people.push(user.person.clone());
|
||||
}
|
||||
}
|
||||
if expanded.issues.is_empty() {
|
||||
@@ -122,7 +119,11 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res
|
||||
draft.route_position
|
||||
]))
|
||||
.to_string();
|
||||
let report = json!({"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues});
|
||||
let publication_changes = crate::store::publication_changes(
|
||||
&expanded.document,
|
||||
db.published_document(workspace).await?.as_ref(),
|
||||
)?;
|
||||
let report = json!({"shared_revision":draft.shared_revision,"publication_changes":publication_changes,"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues});
|
||||
record.document = expanded.document;
|
||||
Ok(Prepared {
|
||||
report,
|
||||
@@ -161,3 +162,104 @@ pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Re
|
||||
.await?;
|
||||
Ok(json!({"ok":true}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Transfer {
|
||||
pub revision: i64,
|
||||
pub shared_revision: i64,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub review_id: Option<String>,
|
||||
}
|
||||
async fn transfer_base(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
request: &Transfer,
|
||||
) -> Result<(WorkspaceRecord, Draft)> {
|
||||
let record = db
|
||||
.prepare(
|
||||
workspace,
|
||||
request.revision,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
)
|
||||
.await?;
|
||||
let shared = db.shared_draft(workspace).await?;
|
||||
ensure(
|
||||
shared["revision"].as_i64() == Some(request.shared_revision),
|
||||
crate::i18n::text("err_shared_draft_stale"),
|
||||
)?;
|
||||
let mut draft: Draft = if shared["payload"].is_null() {
|
||||
Draft {
|
||||
revision: record.revision,
|
||||
shared_revision: Some(request.shared_revision),
|
||||
variables: record.document.generation.variables.clone(),
|
||||
rules: record.document.generation.rules.clone(),
|
||||
route_position: record.document.generation.route_position,
|
||||
review_id: None,
|
||||
}
|
||||
} else {
|
||||
serde_json::from_value(shared["payload"].clone())?
|
||||
};
|
||||
draft.revision = record.revision;
|
||||
draft.shared_revision = Some(request.shared_revision);
|
||||
Ok((record, draft))
|
||||
}
|
||||
pub async fn export_config(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
request: Transfer,
|
||||
) -> Result<Value> {
|
||||
let (record, draft) = transfer_base(db, actor, workspace, &request).await?;
|
||||
Ok(
|
||||
json!({"text":crate::portable::format_config(&crate::portable::export(&record.document,&draft)?)}),
|
||||
)
|
||||
}
|
||||
pub async fn import_config(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
request: Transfer,
|
||||
apply: bool,
|
||||
) -> Result<Value> {
|
||||
let (record, base) = transfer_base(db, actor, workspace, &request).await?;
|
||||
let (draft, inputs) =
|
||||
crate::portable::import(workspace, &record.document, &base, &request.text)?;
|
||||
let prepared = prepare(db, actor, workspace, &draft).await?;
|
||||
let review = generation::stable_id(&json!([
|
||||
prepared.review,
|
||||
request.shared_revision,
|
||||
request.text
|
||||
]))
|
||||
.to_string();
|
||||
if apply {
|
||||
ensure(
|
||||
prepared.issues.is_empty(),
|
||||
crate::i18n::text("transfer_fix_errors"),
|
||||
)?;
|
||||
ensure(
|
||||
request.review_id.as_deref() == Some(review.as_str()),
|
||||
crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"),
|
||||
)?;
|
||||
return db
|
||||
.save_shared_draft(
|
||||
workspace,
|
||||
actor,
|
||||
request.shared_revision,
|
||||
serde_json::to_value(draft)?,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let contacts: Vec<_> = prepared
|
||||
.record
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.filter(|p| record.document.person(p.id).is_none() && p.user_id.is_none())
|
||||
.collect();
|
||||
Ok(json!({"review_id":review,"inputs":inputs,"contacts":contacts,"preview":prepared.report}))
|
||||
}
|
||||
@@ -12,8 +12,12 @@ pub mod holidays;
|
||||
pub mod i18n;
|
||||
pub mod migrations;
|
||||
pub mod models;
|
||||
pub mod portable;
|
||||
mod portable_v2;
|
||||
pub mod providers;
|
||||
pub mod service;
|
||||
pub mod store;
|
||||
pub mod web;
|
||||
pub mod worker;
|
||||
|
||||
pub mod site;
|
||||
@@ -72,6 +72,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if enabled("MEGURI_SEED_DEMO") {
|
||||
service::seed_demo(&db).await?;
|
||||
}
|
||||
if let Ok(username) = std::env::var("MEGURI_BOOTSTRAP_ADMIN")
|
||||
&& !username.trim().is_empty()
|
||||
{
|
||||
meguri::site::bootstrap(&db, &username).await?;
|
||||
}
|
||||
let demo_maintenance = demo_runtime::notice()
|
||||
.map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone())));
|
||||
if role == "worker" {
|
||||
|
||||
@@ -6,6 +6,10 @@ pub mod m_0005_variables;
|
||||
pub mod m_0006_demo_lifecycle;
|
||||
pub mod m_0007_language;
|
||||
pub mod m_0008_workspaces;
|
||||
pub mod m_0009_manual_publication;
|
||||
pub mod m_0010_site_admin;
|
||||
pub mod m_0010b_contact_accounts;
|
||||
pub mod m_0011_account_people;
|
||||
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_constraints::Migration,
|
||||
@@ -15,4 +19,8 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0006_demo_lifecycle::Migration,
|
||||
&m_0007_language::Migration,
|
||||
&m_0008_workspaces::Migration,
|
||||
&m_0009_manual_publication::Migration,
|
||||
&m_0010_site_admin::Migration,
|
||||
&m_0010b_contact_accounts::Migration,
|
||||
&m_0011_account_people::Migration,
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
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_0009_manual_publication";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0008_workspaces",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw(r#"
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE meguri__sync_jobs ADD COLUMN authorized_revision bigint, ADD COLUMN authorized_document text, ADD COLUMN published_document text;
|
||||
UPDATE meguri__sync_jobs j SET published_document=h.document FROM meguri__workspace_history h WHERE h.workspace_id=j.workspace_id AND h.revision=j.published_revision;
|
||||
UPDATE meguri__sync_jobs SET state=CASE WHEN published_revision=requested_revision THEN 'synced' ELSE 'draft' END,lease_token=NULL,lease_until=NULL;
|
||||
CREATE TABLE meguri__workspace_drafts(workspace_id text PRIMARY KEY REFERENCES meguri__workspaces(id) ON DELETE CASCADE, revision bigint NOT NULL, payload text NOT NULL);
|
||||
END $$;
|
||||
"#).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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_0010_site_admin";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0009_manual_publication",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw(r#"DO $$ BEGIN
|
||||
CREATE TABLE meguri__site_access(user_id text PRIMARY KEY REFERENCES meguri__users(id) ON DELETE CASCADE, administrator boolean NOT NULL DEFAULT false, disabled boolean NOT NULL DEFAULT false, revision bigint NOT NULL DEFAULT 1);
|
||||
CREATE TABLE meguri__site_settings(section text PRIMARY KEY, revision bigint NOT NULL, payload text NOT NULL);
|
||||
INSERT INTO meguri__site_settings VALUES('authentication',1,'{"oidc_enabled":false,"password_enabled":true,"issuer":"","client_id":"","client_secret_env":"MEGURI_OIDC_CLIENT_SECRET","button_label":"Single sign-on","allow_new_users":false}');
|
||||
INSERT INTO meguri__site_settings VALUES('control',1,'{}');
|
||||
CREATE TABLE meguri__site_audit(id bigserial PRIMARY KEY, actor_id text, action text NOT NULL, details text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
END $$;"#).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Repair pre-account-email identity duplication without rewriting applied migrations.
|
||||
use crate::{
|
||||
domain::*,
|
||||
generation::{Binding, Node, Rule},
|
||||
models::{WorkspaceDraftRow, WorkspaceMemberRow, WorkspaceRow},
|
||||
};
|
||||
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
|
||||
use cot::db::{Auto, Database, DatabaseError, Model, model, query};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[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_0010b_contact_accounts";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0010_site_admin",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(table_name = "cot__migrations", model_type = "internal")]
|
||||
struct Applied {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i32>,
|
||||
app: String,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(table_name = "meguri__users", model_type = "internal")]
|
||||
struct Account {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
username: String,
|
||||
display_name: String,
|
||||
country: String,
|
||||
}
|
||||
fn fail(error: impl std::fmt::Display) -> DatabaseError {
|
||||
DatabaseError::ValueDecode(Box::new(std::io::Error::other(error.to_string())))
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
// Already migrated installations retain their existing identity policy.
|
||||
if query!(Applied,$app=="meguri" && $name=="m_0011_account_people")
|
||||
.exists(context.db)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
repair(
|
||||
context.db,
|
||||
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
type Links = BTreeMap<Uuid, Uuid>;
|
||||
fn id(value: &mut Uuid, links: &Links) {
|
||||
if let Some(next) = links.get(value) {
|
||||
*value = *next;
|
||||
}
|
||||
}
|
||||
fn values(value: &mut Value, links: &Links) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Ok(old) = Uuid::parse_str(s)
|
||||
&& let Some(next) = links.get(&old)
|
||||
{
|
||||
*s = next.to_string();
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for v in a {
|
||||
values(v, links);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
for v in o.values_mut() {
|
||||
values(v, links);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn node(value: &mut Node, links: &Links) {
|
||||
if value.kind == "person" {
|
||||
values(&mut value.value, links);
|
||||
}
|
||||
for child in &mut value.entries {
|
||||
node(child, links);
|
||||
}
|
||||
}
|
||||
fn binding(b: &mut Binding, links: &Links) {
|
||||
values(&mut b.value, links);
|
||||
for item in &mut b.items {
|
||||
binding(item, links);
|
||||
}
|
||||
if let Some(key) = &mut b.key {
|
||||
binding(key, links);
|
||||
}
|
||||
}
|
||||
fn rules(rules: &mut [Rule], links: &Links) {
|
||||
for rule in rules {
|
||||
for iteration in &mut rule.iterations {
|
||||
binding(&mut iteration.source, links);
|
||||
}
|
||||
if let Some(p) = &mut rule.when {
|
||||
binding(&mut p.left, links);
|
||||
binding(&mut p.right, links);
|
||||
}
|
||||
for block in &mut rule.blocks {
|
||||
for field in ["participants", "primary", "secondary"] {
|
||||
if let Some(b) = block.fields.get_mut(field) {
|
||||
binding(b, links);
|
||||
}
|
||||
}
|
||||
for step in &mut block.steps {
|
||||
if step.user && !step.team_all && !step.team_random {
|
||||
binding(&mut step.recipient, links);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn leg(leg: &mut ReplacementLeg, links: &Links) -> cot::db::Result<()> {
|
||||
id(&mut leg.from_user, links);
|
||||
id(&mut leg.to_user, links);
|
||||
if leg.from_user == leg.to_user {
|
||||
return Err(fail(
|
||||
"Contact association collapses a replacement to the same person; review it before migration",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remap(doc: &mut WorkspaceDocument, links: &Links) -> cot::db::Result<()> {
|
||||
for schedule in &mut doc.schedules {
|
||||
let (ScheduleKind::Rotation { participants, .. }
|
||||
| ScheduleKind::Priority { participants, .. }) = &mut schedule.kind;
|
||||
for person in participants {
|
||||
id(person, links);
|
||||
}
|
||||
}
|
||||
for absence in &mut doc.absences {
|
||||
id(&mut absence.user_id, links);
|
||||
}
|
||||
for policy in &mut doc.escalations {
|
||||
for step in &mut policy.steps {
|
||||
if let Recipient::User { id: person } = &mut step.recipient {
|
||||
id(person, links);
|
||||
}
|
||||
}
|
||||
}
|
||||
for request in &mut doc.requests {
|
||||
for l in &mut request.legs {
|
||||
leg(l, links)?;
|
||||
}
|
||||
if let Some(legs) = &mut request.effective_legs {
|
||||
for l in legs {
|
||||
leg(l, links)?;
|
||||
}
|
||||
}
|
||||
request.required_approvers = request
|
||||
.required_approvers
|
||||
.iter()
|
||||
.map(|v| *links.get(v).unwrap_or(v))
|
||||
.collect();
|
||||
// Existing decisions remain evidence of the original actors. Newly linked
|
||||
// account participants must approve still-pending requests themselves.
|
||||
if request.status == RequestStatus::Pending {
|
||||
for l in &request.legs {
|
||||
for person in [l.from_user, l.to_user] {
|
||||
if links.values().any(|v| *v == person) && person != request.requested_by {
|
||||
request.required_approvers.insert(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Integration::Jsm { users, .. } = &mut doc.integration {
|
||||
for (old, new) in links {
|
||||
if let Some(external) = users.remove(old) {
|
||||
if users.get(new).is_some_and(|v| v != &external) {
|
||||
return Err(fail(
|
||||
"Conflicting explicit JSM identities for contact and account",
|
||||
));
|
||||
}
|
||||
users.insert(*new, external);
|
||||
}
|
||||
}
|
||||
}
|
||||
for variable in &mut doc.generation.variables {
|
||||
node(&mut variable.node, links);
|
||||
}
|
||||
rules(&mut doc.generation.rules, links);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate all links before atomically updating current documents and shared drafts.
|
||||
pub async fn repair(db: &Database, mapping: &str) -> cot::db::Result<()> {
|
||||
let mapping: BTreeMap<String, String> = serde_json::from_str(mapping).map_err(fail)?;
|
||||
let accounts = Account::objects().all(db).await?;
|
||||
for (name, email) in &mapping {
|
||||
if !accounts.iter().any(|u| &u.username == name) {
|
||||
return Err(fail(format!(
|
||||
"Unknown username in MEGURI_ACCOUNT_EMAILS: {name}"
|
||||
)));
|
||||
}
|
||||
crate::service::normalize_email(email).map_err(fail)?;
|
||||
}
|
||||
let workspaces = WorkspaceRow::objects().all(db).await?;
|
||||
let memberships = WorkspaceMemberRow::objects().all(db).await?;
|
||||
let drafts = WorkspaceDraftRow::objects().all(db).await?;
|
||||
let mut emails = BTreeMap::new();
|
||||
// Fail before any writes if the subsequent email migration would need more input.
|
||||
for account in &accounts {
|
||||
let email = if let Some(e) = mapping.get(&account.username) {
|
||||
crate::service::normalize_email(e).map_err(fail)?
|
||||
} else {
|
||||
let mut found = BTreeSet::new();
|
||||
for row in &workspaces {
|
||||
let doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
|
||||
for p in doc.people {
|
||||
if p.user_id.map(|v| v.to_string()).as_deref() == Some(&account.id)
|
||||
&& let Some(e) = p.email
|
||||
{
|
||||
found.insert(crate::service::normalize_email(&e).map_err(fail)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.len() != 1 {
|
||||
return Err(fail(format!(
|
||||
"Set MEGURI_ACCOUNT_EMAILS for missing or ambiguous email: {}",
|
||||
account.username
|
||||
)));
|
||||
}
|
||||
found.into_iter().next().unwrap()
|
||||
};
|
||||
if emails.values().any(|e| e == &email) {
|
||||
return Err(fail("Duplicate account email in MEGURI_ACCOUNT_EMAILS"));
|
||||
}
|
||||
emails.insert(account.id.clone(), email);
|
||||
}
|
||||
let mut changes = vec![];
|
||||
for row in workspaces {
|
||||
let mut doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
|
||||
let mut links = Links::new();
|
||||
let members: BTreeSet<Uuid> = memberships
|
||||
.iter()
|
||||
.filter(|m| m.workspace_id == row.id)
|
||||
.map(|m| Uuid::parse_str(&m.user_id).map_err(fail))
|
||||
.collect::<cot::db::Result<_>>()?;
|
||||
for account in &accounts {
|
||||
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
|
||||
if !members.contains(&account_id) {
|
||||
continue;
|
||||
}
|
||||
let email = &emails[&account.id];
|
||||
for person in &doc.people {
|
||||
if person.id != account_id
|
||||
&& person
|
||||
.email
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.trim().eq_ignore_ascii_case(email))
|
||||
{
|
||||
if person.user_id.is_some() || !mapping.contains_key(&account.username) {
|
||||
return Err(fail(format!(
|
||||
"Explicit MEGURI_ACCOUNT_EMAILS required to associate contact with {} in workspace {}",
|
||||
account.username, row.id
|
||||
)));
|
||||
}
|
||||
links.insert(person.id, account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if links.is_empty() {
|
||||
continue;
|
||||
}
|
||||
remap(&mut doc, &links)?;
|
||||
doc.people.retain(|p| !links.contains_key(&p.id));
|
||||
for account in &accounts {
|
||||
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
|
||||
if links.values().any(|v| *v == account_id) {
|
||||
doc.people.retain(|p| p.id != account_id);
|
||||
doc.people.push(Person {
|
||||
id: account_id,
|
||||
user_id: Some(account_id),
|
||||
display_name: account.display_name.clone(),
|
||||
email: Some(emails[&account.id].clone()),
|
||||
country: Some(account.country.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
doc.validate(&members).map_err(fail)?;
|
||||
let draft = if let Some(d) = drafts.iter().find(|d| d.workspace_id == row.id) {
|
||||
let mut payload: crate::generation_service::Draft =
|
||||
serde_json::from_str(&d.payload).map_err(fail)?;
|
||||
for v in &mut payload.variables {
|
||||
node(&mut v.node, &links);
|
||||
}
|
||||
rules(&mut payload.rules, &links);
|
||||
payload.review_id = None;
|
||||
Some(json!({"revision":d.revision,"payload":payload}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
changes.push(
|
||||
json!({"id":row.id,"revision":row.revision,"document":doc,"links":links,"draft":draft}),
|
||||
);
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let literal = json!(changes)
|
||||
.to_string()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('\'', "''");
|
||||
let mut delimiter = "$repair$".to_owned();
|
||||
while literal.contains(&delimiter) {
|
||||
delimiter.insert(1, '_');
|
||||
}
|
||||
db.raw(&format!(r#"DO {delimiter}
|
||||
DECLARE changes jsonb := E'{literal}'::jsonb; item jsonb;
|
||||
BEGIN
|
||||
LOCK TABLE meguri__workspaces,meguri__workspace_drafts,meguri__workspace_members IN SHARE ROW EXCLUSIVE MODE;
|
||||
FOR item IN SELECT value FROM jsonb_array_elements(changes) LOOP
|
||||
IF NOT EXISTS(SELECT 1 FROM meguri__workspaces WHERE id=item->>'id' AND revision=(item->>'revision')::bigint) THEN RAISE EXCEPTION 'Workspace changed during contact migration'; END IF;
|
||||
IF item->'draft'='null'::jsonb AND EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id') THEN RAISE EXCEPTION 'Shared draft created during contact migration'; END IF;
|
||||
IF item->'draft'<>'null'::jsonb AND NOT EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id' AND revision=(item->'draft'->>'revision')::bigint) THEN RAISE EXCEPTION 'Shared draft changed during contact migration'; END IF;
|
||||
UPDATE meguri__workspaces SET document=(item->'document')::text,revision=revision+1 WHERE id=item->>'id';
|
||||
IF item->'draft'<>'null'::jsonb THEN
|
||||
UPDATE meguri__workspace_drafts SET payload=(item->'draft'->'payload')::text,revision=revision+1 WHERE workspace_id=item->>'id';
|
||||
END IF;
|
||||
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
|
||||
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM meguri__workspaces WHERE id=item->>'id';
|
||||
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) VALUES(item->>'id','account.contacts_linked',(item->'links')::text,clock_timestamp());
|
||||
UPDATE meguri__sync_jobs SET requested_revision=(item->>'revision')::bigint+1 WHERE workspace_id=item->>'id';
|
||||
END LOOP;
|
||||
END {delimiter};"#)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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_0011_account_people";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0010_site_admin",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
migrate(
|
||||
context.db,
|
||||
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A single PostgreSQL statement ensures validation failures leave the old schema intact.
|
||||
pub async fn migrate(db: &cot::db::Database, mapping: &str) -> cot::db::Result<()> {
|
||||
// Explicit E-string escaping, never shell interpolation or unquoted JSON.
|
||||
let literal = mapping.replace('\\', "\\\\").replace('\'', "''");
|
||||
let mut delimiter = "$migration$".to_owned();
|
||||
while literal.contains(&delimiter) {
|
||||
delimiter.insert(1, '_');
|
||||
}
|
||||
let sql = format!(
|
||||
r#"DO {delimiter}
|
||||
DECLARE mapping jsonb := E'{literal}'::jsonb; missing text;
|
||||
BEGIN
|
||||
IF jsonb_typeof(mapping) <> 'object' OR EXISTS(SELECT 1 FROM jsonb_each(mapping) WHERE jsonb_typeof(value)<>'string') THEN
|
||||
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS must be a JSON object mapping existing usernames to email addresses';
|
||||
END IF;
|
||||
IF EXISTS(SELECT 1 FROM jsonb_object_keys(mapping) k WHERE NOT EXISTS(SELECT 1 FROM meguri__users u WHERE u.username=k)) THEN
|
||||
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS contains an unknown username';
|
||||
END IF;
|
||||
ALTER TABLE meguri__users ADD COLUMN email text;
|
||||
UPDATE meguri__users u SET email=lower(trim(COALESCE(mapping->>u.username,(
|
||||
SELECT CASE WHEN count(DISTINCT lower(trim(p->>'email')))=1 THEN min(lower(trim(p->>'email'))) END
|
||||
FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
|
||||
WHERE p->>'user_id'=u.id AND nullif(trim(p->>'email'),'') IS NOT NULL
|
||||
))));
|
||||
SELECT string_agg(username,', ' ORDER BY username) INTO missing FROM meguri__users
|
||||
WHERE email IS NULL OR length(email)>254 OR email !~ '^[^[:space:]@]+@[^[:space:]@]+$';
|
||||
IF missing IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Missing, ambiguous or invalid account email. Set MEGURI_ACCOUNT_EMAILS for: %', missing;
|
||||
END IF;
|
||||
IF EXISTS(SELECT email FROM meguri__users GROUP BY email HAVING count(*)>1) THEN
|
||||
RAISE EXCEPTION 'Duplicate account email. Resolve MEGURI_ACCOUNT_EMAILS before migration';
|
||||
END IF;
|
||||
ALTER TABLE meguri__users ALTER COLUMN email SET NOT NULL;
|
||||
ALTER TABLE meguri__users ADD CONSTRAINT account_email_valid CHECK(email=lower(trim(email)) AND length(email)<=254 AND email ~ '^[^[:space:]@]+@[^[:space:]@]+$');
|
||||
ALTER TABLE meguri__users ADD CONSTRAINT account_email_unique UNIQUE(email);
|
||||
-- Current documents acquire canonical member snapshots; historical and authorized
|
||||
-- publication snapshots retain the identity data that was actually used then.
|
||||
WITH snapshots AS (
|
||||
SELECT w.id, COALESCE((
|
||||
SELECT jsonb_agg(p) FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
|
||||
WHERE NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.workspace_id=w.id AND m.user_id=p->>'id')
|
||||
),'[]'::jsonb) || COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object('id',u.id,'user_id',u.id,'display_name',u.display_name,'email',u.email,'country',u.country))
|
||||
FROM meguri__workspace_members m JOIN meguri__users u ON u.id=m.user_id WHERE m.workspace_id=w.id
|
||||
),'[]'::jsonb) AS people FROM meguri__workspaces w
|
||||
), changed AS (
|
||||
UPDATE meguri__workspaces w SET document=jsonb_set(w.document::jsonb,'{{people}}',s.people)::text, revision=w.revision+1
|
||||
FROM snapshots s WHERE w.id=s.id RETURNING w.*
|
||||
), history AS (
|
||||
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
|
||||
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM changed
|
||||
), jobs AS (
|
||||
UPDATE meguri__sync_jobs j SET requested_revision=c.revision FROM changed c WHERE j.workspace_id=c.id
|
||||
)
|
||||
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at)
|
||||
SELECT id,'account.people_migrated','{{}}',clock_timestamp() FROM changed;
|
||||
IF EXISTS(SELECT 1 FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(w.document::jsonb->'people') p GROUP BY w.id,p->>'email' HAVING count(*)>1) THEN
|
||||
RAISE EXCEPTION 'Account email conflicts with an existing planning contact. Resolve the contact identity before migration; no identities were merged';
|
||||
END IF;
|
||||
END {delimiter};"#
|
||||
);
|
||||
db.raw(&sql).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,6 +11,8 @@ pub struct UserRow {
|
||||
pub id: String,
|
||||
#[model(unique)]
|
||||
pub username: String,
|
||||
#[model(unique)]
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub country: String,
|
||||
pub timezone: Option<String>,
|
||||
@@ -87,6 +89,9 @@ pub struct AuditRow {
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "sync_jobs")]
|
||||
pub struct SyncJobRow {
|
||||
pub authorized_revision: Option<i64>,
|
||||
pub authorized_document: Option<String>,
|
||||
pub published_document: Option<String>,
|
||||
#[model(primary_key)]
|
||||
pub workspace_id: String,
|
||||
pub requested_revision: i64,
|
||||
@@ -112,3 +117,40 @@ pub struct ProviderObjectRow {
|
||||
pub content: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "workspace_drafts")]
|
||||
pub struct WorkspaceDraftRow {
|
||||
#[model(primary_key)]
|
||||
pub workspace_id: String,
|
||||
pub revision: i64,
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "site_access")]
|
||||
pub struct SiteAccessRow {
|
||||
#[model(primary_key)]
|
||||
pub user_id: String,
|
||||
pub administrator: bool,
|
||||
pub disabled: bool,
|
||||
pub revision: i64,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "site_settings")]
|
||||
pub struct SiteSettingsRow {
|
||||
#[model(primary_key)]
|
||||
pub section: String,
|
||||
pub revision: i64,
|
||||
pub payload: String,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "site_audit")]
|
||||
pub struct SiteAuditRow {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub actor_id: Option<String>,
|
||||
pub action: String,
|
||||
pub details: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
//! Portable, non-executable generation documents. Import only prepares a shared draft.
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result, ensure},
|
||||
generation::{self, Node, Rule, Variable},
|
||||
generation_service::Draft,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) fn fail(detail: impl ToString) -> Error {
|
||||
Error::Validation(crate::i18n::message(
|
||||
"transfer_invalid",
|
||||
&[("detail", detail.to_string())],
|
||||
))
|
||||
}
|
||||
pub(super) fn object(v: &Value, allowed: &[&str]) -> Result<()> {
|
||||
let o = v.as_object().ok_or_else(|| fail(v))?;
|
||||
if let Some(k) = o.keys().find(|k| !allowed.contains(&k.as_str())) {
|
||||
return Err(fail(k));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn text(v: &Value) -> Result<&str> {
|
||||
v.as_str()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| fail(v))
|
||||
}
|
||||
fn array(v: &Value) -> Result<&[Value]> {
|
||||
v.as_array().map(Vec::as_slice).ok_or_else(|| fail(v))
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(v: Value) -> Result<T> {
|
||||
serde_json::from_value(v).map_err(fail)
|
||||
}
|
||||
fn normalized(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
fn unique_names(values: impl Iterator<Item = String>) -> Result<()> {
|
||||
let mut seen = BTreeSet::new();
|
||||
for name in values {
|
||||
ensure(
|
||||
!name.trim().is_empty() && seen.insert(normalized(&name)),
|
||||
crate::i18n::message("transfer_duplicate", &[("name", name)]),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn email(doc: &WorkspaceDocument, value: &str) -> Result<String> {
|
||||
if email_address::EmailAddress::is_valid(value) {
|
||||
return Ok(value.trim().to_lowercase());
|
||||
}
|
||||
let found = Uuid::parse_str(value)
|
||||
.ok()
|
||||
.and_then(|id| doc.person(id))
|
||||
.and_then(|p| p.email.as_deref());
|
||||
found
|
||||
.filter(|s| email_address::EmailAddress::is_valid(s))
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::message(
|
||||
"transfer_email_required",
|
||||
&[(
|
||||
"person",
|
||||
doc.people
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == value)
|
||||
.map(|p| p.display_name.clone())
|
||||
.unwrap_or_else(|| value.into()),
|
||||
)],
|
||||
))
|
||||
})
|
||||
}
|
||||
fn resolve_email(doc: &WorkspaceDocument, value: &str) -> Result<Value> {
|
||||
ensure(
|
||||
email_address::EmailAddress::is_valid(value),
|
||||
crate::i18n::message("transfer_email_required", &[("person", value.into())]),
|
||||
)?;
|
||||
let matches: Vec<_> = doc
|
||||
.people
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.email
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case(value.trim()))
|
||||
})
|
||||
.collect();
|
||||
ensure(
|
||||
matches.len() <= 1,
|
||||
crate::i18n::message("transfer_duplicate", &[("name", value.into())]),
|
||||
)?;
|
||||
Ok(matches
|
||||
.first()
|
||||
.map(|p| json!(p.id))
|
||||
.unwrap_or_else(|| json!(value.trim().to_lowercase())))
|
||||
}
|
||||
fn literal(doc: &WorkspaceDocument, value: &mut Value, role: &str, export: bool) -> Result<()> {
|
||||
if let Some(a) = value.as_array_mut() {
|
||||
for v in a {
|
||||
literal(doc, v, role, export)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if value.is_null() || value.as_str() == Some("") {
|
||||
return Ok(());
|
||||
}
|
||||
if role.is_empty() {
|
||||
if !export && value.get("$person").is_some() {
|
||||
object(value, &["$person"])?;
|
||||
*value = resolve_email(doc, text(&value["$person"])?)?;
|
||||
} else if export
|
||||
&& value
|
||||
.as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.is_some_and(|id| doc.person(id).is_some())
|
||||
{
|
||||
*value = json!({"$person":email(doc,text(value)?)?});
|
||||
} else if let Some(fields) = value.as_object_mut() {
|
||||
for field in fields.values_mut() {
|
||||
literal(doc, field, "", export)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if role == "person" {
|
||||
*value = if export {
|
||||
json!(email(doc, text(value)?)?)
|
||||
} else {
|
||||
resolve_email(doc, text(value)?)?
|
||||
};
|
||||
} else if matches!(role, "schedule" | "escalation") {
|
||||
let resources: Vec<_> = if role == "schedule" {
|
||||
doc.schedules
|
||||
.iter()
|
||||
.map(|s| (s.id, s.name.as_str()))
|
||||
.collect()
|
||||
} else {
|
||||
doc.escalations
|
||||
.iter()
|
||||
.map(|s| (s.id, s.name.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let input = text(value)?;
|
||||
let found: Vec<_> = resources
|
||||
.into_iter()
|
||||
.filter(|(id, name)| {
|
||||
if export {
|
||||
id.to_string() == input
|
||||
} else {
|
||||
*name == input
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ensure(
|
||||
found.len() == 1,
|
||||
crate::i18n::message("transfer_reference", &[("name", input.into())]),
|
||||
)?;
|
||||
*value = if export {
|
||||
json!(found[0].1)
|
||||
} else {
|
||||
json!(found[0].0)
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn compact(value: &mut Value) {
|
||||
if let Some(o) = value.as_object_mut() {
|
||||
o.retain(|k, v| {
|
||||
k == "value"
|
||||
|| (!v.is_null()
|
||||
&& v.as_str() != Some("")
|
||||
&& !v.as_array().is_some_and(Vec::is_empty))
|
||||
});
|
||||
}
|
||||
}
|
||||
fn field_role(field: &str) -> &str {
|
||||
match field {
|
||||
"participants" | "primary" | "secondary" => "person",
|
||||
"fallback" => "schedule",
|
||||
"escalation" => "escalation",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
struct Links {
|
||||
variables: BTreeMap<String, String>,
|
||||
iterations: BTreeMap<String, String>,
|
||||
outputs: BTreeMap<String, Value>,
|
||||
}
|
||||
fn binding(
|
||||
b: &mut Value,
|
||||
links: &Links,
|
||||
doc: &WorkspaceDocument,
|
||||
role: &str,
|
||||
export: bool,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
ensure(depth <= 16, crate::i18n::text("transfer_limit"))?;
|
||||
object(
|
||||
b,
|
||||
&[
|
||||
"kind",
|
||||
"value",
|
||||
"variable",
|
||||
"iteration",
|
||||
"output",
|
||||
"key",
|
||||
"items",
|
||||
"path",
|
||||
"prefix",
|
||||
"suffix",
|
||||
],
|
||||
)?;
|
||||
if export {
|
||||
let kind = b["kind"].as_str().unwrap_or_default().to_owned();
|
||||
b.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|key, _| match key.as_str() {
|
||||
"value" => kind == "literal",
|
||||
"variable" => matches!(kind.as_str(), "variable" | "lookup"),
|
||||
"iteration" => matches!(kind.as_str(), "item" | "key"),
|
||||
"output" => kind == "output",
|
||||
"items" => kind == "list",
|
||||
"key" => kind == "lookup",
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
for field in ["variable", "iteration", "output"] {
|
||||
if b.get(field).is_none_or(Value::is_null) {
|
||||
continue;
|
||||
}
|
||||
let key = if field == "output" && !export {
|
||||
b[field].to_string()
|
||||
} else {
|
||||
text(&b[field])?.into()
|
||||
};
|
||||
b[field] = if field == "output" {
|
||||
links.outputs.get(&key).cloned()
|
||||
} else {
|
||||
(if field == "variable" {
|
||||
&links.variables
|
||||
} else {
|
||||
&links.iterations
|
||||
})
|
||||
.get(&key)
|
||||
.map(|s| json!(s))
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::message("transfer_reference", &[("name", key)]))
|
||||
})?;
|
||||
}
|
||||
if b["kind"] == "literal" {
|
||||
literal(doc, &mut b["value"], role, export)?;
|
||||
}
|
||||
if let Some(items) = b.get_mut("items") {
|
||||
for item in items.as_array_mut().ok_or_else(|| fail("items"))? {
|
||||
binding(item, links, doc, role, export, depth + 1)?;
|
||||
}
|
||||
}
|
||||
if let Some(key) = b.get_mut("key").filter(|v| !v.is_null()) {
|
||||
binding(key, links, doc, "", export, depth + 1)?;
|
||||
}
|
||||
if export {
|
||||
compact(b);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rule_bindings(
|
||||
rule: &mut Value,
|
||||
links: &Links,
|
||||
doc: &WorkspaceDocument,
|
||||
export: bool,
|
||||
) -> Result<()> {
|
||||
if let Some(iterations) = rule.get_mut("iterations") {
|
||||
for i in iterations
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| fail("iterations"))?
|
||||
{
|
||||
binding(&mut i["source"], links, doc, "", export, 0)?;
|
||||
}
|
||||
}
|
||||
if let Some(p) = rule.get_mut("when").filter(|v| !v.is_null()) {
|
||||
object(p, &["left", "operation", "right"])?;
|
||||
binding(&mut p["left"], links, doc, "", export, 0)?;
|
||||
if export && p["operation"] == "not_empty" {
|
||||
p["right"] = json!({"kind":"literal","value":null});
|
||||
}
|
||||
binding(&mut p["right"], links, doc, "", export, 0)?;
|
||||
}
|
||||
for block in rule["blocks"]
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| fail("blocks"))?
|
||||
{
|
||||
object(
|
||||
block,
|
||||
&[
|
||||
"id",
|
||||
"kind",
|
||||
"fields",
|
||||
"steps",
|
||||
"conditions",
|
||||
"portable_key",
|
||||
],
|
||||
)?;
|
||||
let allowed = match block["kind"].as_str() {
|
||||
Some("rotation") => vec!["name", "participants", "anchor", "handover", "shift_days"],
|
||||
Some("priority") => vec!["name", "participants", "fallback", "handover"],
|
||||
Some("escalation") => vec!["name", "repeat_count", "repeat_wait"],
|
||||
Some("route") => vec!["name", "escalation", "working"],
|
||||
_ => return Err(fail(&block["kind"])),
|
||||
};
|
||||
object(&block["fields"], &allowed)?;
|
||||
if block["kind"] != "escalation"
|
||||
&& block
|
||||
.get("steps")
|
||||
.is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty()))
|
||||
{
|
||||
return Err(fail("steps"));
|
||||
}
|
||||
if block["kind"] != "route"
|
||||
&& block
|
||||
.get("conditions")
|
||||
.is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty()))
|
||||
{
|
||||
return Err(fail("conditions"));
|
||||
}
|
||||
|
||||
for (field, b) in block["fields"]
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| fail("fields"))?
|
||||
{
|
||||
binding(b, links, doc, field_role(field), export, 0)?;
|
||||
}
|
||||
if let Some(steps) = block.get_mut("steps") {
|
||||
for step in steps.as_array_mut().ok_or_else(|| fail("steps"))? {
|
||||
object(
|
||||
step,
|
||||
&[
|
||||
"delay",
|
||||
"recipient",
|
||||
"user",
|
||||
"team_all",
|
||||
"team_random",
|
||||
"condition",
|
||||
],
|
||||
)?;
|
||||
if step.get("condition").is_some_and(|v| {
|
||||
!matches!(v.as_str(), Some("" | "if-not-acked" | "if-not-closed"))
|
||||
}) {
|
||||
return Err(fail(&step["condition"]));
|
||||
}
|
||||
binding(&mut step["delay"], links, doc, "", export, 0)?;
|
||||
let role = if step["team_all"] == true || step["team_random"] == true {
|
||||
""
|
||||
} else if step["user"] == true {
|
||||
"person"
|
||||
} else {
|
||||
"schedule"
|
||||
};
|
||||
if (step["team_all"] == true || step["team_random"] == true) && export {
|
||||
step["recipient"] = json!({"kind":"literal","value":null});
|
||||
}
|
||||
binding(&mut step["recipient"], links, doc, role, export, 0)?;
|
||||
}
|
||||
}
|
||||
if let Some(conditions) = block.get_mut("conditions") {
|
||||
for c in conditions
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| fail("conditions"))?
|
||||
{
|
||||
object(c, &["field", "operation", "value", "negate"])?;
|
||||
binding(&mut c["value"], links, doc, "", export, 0)?;
|
||||
}
|
||||
}
|
||||
if export {
|
||||
compact(block);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn export_node(node: &Node, doc: &WorkspaceDocument) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(node)?;
|
||||
if node.kind == "person" {
|
||||
v["value"] = json!(email(doc, text(&node.value)?)?);
|
||||
}
|
||||
v["entries"] = Value::Array(
|
||||
node.entries
|
||||
.iter()
|
||||
.map(|n| export_node(n, doc))
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
compact(&mut v);
|
||||
Ok(v)
|
||||
}
|
||||
/// IDs on nested entries are portable identity keys, never authority to access another workspace.
|
||||
pub fn export_v1(doc: &WorkspaceDocument, draft: &Draft) -> Result<Value> {
|
||||
unique_names(draft.variables.iter().map(|v| v.name.clone()))?;
|
||||
unique_names(draft.rules.iter().map(|r| r.name.clone()))?;
|
||||
let mut links = Links {
|
||||
variables: draft
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| (v.id.to_string(), v.name.clone()))
|
||||
.collect(),
|
||||
iterations: BTreeMap::new(),
|
||||
outputs: BTreeMap::new(),
|
||||
};
|
||||
for r in &draft.rules {
|
||||
for b in &r.blocks {
|
||||
links
|
||||
.outputs
|
||||
.insert(b.id.to_string(), json!([r.name, b.id.to_string()]));
|
||||
}
|
||||
}
|
||||
let mut rules = vec![];
|
||||
for r in &draft.rules {
|
||||
unique_names(r.iterations.iter().map(|i| i.name.clone()))?;
|
||||
links.iterations = r
|
||||
.iterations
|
||||
.iter()
|
||||
.map(|i| (i.id.to_string(), i.name.clone()))
|
||||
.collect();
|
||||
let mut value = serde_json::to_value(r)?;
|
||||
value.as_object_mut().unwrap().remove("id");
|
||||
rule_bindings(&mut value, &links, doc, true)?;
|
||||
for i in value["iterations"].as_array_mut().unwrap() {
|
||||
i.as_object_mut().unwrap().remove("id");
|
||||
}
|
||||
compact(&mut value);
|
||||
rules.push(value);
|
||||
}
|
||||
Ok(
|
||||
json!({"format":"meguri/v1","variables":draft.variables.iter().map(|v| Ok(json!({"name":v.name,"node":export_node(&v.node,doc)?}))).collect::<Result<Vec<_>>>()?,"generators":rules}),
|
||||
)
|
||||
}
|
||||
fn identity(
|
||||
scope: Uuid,
|
||||
key: Option<&Value>,
|
||||
fallback: &str,
|
||||
existing: Option<Uuid>,
|
||||
owned: &BTreeSet<Uuid>,
|
||||
) -> Result<Uuid> {
|
||||
if let Some(key) = key {
|
||||
let key = text(key)?;
|
||||
if let Ok(id) = Uuid::parse_str(key)
|
||||
&& owned.contains(&id)
|
||||
{
|
||||
return Ok(id);
|
||||
}
|
||||
Ok(generation::stable_id(&json!(["portable", scope, key])))
|
||||
} else {
|
||||
Ok(
|
||||
existing
|
||||
.unwrap_or_else(|| generation::stable_id(&json!(["portable", scope, fallback]))),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn node_ids(n: &Node, into: &mut BTreeSet<Uuid>) {
|
||||
into.insert(n.id);
|
||||
for child in &n.entries {
|
||||
node_ids(child, into);
|
||||
}
|
||||
}
|
||||
fn import_node(
|
||||
v: &Value,
|
||||
old: Option<&Node>,
|
||||
scope: Uuid,
|
||||
path: &str,
|
||||
owned: &BTreeSet<Uuid>,
|
||||
doc: &WorkspaceDocument,
|
||||
depth: usize,
|
||||
) -> Result<Node> {
|
||||
ensure(depth <= 8, crate::i18n::text("transfer_limit"))?;
|
||||
object(v, &["id", "kind", "key", "value", "entries"])?;
|
||||
if matches!(v["kind"].as_str(), Some("list" | "dictionary"))
|
||||
&& v.get("value")
|
||||
.is_some_and(|v| !v.is_null() && v.as_str() != Some(""))
|
||||
{
|
||||
return Err(fail("entries"));
|
||||
}
|
||||
let id = identity(scope, v.get("id"), path, old.map(|n| n.id), owned)?;
|
||||
let mut result = v.clone();
|
||||
result["id"] = json!(id);
|
||||
if v["kind"] == "person" {
|
||||
result["value"] = resolve_email(doc, text(&v["value"])?)?;
|
||||
}
|
||||
if let Some(entries) = v.get("entries") {
|
||||
result["entries"] = Value::Array(
|
||||
array(entries)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| {
|
||||
let child_old = old.and_then(|n| {
|
||||
if n.kind == "dictionary" {
|
||||
n.entries
|
||||
.iter()
|
||||
.find(|c| Some(c.key.as_str()) == entry["key"].as_str())
|
||||
} else {
|
||||
n.entries.get(index)
|
||||
}
|
||||
});
|
||||
import_node(
|
||||
entry,
|
||||
child_old,
|
||||
scope,
|
||||
&format!("{path}/{index}"),
|
||||
owned,
|
||||
doc,
|
||||
depth + 1,
|
||||
)
|
||||
.and_then(|n| Ok(serde_json::to_value(n)?))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
decode(result)
|
||||
}
|
||||
/// Merge only named inputs. Omitted inputs and all manual resources are retained.
|
||||
pub fn import(
|
||||
workspace: Uuid,
|
||||
doc: &WorkspaceDocument,
|
||||
base: &Draft,
|
||||
source: &str,
|
||||
) -> Result<(Draft, Value)> {
|
||||
ensure(
|
||||
source.len() <= 1_000_000,
|
||||
crate::i18n::text("transfer_limit"),
|
||||
)?;
|
||||
let mut input: Value = serde_json::from_str(source).map_err(fail)?;
|
||||
if input["format"] == "meguri/v2" {
|
||||
input = crate::portable_v2::expand(&input, doc, base)?;
|
||||
}
|
||||
object(&input, &["format", "variables", "generators"])?;
|
||||
ensure(
|
||||
input["format"] == "meguri/v1",
|
||||
crate::i18n::text("transfer_version"),
|
||||
)?;
|
||||
let vars = array(&input["variables"])?;
|
||||
let rules = array(&input["generators"])?;
|
||||
ensure(
|
||||
vars.len() <= 100 && rules.len() <= 50,
|
||||
crate::i18n::text("transfer_limit"),
|
||||
)?;
|
||||
unique_names(
|
||||
vars.iter()
|
||||
.map(|v| v["name"].as_str().unwrap_or_default().into()),
|
||||
)?;
|
||||
unique_names(
|
||||
rules
|
||||
.iter()
|
||||
.map(|v| v["name"].as_str().unwrap_or_default().into()),
|
||||
)?;
|
||||
unique_names(base.variables.iter().map(|v| v.name.clone()))?;
|
||||
unique_names(base.rules.iter().map(|v| v.name.clone()))?;
|
||||
let mut draft = base.clone();
|
||||
let mut changes = vec![];
|
||||
for v in vars {
|
||||
object(v, &["name", "node"])?;
|
||||
let name = text(&v["name"])?;
|
||||
let old = base
|
||||
.variables
|
||||
.iter()
|
||||
.find(|v| normalized(&v.name) == normalized(name));
|
||||
let id = old.map(|v| v.id).unwrap_or_else(|| {
|
||||
generation::stable_id(&json!([workspace, "variable", normalized(name)]))
|
||||
});
|
||||
let mut owned = BTreeSet::new();
|
||||
if let Some(v) = old {
|
||||
node_ids(&v.node, &mut owned);
|
||||
}
|
||||
let new = Variable {
|
||||
id,
|
||||
name: name.into(),
|
||||
node: import_node(&v["node"], old.map(|v| &v.node), id, "root", &owned, doc, 0)?,
|
||||
};
|
||||
let same =
|
||||
old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok());
|
||||
changes.push(json!({"kind":"variable","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}}));
|
||||
if let Some(at) = draft.variables.iter().position(|v| v.id == id) {
|
||||
draft.variables[at] = new;
|
||||
} else {
|
||||
draft.variables.push(new);
|
||||
}
|
||||
}
|
||||
let mut prepared = vec![];
|
||||
let mut links = Links {
|
||||
variables: draft
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| (v.name.clone(), v.id.to_string()))
|
||||
.collect(),
|
||||
iterations: BTreeMap::new(),
|
||||
outputs: BTreeMap::new(),
|
||||
};
|
||||
for r in &base.rules {
|
||||
for b in &r.blocks {
|
||||
links
|
||||
.outputs
|
||||
.insert(json!([r.name, b.id.to_string()]).to_string(), json!(b.id));
|
||||
}
|
||||
}
|
||||
for r in rules {
|
||||
object(r, &["name", "enabled", "iterations", "when", "blocks"])?;
|
||||
let name = text(&r["name"])?;
|
||||
let old = base
|
||||
.rules
|
||||
.iter()
|
||||
.find(|r| normalized(&r.name) == normalized(name));
|
||||
let id = old.map(|r| r.id).unwrap_or_else(|| {
|
||||
generation::stable_id(&json!([workspace, "rule", normalized(name)]))
|
||||
});
|
||||
let mut new = r.clone();
|
||||
new["id"] = json!(id);
|
||||
if new.get("enabled").is_none() {
|
||||
new["enabled"] = json!(true);
|
||||
}
|
||||
if new.get("iterations").is_none() {
|
||||
new["iterations"] = json!([]);
|
||||
}
|
||||
let owned: BTreeSet<_> = old
|
||||
.into_iter()
|
||||
.flat_map(|r| r.blocks.iter().map(|b| b.id))
|
||||
.collect();
|
||||
let blocks = new["blocks"].as_array_mut().ok_or_else(|| fail("blocks"))?;
|
||||
let mut keys = BTreeSet::new();
|
||||
for (index, b) in blocks.iter_mut().enumerate() {
|
||||
let key = b
|
||||
.get("id")
|
||||
.map(text)
|
||||
.transpose()?
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("block-{}", index + 1));
|
||||
ensure(
|
||||
keys.insert(key.clone()),
|
||||
crate::i18n::message("transfer_duplicate", &[("name", key.clone())]),
|
||||
)?;
|
||||
let bid = identity(
|
||||
id,
|
||||
b.get("id"),
|
||||
&key,
|
||||
old.and_then(|r| r.blocks.get(index)).map(|b| b.id),
|
||||
&owned,
|
||||
)?;
|
||||
links
|
||||
.outputs
|
||||
.insert(json!([name, key]).to_string(), json!(bid));
|
||||
b["id"] = json!(bid);
|
||||
}
|
||||
prepared.push(new);
|
||||
}
|
||||
for mut r in prepared {
|
||||
let name = text(&r["name"])?.to_owned();
|
||||
let old = base
|
||||
.rules
|
||||
.iter()
|
||||
.find(|r| normalized(&r.name) == normalized(&name));
|
||||
let id: Uuid = decode(r["id"].clone())?;
|
||||
let iterations = r["iterations"].as_array_mut().unwrap();
|
||||
unique_names(
|
||||
iterations
|
||||
.iter()
|
||||
.map(|i| i["name"].as_str().unwrap_or_default().into()),
|
||||
)?;
|
||||
links.iterations.clear();
|
||||
for i in iterations {
|
||||
object(i, &["name", "source"])?;
|
||||
let name = text(&i["name"])?;
|
||||
let iid = old
|
||||
.and_then(|r| r.iterations.iter().find(|i| i.name == name))
|
||||
.map(|i| i.id)
|
||||
.unwrap_or_else(|| generation::stable_id(&json!([id, "iteration", name])));
|
||||
links.iterations.insert(name.into(), iid.to_string());
|
||||
i["id"] = json!(iid);
|
||||
}
|
||||
rule_bindings(&mut r, &links, doc, false)?;
|
||||
let new: Rule = decode(r)?;
|
||||
let same =
|
||||
old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok());
|
||||
changes.push(json!({"kind":"rule","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}}));
|
||||
if let Some(at) = draft.rules.iter().position(|r| r.id == new.id) {
|
||||
draft.rules[at] = new;
|
||||
} else {
|
||||
draft.rules.push(new);
|
||||
}
|
||||
}
|
||||
Ok((draft, Value::Array(changes)))
|
||||
}
|
||||
|
||||
pub fn export(doc: &WorkspaceDocument, draft: &Draft) -> Result<Value> {
|
||||
crate::portable_v2::compact(&export_v1(doc, draft)?, draft)
|
||||
}
|
||||
|
||||
/// Keep small references and scalar collections on one line for hand editing.
|
||||
pub fn format_config(value: &Value) -> String {
|
||||
fn render(v: &Value, depth: usize) -> String {
|
||||
let short = v.to_string();
|
||||
if short.len() + depth * 2 <= 110 || (!v.is_array() && !v.is_object()) {
|
||||
return short;
|
||||
}
|
||||
let indent = " ".repeat(depth + 1);
|
||||
let end = " ".repeat(depth);
|
||||
match v {
|
||||
Value::Array(a) => format!(
|
||||
"[\n{}\n{}]",
|
||||
a.iter()
|
||||
.map(|v| format!("{}{}", indent, render(v, depth + 1)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n"),
|
||||
end
|
||||
),
|
||||
Value::Object(o) => {
|
||||
let preferred: &[&str] = if o.contains_key("format") {
|
||||
&["format", "variables", "generators"]
|
||||
} else if o.contains_key("outputs") {
|
||||
&["name", "enabled", "for", "when", "outputs"]
|
||||
} else if o.contains_key("type") && o.contains_key("key") {
|
||||
&["key", "type", "name"]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let keys: Vec<_> = preferred
|
||||
.iter()
|
||||
.filter_map(|k| o.get_key_value(*k).map(|(k, _)| k))
|
||||
.chain(o.keys().filter(|k| !preferred.contains(&k.as_str())))
|
||||
.collect();
|
||||
format!(
|
||||
"{{\n{}\n{}}}",
|
||||
keys.iter()
|
||||
.map(|k| format!("{}{}: {}", indent, json!(k), render(&o[*k], depth + 1)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n"),
|
||||
end
|
||||
)
|
||||
}
|
||||
_ => short,
|
||||
}
|
||||
}
|
||||
render(value, 0)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Compact JSON syntax translates to the existing, validated portable importer.
|
||||
use crate::{
|
||||
domain::WorkspaceDocument,
|
||||
error::{Result, ensure},
|
||||
generation,
|
||||
generation_service::Draft,
|
||||
portable::{self, fail, object},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
fn arr(v: &Value) -> Result<&Vec<Value>> {
|
||||
v.as_array().ok_or_else(|| fail(v))
|
||||
}
|
||||
fn txt(v: &Value) -> Result<&str> {
|
||||
v.as_str().filter(|s| !s.is_empty()).ok_or_else(|| fail(v))
|
||||
}
|
||||
fn norm(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
fn aliases(draft: &Draft) -> Result<BTreeMap<String, String>> {
|
||||
let mut result = BTreeMap::new();
|
||||
for r in &draft.rules {
|
||||
let mut used: BTreeSet<String> = r
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| b.portable_key.clone())
|
||||
.collect();
|
||||
ensure(
|
||||
used.len() == r.blocks.iter().filter(|b| b.portable_key.is_some()).count()
|
||||
&& used.iter().all(|k| !k.trim().is_empty() && k.len() <= 120),
|
||||
crate::i18n::text("transfer_v2_binding"),
|
||||
)?;
|
||||
for b in &r.blocks {
|
||||
let key = b.portable_key.clone().unwrap_or_else(|| {
|
||||
let mut key = b.kind.clone();
|
||||
let mut n = 2;
|
||||
while used.contains(&key) {
|
||||
key = format!("{}-{n}", b.kind);
|
||||
n += 1;
|
||||
}
|
||||
used.insert(key.clone());
|
||||
key
|
||||
});
|
||||
result.insert(b.id.to_string(), key);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
fn compact_node(n: &Value) -> Result<Value> {
|
||||
Ok(match txt(&n["kind"])? {
|
||||
"list" => Value::Array(
|
||||
n["entries"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.map(compact_node)
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
"dictionary" => {
|
||||
let entries = n["entries"].as_array().cloned().unwrap_or_default();
|
||||
let keys: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|e| e["key"].as_str().unwrap_or_default())
|
||||
.collect();
|
||||
if keys.iter().any(|k| k.starts_with('$')) || !keys.windows(2).all(|w| w[0] < w[1]) {
|
||||
json!({"$object":entries.iter().map(|e|Ok(json!([e["key"],compact_node(e)?]))).collect::<Result<Vec<_>>>()?})
|
||||
} else {
|
||||
let mut values = serde_json::Map::new();
|
||||
for e in entries {
|
||||
values.insert(txt(&e["key"])?.into(), compact_node(&e)?);
|
||||
}
|
||||
Value::Object(values)
|
||||
}
|
||||
}
|
||||
"person" => n["value"].clone(),
|
||||
"text"
|
||||
if n["value"]
|
||||
.as_str()
|
||||
.is_some_and(email_address::EmailAddress::is_valid) =>
|
||||
{
|
||||
json!({"$text":n["value"]})
|
||||
}
|
||||
"text" | "number" | "boolean" => n["value"].clone(),
|
||||
kind @ ("date" | "time" | "duration") => {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(format!("${kind}"), n["value"].clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
_ => return Err(fail(&n["kind"])),
|
||||
})
|
||||
}
|
||||
fn compact_binding(b: &Value, names: &BTreeMap<String, String>) -> Result<Value> {
|
||||
let kind = txt(&b["kind"])?;
|
||||
let mut out = match kind {
|
||||
"literal" => {
|
||||
if b["value"].is_object() {
|
||||
json!({"$literal":b["value"]})
|
||||
} else {
|
||||
b["value"].clone()
|
||||
}
|
||||
}
|
||||
"list" => {
|
||||
json!({"$list":arr(&b["items"] )?.iter().map(|v|compact_binding(v,names)).collect::<Result<Vec<_>>>()?})
|
||||
}
|
||||
"variable" | "item" | "key" => {
|
||||
let key = if kind == "variable" {
|
||||
"$var"
|
||||
} else if kind == "item" {
|
||||
"$item"
|
||||
} else {
|
||||
"$key"
|
||||
};
|
||||
let root = if kind == "variable" {
|
||||
b["variable"].clone()
|
||||
} else {
|
||||
b["iteration"].clone()
|
||||
};
|
||||
let mut path = vec![root.clone()];
|
||||
if let Some(a) = b["path"].as_array() {
|
||||
path.extend(a.iter().cloned());
|
||||
}
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(key.into(), if path.len() == 1 { root } else { json!(path) });
|
||||
Value::Object(o)
|
||||
}
|
||||
"output" => {
|
||||
let pair = arr(&b["output"])?;
|
||||
let alias = names.get(txt(&pair[1])?).ok_or_else(|| fail(&pair[1]))?;
|
||||
json!({"$output":[pair[0],alias]})
|
||||
}
|
||||
"lookup" => json!({"$lookup":[b["variable"],compact_binding(&b["key"],names)?]}),
|
||||
_ => return Err(fail(kind)),
|
||||
};
|
||||
let mut modifiers = serde_json::Map::new();
|
||||
for k in ["prefix", "suffix", "path"] {
|
||||
if k == "path" && matches!(kind, "variable" | "item" | "key") {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = b.get(k).filter(|v| {
|
||||
!v.is_null() && v.as_str() != Some("") && !v.as_array().is_some_and(Vec::is_empty)
|
||||
}) {
|
||||
modifiers.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if !modifiers.is_empty() {
|
||||
if kind == "literal" || !out.is_object() {
|
||||
out = json!({"$literal":b["value"]});
|
||||
}
|
||||
out.as_object_mut().unwrap().extend(modifiers);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn expand_binding(v: &Value) -> Result<Value> {
|
||||
if !v.is_object() {
|
||||
return Ok(json!({"kind":"literal","value":v}));
|
||||
}
|
||||
let o = v.as_object().unwrap();
|
||||
let tags: Vec<_> = o.keys().filter(|k| k.starts_with('$')).collect();
|
||||
ensure(tags.len() == 1, crate::i18n::text("transfer_v2_binding"))?;
|
||||
let tag = tags[0].as_str();
|
||||
object(v, &[tag, "path", "prefix", "suffix"])?;
|
||||
let mut out = match tag {
|
||||
"$literal" => json!({"kind":"literal","value":v[tag]}),
|
||||
"$var" | "$item" | "$key" => {
|
||||
let (root, path) = if let Some(s) = v[tag].as_str() {
|
||||
(json!(s), vec![])
|
||||
} else {
|
||||
let a = arr(&v[tag])?;
|
||||
ensure(!a.is_empty(), crate::i18n::text("transfer_v2_binding"))?;
|
||||
(a[0].clone(), a[1..].to_vec())
|
||||
};
|
||||
txt(&root)?;
|
||||
if tag == "$var" {
|
||||
json!({"kind":"variable","variable":root,"path":path})
|
||||
} else {
|
||||
json!({"kind":if tag=="$key"{"key"}else{"item"},"iteration":root,"path":path})
|
||||
}
|
||||
}
|
||||
"$output" => {
|
||||
let a = arr(&v[tag])?;
|
||||
ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?;
|
||||
txt(&a[0])?;
|
||||
txt(&a[1])?;
|
||||
json!({"kind":"output","output":a})
|
||||
}
|
||||
"$lookup" => {
|
||||
let a = arr(&v[tag])?;
|
||||
ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?;
|
||||
txt(&a[0])?;
|
||||
json!({"kind":"lookup","variable":a[0],"key":expand_binding(&a[1])?})
|
||||
}
|
||||
"$list" => {
|
||||
json!({"kind":"list","items":arr(&v[tag])?.iter().map(expand_binding).collect::<Result<Vec<_>>>()?})
|
||||
}
|
||||
_ => return Err(fail(tag)),
|
||||
};
|
||||
for key in ["path", "prefix", "suffix"] {
|
||||
if let Some(val) = v.get(key) {
|
||||
if key == "path" && out[key].as_array().is_some_and(|a| !a.is_empty()) {
|
||||
return Err(fail("path"));
|
||||
}
|
||||
out[key] = val.clone();
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn default_field(k: &str, v: &Value) -> bool {
|
||||
match k {
|
||||
"handover" => v == "11:00" || v == "11:00:00",
|
||||
"shift_days" => v == 7,
|
||||
"repeat_count" | "repeat_wait" => v == 0,
|
||||
"working" => v == false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(super) fn compact(v1: &Value, draft: &Draft) -> Result<Value> {
|
||||
let names = aliases(draft)?;
|
||||
let mut variables = serde_json::Map::new();
|
||||
for v in arr(&v1["variables"])? {
|
||||
variables.insert(txt(&v["name"])?.into(), compact_node(&v["node"])?);
|
||||
}
|
||||
let mut generators = vec![];
|
||||
for r in arr(&v1["generators"])? {
|
||||
let mut rule = json!({"name":r["name"],"outputs":[]});
|
||||
if r["enabled"] == false {
|
||||
rule["enabled"] = json!(false);
|
||||
}
|
||||
if let Some(iterations) = r["iterations"].as_array().filter(|a| !a.is_empty()) {
|
||||
rule["for"] = Value::Array(
|
||||
iterations
|
||||
.iter()
|
||||
.map(|i| Ok(json!({"as":i["name"],"in":compact_binding(&i["source"],&names)?})))
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
if let Some(p) = r.get("when").filter(|v| !v.is_null()) {
|
||||
rule["when"] = json!([
|
||||
compact_binding(&p["left"], &names)?,
|
||||
p["operation"],
|
||||
compact_binding(&p["right"], &names)?
|
||||
]);
|
||||
}
|
||||
for b in arr(&r["blocks"])? {
|
||||
let mut block = json!({"key":names.get(txt(&b["id"] )?).ok_or_else(||fail(&b["id"]))?,"type":b["kind"]});
|
||||
for (field, binding) in b["fields"].as_object().ok_or_else(|| fail("fields"))? {
|
||||
let value = compact_binding(binding, &names)?;
|
||||
if !default_field(field, &value) {
|
||||
block[field] = value;
|
||||
}
|
||||
}
|
||||
if let Some(steps) = b["steps"].as_array().filter(|a| !a.is_empty()) {
|
||||
block["steps"] = Value::Array(
|
||||
steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut step = if s["team_all"] == true {
|
||||
json!({"team":"all"})
|
||||
} else if s["team_random"] == true {
|
||||
json!({"team":"random"})
|
||||
} else {
|
||||
let key = if s["user"] == true {
|
||||
"person"
|
||||
} else {
|
||||
"schedule"
|
||||
};
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert(key.into(), compact_binding(&s["recipient"], &names)?);
|
||||
Value::Object(m)
|
||||
};
|
||||
let after = compact_binding(&s["delay"], &names)?;
|
||||
if after != 0 {
|
||||
step["after"] = after;
|
||||
}
|
||||
if s["condition"] == "if-not-closed" {
|
||||
step["if"] = json!("not-closed");
|
||||
}
|
||||
Ok(step)
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
if let Some(conditions) = b["conditions"].as_array().filter(|a| !a.is_empty()) {
|
||||
block["conditions"] = Value::Array(
|
||||
conditions
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut a = vec![
|
||||
c["field"].clone(),
|
||||
c["operation"].clone(),
|
||||
compact_binding(&c["value"], &names)?,
|
||||
];
|
||||
if c["negate"] == true {
|
||||
a.push(json!("not"));
|
||||
}
|
||||
Ok(json!(a))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
rule["outputs"].as_array_mut().unwrap().push(block);
|
||||
}
|
||||
generators.push(rule);
|
||||
}
|
||||
Ok(json!({"format":"meguri/v2","variables":variables,"generators":generators}))
|
||||
}
|
||||
fn node_key(v: &Value) -> Option<String> {
|
||||
if let Some(name) = v.get("name").and_then(Value::as_str) {
|
||||
Some(format!("name:{name}"))
|
||||
} else if !v.is_array() && !v.is_object() {
|
||||
Some(format!("value:{v}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn expand_node(v: &Value, old: Option<&Value>, path: &str, depth: usize) -> Result<Value> {
|
||||
ensure(depth <= 8, crate::i18n::text("transfer_limit"))?;
|
||||
let mut n = if let Some(a) = v.as_array() {
|
||||
let prior = old
|
||||
.and_then(|o| o["entries"].as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let values = prior.iter().map(compact_node).collect::<Result<Vec<_>>>()?;
|
||||
let mut used = BTreeSet::new();
|
||||
let mut matches = vec![None; a.len()];
|
||||
for (i, item) in a.iter().enumerate() {
|
||||
let candidates: Vec<_> = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, x)| {
|
||||
!used.contains(j)
|
||||
&& (*x == item
|
||||
|| node_key(item).is_some_and(|key| node_key(x) == Some(key)))
|
||||
})
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
if candidates.len() == 1 {
|
||||
matches[i] = Some(candidates[0]);
|
||||
used.insert(candidates[0]);
|
||||
}
|
||||
}
|
||||
// Preserve a lone in-place edit, but never borrow an identity already matched elsewhere.
|
||||
for (i, m) in matches.iter_mut().enumerate() {
|
||||
if m.is_none() && prior.len() == a.len() && !used.contains(&i) {
|
||||
*m = Some(i);
|
||||
used.insert(i);
|
||||
}
|
||||
}
|
||||
let entries = a
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
expand_node(
|
||||
item,
|
||||
matches[i].map(|j| &prior[j]),
|
||||
&format!("{path}/{i}"),
|
||||
depth + 1,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
json!({"kind":"list","entries":entries,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)})
|
||||
} else if let Some(o) = v.as_object() {
|
||||
if let Some((tag, value)) = o.iter().find(|(k, _)| k.starts_with('$')) {
|
||||
object(v, &[tag])?;
|
||||
match tag.as_str() {
|
||||
"$text" | "$person" | "$date" | "$time" | "$duration" => {
|
||||
json!({"kind":&tag[1..],"value":value})
|
||||
}
|
||||
"$object" => dictionary(
|
||||
arr(value)?
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
let pair = arr(pair)?;
|
||||
ensure(pair.len() == 2, crate::i18n::text("transfer_v2_binding"))?;
|
||||
Ok((txt(&pair[0])?.to_owned(), pair[1].clone()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
old,
|
||||
path,
|
||||
depth,
|
||||
)?,
|
||||
_ => return Err(fail(tag)),
|
||||
}
|
||||
} else {
|
||||
dictionary(
|
||||
o.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
old,
|
||||
path,
|
||||
depth,
|
||||
)?
|
||||
}
|
||||
} else if let Some(s) = v.as_str() {
|
||||
json!({"kind":if email_address::EmailAddress::is_valid(s){"person"}else{"text"},"value":v})
|
||||
} else if v.is_boolean() {
|
||||
json!({"kind":"boolean","value":v})
|
||||
} else if v.is_number() {
|
||||
json!({"kind":"number","value":v})
|
||||
} else {
|
||||
return Err(fail(v));
|
||||
};
|
||||
n["id"] = old
|
||||
.map(|o| o["id"].clone())
|
||||
.unwrap_or_else(|| json!(generation::stable_id(&json!(["v2-entry", path, v]))));
|
||||
Ok(n)
|
||||
}
|
||||
fn dictionary(
|
||||
entries: Vec<(String, Value)>,
|
||||
old: Option<&Value>,
|
||||
path: &str,
|
||||
depth: usize,
|
||||
) -> Result<Value> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut nodes = vec![];
|
||||
for (key, value) in entries {
|
||||
ensure(
|
||||
seen.insert(key.clone()),
|
||||
crate::i18n::message("transfer_duplicate", &[("name", key.clone())]),
|
||||
)?;
|
||||
let previous = old
|
||||
.and_then(|o| o["entries"].as_array())
|
||||
.and_then(|a| a.iter().find(|n| n["key"] == key));
|
||||
let mut node = expand_node(&value, previous, &format!("{path}/{key}"), depth + 1)?;
|
||||
node["key"] = json!(key);
|
||||
nodes.push(node);
|
||||
}
|
||||
Ok(
|
||||
json!({"kind":"dictionary","entries":nodes,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)}),
|
||||
)
|
||||
}
|
||||
pub(super) fn expand(v: &Value, doc: &WorkspaceDocument, base: &Draft) -> Result<Value> {
|
||||
object(v, &["format", "variables", "generators"])?;
|
||||
let old = json!({"variables":base.variables.iter().map(|v|json!({"name":v.name,"node":portable::export_node(&v.node,doc).unwrap_or_else(|_|serde_json::to_value(&v.node).expect("typed node"))})).collect::<Vec<_>>()});
|
||||
let names = aliases(base)?;
|
||||
let mut vars = vec![];
|
||||
for (name, value) in v["variables"]
|
||||
.as_object()
|
||||
.ok_or_else(|| fail("variables"))?
|
||||
{
|
||||
let previous = old["variables"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| norm(p["name"].as_str().unwrap_or_default()) == norm(name));
|
||||
vars.push(
|
||||
json!({"name":name,"node":expand_node(value,previous.map(|p|&p["node"]),name,0)?}),
|
||||
);
|
||||
}
|
||||
let mut outputs = BTreeMap::new();
|
||||
for r in &base.rules {
|
||||
for b in &r.blocks {
|
||||
outputs.insert(
|
||||
json!([norm(&r.name), names[&b.id.to_string()]]).to_string(),
|
||||
json!(b.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
for r in arr(&v["generators"])? {
|
||||
for b in arr(&r["outputs"])? {
|
||||
let key = txt(&b["key"])?;
|
||||
outputs
|
||||
.entry(json!([norm(txt(&r["name"])?), key]).to_string())
|
||||
.or_insert_with(|| json!(key));
|
||||
}
|
||||
}
|
||||
let mut rules = vec![];
|
||||
for r in arr(&v["generators"])? {
|
||||
object(r, &["name", "enabled", "for", "when", "outputs"])?;
|
||||
let name = txt(&r["name"])?;
|
||||
let mut rule = json!({"name":name,"enabled":r.get("enabled").cloned().unwrap_or(json!(true)),"blocks":[]});
|
||||
if let Some(iterations) = r.get("for") {
|
||||
rule["iterations"] = Value::Array(
|
||||
arr(iterations)?
|
||||
.iter()
|
||||
.map(|i| {
|
||||
object(i, &["as", "in"])?;
|
||||
Ok(json!({"name":i["as"],"source":expand_binding(&i["in"])?}))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
if let Some(p) = r.get("when") {
|
||||
let p = arr(p)?;
|
||||
ensure(p.len() == 3, crate::i18n::text("transfer_v2_binding"))?;
|
||||
rule["when"] = json!({"left":expand_binding(&p[0])?,"operation":p[1],"right":expand_binding(&p[2])?});
|
||||
}
|
||||
for b in arr(&r["outputs"])? {
|
||||
let key = txt(&b["key"])?;
|
||||
let mut block = json!({"id":outputs.get(&json!([norm(name),key]).to_string()).ok_or_else(||fail(key))?,"portable_key":key,"kind":b["type"],"fields":{}});
|
||||
for (field, value) in b.as_object().ok_or_else(|| fail(b))? {
|
||||
if !["key", "type", "steps", "conditions"].contains(&field.as_str()) {
|
||||
block["fields"][field] = expand_binding(value)?;
|
||||
}
|
||||
}
|
||||
if let Some(steps) = b.get("steps") {
|
||||
block["steps"]=Value::Array(arr(steps)?.iter().map(|s|{
|
||||
object(s,&["after","if","person","schedule","team"])?;
|
||||
let recipients:Vec<_>=["person","schedule","team"].into_iter().filter(|k|s.get(k).is_some()).collect();ensure(recipients.len()==1,crate::i18n::text("transfer_v2_binding"))?;
|
||||
let condition=match s.get("if").and_then(Value::as_str){None|Some("not-acked")=>"if-not-acked",Some("not-closed")=>"if-not-closed",_=>return Err(fail(&s["if"]))};
|
||||
let recipient=if recipients[0]=="team"{ensure(s["team"]=="all" || s["team"]=="random",crate::i18n::text("transfer_v2_binding"))?;json!({"kind":"literal","value":null})}else{expand_binding(&s[recipients[0]])?};
|
||||
Ok(json!({"delay":expand_binding(s.get("after").unwrap_or(&json!(0)))?,"condition":condition,"user":recipients[0]=="person","team_all":s["team"]=="all","team_random":s["team"]=="random","recipient":recipient}))
|
||||
}).collect::<Result<_>>()?);
|
||||
}
|
||||
if let Some(conditions) = b.get("conditions") {
|
||||
block["conditions"]=Value::Array(arr(conditions)?.iter().map(|c|{let a=arr(c)?;ensure(a.len()==3||(a.len()==4&&a[3]=="not"),crate::i18n::text("transfer_v2_binding"))?;Ok(json!({"field":a[0],"operation":a[1],"value":expand_binding(&a[2])?,"negate":a.len()==4}))}).collect::<Result<_>>()?);
|
||||
}
|
||||
rule["blocks"].as_array_mut().unwrap().push(block);
|
||||
}
|
||||
remap_outputs(&mut rule, &outputs)?;
|
||||
rules.push(rule);
|
||||
}
|
||||
Ok(json!({"format":"meguri/v1","variables":vars,"generators":rules}))
|
||||
}
|
||||
fn remap_outputs(v: &mut Value, outputs: &BTreeMap<String, Value>) -> Result<()> {
|
||||
if v["kind"] == "literal" {
|
||||
return Ok(());
|
||||
}
|
||||
if v["kind"] == "output" {
|
||||
let old = v["output"].clone();
|
||||
let id = outputs
|
||||
.get(&json!([norm(txt(&old[0])?), old[1]]).to_string())
|
||||
.ok_or_else(|| fail(&old))?;
|
||||
v["output"] = json!([old[0], id]);
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for v in o.values_mut() {
|
||||
remap_outputs(v, outputs)?;
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for v in a {
|
||||
remap_outputs(v, outputs)?;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
+588
-68
@@ -8,6 +8,7 @@ use crate::{
|
||||
use chrono::{DateTime, Timelike, Utc};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -168,7 +169,13 @@ impl Jsm {
|
||||
let mut ids: BTreeSet<_> = plan
|
||||
.schedules
|
||||
.iter()
|
||||
.flat_map(|s| s.periods.iter().map(|p| p.user_id))
|
||||
.flat_map(|s| {
|
||||
s.periods.iter().map(|p| p.user_id).chain(
|
||||
s.recurring
|
||||
.iter()
|
||||
.flat_map(|r| r.participants.iter().copied()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
ids.extend(
|
||||
plan.escalations
|
||||
@@ -241,6 +248,7 @@ impl Jsm {
|
||||
})?))
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Value>> {
|
||||
const PAGE_SIZE: usize = 50;
|
||||
let mut all = vec![];
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
@@ -248,7 +256,7 @@ impl Jsm {
|
||||
let page = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("{path}{sep}offset={offset}&size=100"),
|
||||
&format!("{path}{sep}offset={offset}&size={PAGE_SIZE}"),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
@@ -269,7 +277,7 @@ impl Jsm {
|
||||
.pointer("/links/next")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
&& count < 100
|
||||
&& count < PAGE_SIZE
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -326,8 +334,36 @@ impl Jsm {
|
||||
desired: &Value,
|
||||
previous: Option<&ProviderObjectRow>,
|
||||
overwrite: bool,
|
||||
as_of: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
let name = desired["name"].as_str().unwrap_or_default();
|
||||
let local_id = key
|
||||
.split_once(':')
|
||||
.and_then(|(_, id)| Uuid::parse_str(id).ok())
|
||||
.expect("provider object keys contain a resource UUID");
|
||||
// A deleted resource is absent desired state, not an edit to an existing
|
||||
// resource. Clear stale identity before discovery so a lost replacement
|
||||
// create response can be recovered by its stable ownership marker.
|
||||
let mut previous = previous;
|
||||
if let Some(old) = previous
|
||||
&& self.read(kind, &old.remote_id).await?.is_none()
|
||||
{
|
||||
db.forget(record.id, key).await?;
|
||||
db.forget(record.id, &format!("pending:{key}")).await?;
|
||||
if kind == "schedule" {
|
||||
let prefix = format!("coverage:{local_id}:");
|
||||
for child in db
|
||||
.objects(record.id)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|r| r.object_key.starts_with(&prefix))
|
||||
{
|
||||
db.forget(record.id, &child.object_key).await?;
|
||||
}
|
||||
}
|
||||
previous = None;
|
||||
}
|
||||
let marker = ownership_marker(record.id, local_id);
|
||||
let legacy_name = format!("meguri-{}-{}", record.id.simple(), local_id.simple());
|
||||
let existing_id = if let Some(previous) = previous {
|
||||
Some(previous.remote_id.clone())
|
||||
} else {
|
||||
@@ -335,7 +371,11 @@ impl Jsm {
|
||||
.list(&self.collection(kind))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|v| v["name"].as_str() == Some(name))
|
||||
.filter(|v| {
|
||||
v["name"]
|
||||
.as_str()
|
||||
.is_some_and(|name| name.ends_with(&marker) || name == legacy_name)
|
||||
})
|
||||
.collect();
|
||||
ensure(
|
||||
found.len() <= 1,
|
||||
@@ -354,18 +394,32 @@ impl Jsm {
|
||||
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 pending_key = format!("pending:{key}");
|
||||
let pending =
|
||||
db.objects(record.id).await?.into_iter().find(|r| {
|
||||
r.object_key == pending_key && Some(&r.remote_id) == existing_id.as_ref()
|
||||
});
|
||||
let pending_expected = pending
|
||||
.as_ref()
|
||||
.map(|r| serde_json::from_str::<Value>(&r.content))
|
||||
.transpose()?;
|
||||
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 {
|
||||
.is_some_and(|last| equivalent(current, last))
|
||||
|| pending_expected
|
||||
.as_ref()
|
||||
.is_some_and(|next| equivalent(current, next));
|
||||
let legacy_matches =
|
||||
if previous.is_none() && current["name"].as_str() == Some(&legacy_name) {
|
||||
let mut legacy = desired.clone();
|
||||
legacy["name"] = json!(legacy_name);
|
||||
equivalent(current, &legacy)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !matches_desired && !known && !legacy_matches && !overwrite {
|
||||
return Err(Error::Conflict(crate::i18n::message(
|
||||
"err_jsm_kind_changed_outside_meguri_publication_paus_ac8220",
|
||||
&[("kind", (kind).to_string())],
|
||||
@@ -373,48 +427,75 @@ impl Jsm {
|
||||
}
|
||||
}
|
||||
if kind == "schedule"
|
||||
&& current.is_some()
|
||||
&& 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,
|
||||
)
|
||||
self.check_coverage(db, record.id, local_id, id, overwrite, as_of)
|
||||
.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(),
|
||||
let current = current.as_ref().unwrap();
|
||||
if kind == "schedule"
|
||||
&& current["rotations"]
|
||||
.as_array()
|
||||
.is_some_and(|r| r.len() == 1)
|
||||
&& desired["rotations"]
|
||||
.as_array()
|
||||
.is_some_and(|r| r.len() == 1)
|
||||
{
|
||||
let rotation_id = current["rotations"][0]["id"]
|
||||
.as_str()
|
||||
.filter(|v| safe_id(v))
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_unexpected_jsm_resource_identifier_dedd70")
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let mut metadata = desired.clone();
|
||||
metadata.as_object_mut().unwrap().remove("rotations");
|
||||
if !equivalent(current, &metadata) {
|
||||
let mut intermediate = metadata.clone();
|
||||
intermediate["rotations"] = current["rotations"].clone();
|
||||
self.schedule_step(
|
||||
db,
|
||||
record.id,
|
||||
key,
|
||||
&id,
|
||||
&format!("/schedules/{id}"),
|
||||
&metadata,
|
||||
&intermediate,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if !equivalent(¤t["rotations"][0], &desired["rotations"][0]) {
|
||||
self.schedule_step(
|
||||
db,
|
||||
record.id,
|
||||
key,
|
||||
&id,
|
||||
&format!("/schedules/{id}/rotations/{rotation_id}"),
|
||||
&desired["rotations"][0],
|
||||
desired,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
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
|
||||
@@ -450,8 +531,303 @@ impl Jsm {
|
||||
)));
|
||||
}
|
||||
db.checkpoint(record.id, key, &id, desired).await?;
|
||||
db.forget(record.id, &pending_key).await?;
|
||||
Ok(id)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn schedule_step(
|
||||
&self,
|
||||
db: &Store,
|
||||
workspace: Uuid,
|
||||
key: &str,
|
||||
id: &str,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
expected: &Value,
|
||||
) -> Result<()> {
|
||||
let pending = format!("pending:{key}");
|
||||
db.checkpoint(workspace, &pending, id, expected).await?;
|
||||
self.request(Method::PATCH, path, Some(body))
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Conflict(
|
||||
crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(),
|
||||
)
|
||||
})?;
|
||||
let actual = self.read("schedule", id).await?.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_resource_is_missing_after_publication_3448ad").into(),
|
||||
)
|
||||
})?;
|
||||
ensure(
|
||||
equivalent(&actual, expected),
|
||||
crate::i18n::message(
|
||||
"err_jsm_kind_read_back_does_not_match_the_requested_63440e",
|
||||
&[("kind", "schedule".into())],
|
||||
),
|
||||
)?;
|
||||
db.checkpoint(workspace, key, id, expected).await?;
|
||||
db.forget(workspace, &pending).await?;
|
||||
Ok(())
|
||||
}
|
||||
fn schedule_payloads(
|
||||
&self,
|
||||
schedule: &super::PublishedSchedule,
|
||||
as_of: DateTime<Utc>,
|
||||
) -> Result<(Vec<Value>, Vec<Value>)> {
|
||||
if let Some(rotation) = &schedule.recurring {
|
||||
let zone = schedule.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
|
||||
Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into())
|
||||
})?;
|
||||
if half_hour_floor(rotation.start, zone) == rotation.start
|
||||
&& half_hour_floor(rotation.end, zone) == rotation.end
|
||||
{
|
||||
let participants = rotation
|
||||
.participants
|
||||
.iter()
|
||||
.map(|id| self.mapped(*id).map(|id| json!({"type":"user","id":id})))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let (kind, length) = if rotation.shift_days.is_multiple_of(7) {
|
||||
("weekly", rotation.shift_days / 7)
|
||||
} else {
|
||||
("daily", rotation.shift_days)
|
||||
};
|
||||
return Ok((
|
||||
vec![
|
||||
json!({"name":schedule.name,"startDate":rotation.start,"endDate":rotation.end,"type":kind,"length":length,"participants":participants}),
|
||||
],
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
}
|
||||
if !needs_precise_coverage(schedule)? {
|
||||
let zone = schedule.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
|
||||
Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into())
|
||||
})?;
|
||||
let rotations = schedule.periods.iter().map(|p| Ok(json!({
|
||||
"name":if schedule.periods.len() == 1 { schedule.name.clone() } else {
|
||||
format!("{} · {}", schedule.name, p.start.with_timezone(&zone).format("%d-%m-%Y %H:%M %:z"))
|
||||
}, "startDate":p.start, "endDate":p.end,
|
||||
"type":"daily", "length":1, "participants":[{"type":"user","id":self.mapped(p.user_id)?}]
|
||||
}))).collect::<Result<Vec<_>>>()?;
|
||||
return Ok((rotations, vec![]));
|
||||
}
|
||||
let zone = schedule.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
|
||||
Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into())
|
||||
})?;
|
||||
let current = schedule
|
||||
.periods
|
||||
.iter()
|
||||
.find(|p| p.start <= as_of && as_of < p.end);
|
||||
let base_person = current.map(|p| self.mapped(p.user_id)).transpose()?;
|
||||
let first = current.unwrap_or(&schedule.periods[0]);
|
||||
let last_end = schedule.periods.last().unwrap().end;
|
||||
let start = half_hour_floor(first.start, zone);
|
||||
let floor_end = half_hour_floor(last_end, zone);
|
||||
let end = if floor_end == last_end {
|
||||
floor_end
|
||||
} else {
|
||||
floor_end + chrono::Duration::minutes(30)
|
||||
};
|
||||
let participant = base_person.as_ref().map_or_else(
|
||||
|| json!({"type":"noone"}),
|
||||
|id| json!({"type":"user","id":id}),
|
||||
);
|
||||
let rotations = vec![
|
||||
json!({"name":schedule.name, "startDate":start, "endDate":end,
|
||||
"type":"daily", "length":1, "participants":[participant]}),
|
||||
];
|
||||
let mut coverage = vec![];
|
||||
// JSM rejects new overrides starting in the past. The already effective
|
||||
// assignee is the base; overrides describe only future transitions.
|
||||
for p in schedule.periods.iter().filter(|p| p.start > as_of) {
|
||||
coverage.push(
|
||||
json!({"responder":{"type":"user","id":self.mapped(p.user_id)?},
|
||||
"startDate":milliseconds(p.start), "endDate":milliseconds(p.end)}),
|
||||
);
|
||||
}
|
||||
if base_person.is_some() {
|
||||
for pair in schedule.periods.windows(2) {
|
||||
if pair[0].end > as_of && pair[0].end < pair[1].start {
|
||||
coverage.push(json!({"responder":{"type":"noone"},
|
||||
"startDate":milliseconds(pair[0].end), "endDate":milliseconds(pair[1].start)}));
|
||||
}
|
||||
}
|
||||
if last_end > as_of && last_end < end {
|
||||
coverage.push(json!({"responder":{"type":"noone"},
|
||||
"startDate":milliseconds(last_end), "endDate":end}));
|
||||
}
|
||||
}
|
||||
Ok((rotations, coverage))
|
||||
}
|
||||
|
||||
// Empty remote IDs are durable create intents, never verified publications.
|
||||
async fn check_coverage(
|
||||
&self,
|
||||
db: &Store,
|
||||
workspace: Uuid,
|
||||
schedule: Uuid,
|
||||
remote: &str,
|
||||
overwrite: bool,
|
||||
_as_of: DateTime<Utc>,
|
||||
) -> Result<()> {
|
||||
let prefix = format!("coverage:{schedule}:");
|
||||
let rows = db.objects(workspace).await?;
|
||||
let mut owned = vec![];
|
||||
for row in rows.iter().filter(|r| r.object_key.starts_with(&prefix)) {
|
||||
let content: Value = serde_json::from_str(&row.content)?;
|
||||
if content["scheduleId"].as_str() == Some(remote) {
|
||||
owned.push((row, content));
|
||||
}
|
||||
}
|
||||
let items = self.list(&format!("/schedules/{remote}/overrides")).await?;
|
||||
for item in &items {
|
||||
let alias = coverage_alias(item)?;
|
||||
if let Some((_, content)) = owned.iter().find(|(row, _)| row.remote_id == alias) {
|
||||
if !overwrite && !equivalent(item, &content["payload"]) {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("jsm_coverage_conflict").into(),
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let pending: Vec<_> = owned
|
||||
.iter()
|
||||
.filter(|(row, content)| {
|
||||
row.remote_id.is_empty() && equivalent(item, &content["payload"])
|
||||
})
|
||||
.collect();
|
||||
ensure(
|
||||
pending.len() <= 1,
|
||||
crate::i18n::text("jsm_coverage_conflict"),
|
||||
)?;
|
||||
if let Some((row, content)) = pending.first() {
|
||||
ensure(
|
||||
items
|
||||
.iter()
|
||||
.filter(|candidate| equivalent(candidate, &content["payload"]))
|
||||
.count()
|
||||
== 1,
|
||||
crate::i18n::text("jsm_coverage_conflict"),
|
||||
)?;
|
||||
db.checkpoint(workspace, &row.object_key, alias, content)
|
||||
.await?;
|
||||
} else if overwrite {
|
||||
self.request(
|
||||
Method::DELETE,
|
||||
&format!("/schedules/{remote}/overrides/{alias}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("jsm_coverage_conflict").into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_coverage(
|
||||
&self,
|
||||
db: &Store,
|
||||
workspace: Uuid,
|
||||
schedule: Uuid,
|
||||
remote: &str,
|
||||
desired: &[Value],
|
||||
) -> Result<()> {
|
||||
let prefix = format!("coverage:{schedule}:");
|
||||
let rows = db.objects(workspace).await?;
|
||||
let items = self.list(&format!("/schedules/{remote}/overrides")).await?;
|
||||
let mut used = BTreeSet::new();
|
||||
for payload in desired {
|
||||
let digest = Sha256::digest(serde_json::to_vec(payload)?);
|
||||
let key = format!("{prefix}{digest:x}");
|
||||
used.insert(key.clone());
|
||||
let content = json!({"scheduleId":remote,"payload":payload});
|
||||
let previous = rows.iter().find(|r| r.object_key == key);
|
||||
let alias = previous
|
||||
.filter(|r| !r.remote_id.is_empty())
|
||||
.filter(|r| {
|
||||
serde_json::from_str::<Value>(&r.content)
|
||||
.ok()
|
||||
.is_some_and(|v| v["scheduleId"].as_str() == Some(remote))
|
||||
})
|
||||
.map(|r| r.remote_id.as_str());
|
||||
let existing = alias.and_then(|alias| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item["alias"].as_str() == Some(alias))
|
||||
});
|
||||
let alias = if let (Some(alias), Some(existing)) = (alias, existing) {
|
||||
if !equivalent(existing, payload) {
|
||||
self.request(
|
||||
Method::PUT,
|
||||
&format!("/schedules/{remote}/overrides/{alias}"),
|
||||
Some(payload),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
alias.to_owned()
|
||||
} else {
|
||||
db.checkpoint(workspace, &key, "", &content).await?;
|
||||
let created = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/schedules/{remote}/overrides"),
|
||||
Some(payload),
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into())
|
||||
})?;
|
||||
coverage_alias(&created)?.to_owned()
|
||||
};
|
||||
let actual = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/schedules/{remote}/overrides/{alias}"),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into())
|
||||
})?;
|
||||
ensure(
|
||||
equivalent(&actual, payload),
|
||||
crate::i18n::text("jsm_coverage_response_invalid"),
|
||||
)?;
|
||||
db.checkpoint(workspace, &key, &alias, &content).await?;
|
||||
}
|
||||
// Keep previous effective spans until all replacement writes are verified.
|
||||
for row in rows
|
||||
.iter()
|
||||
.filter(|r| r.object_key.starts_with(&prefix) && !used.contains(&r.object_key))
|
||||
{
|
||||
let content: Value = serde_json::from_str(&row.content)?;
|
||||
if !row.remote_id.is_empty() {
|
||||
let parent = content["scheduleId"]
|
||||
.as_str()
|
||||
.filter(|v| safe_id(v))
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into())
|
||||
})?;
|
||||
ensure(
|
||||
safe_id(&row.remote_id),
|
||||
crate::i18n::text("jsm_coverage_response_invalid"),
|
||||
)?;
|
||||
self.request(
|
||||
Method::DELETE,
|
||||
&format!("/schedules/{parent}/overrides/{}", row.remote_id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
db.forget(workspace, &row.object_key).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
&self,
|
||||
db: &Store,
|
||||
@@ -459,6 +835,9 @@ impl Jsm {
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
for schedule in &plan.schedules {
|
||||
validate_precise_intervals(schedule)?;
|
||||
}
|
||||
// Preflight every recipient before creating or modifying any external object.
|
||||
self.resolve_people(record, plan)
|
||||
.await?
|
||||
@@ -481,8 +860,8 @@ impl Jsm {
|
||||
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 (rotations, coverage) = self.schedule_payloads(s, plan.as_of)?;
|
||||
let body = json!({"name":owned_name(record.id,s.id,&s.name),"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,
|
||||
@@ -492,8 +871,11 @@ impl Jsm {
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
plan.as_of,
|
||||
)
|
||||
.await?;
|
||||
self.publish_coverage(db, record.id, s.id, &remote, &coverage)
|
||||
.await?;
|
||||
schedules.insert(s.id, remote);
|
||||
}
|
||||
for e in &plan.escalations {
|
||||
@@ -520,7 +902,7 @@ impl Jsm {
|
||||
};
|
||||
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 body = json!({"name":owned_name(record.id,e.id,&e.name),"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,
|
||||
@@ -530,6 +912,7 @@ impl Jsm {
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
plan.as_of,
|
||||
)
|
||||
.await?;
|
||||
policies.insert(e.id, remote);
|
||||
@@ -551,7 +934,7 @@ impl Jsm {
|
||||
} 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});
|
||||
let mut body = json!({"name":owned_name(record.id,route.id,&route.name),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify});
|
||||
if let Some(w) = &route.window {
|
||||
body["timezone"] = json!(w.timezone);
|
||||
let days = [
|
||||
@@ -577,6 +960,7 @@ impl Jsm {
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
plan.as_of,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -595,19 +979,21 @@ impl Jsm {
|
||||
.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(),
|
||||
));
|
||||
if kind == "schedule" {
|
||||
let local_id = old
|
||||
.object_key
|
||||
.split_once(':')
|
||||
.and_then(|(_, id)| Uuid::parse_str(id).ok())
|
||||
.unwrap();
|
||||
self.check_coverage(
|
||||
db,
|
||||
record.id,
|
||||
local_id,
|
||||
&old.remote_id,
|
||||
overwrite,
|
||||
plan.as_of,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
self.request(
|
||||
Method::DELETE,
|
||||
@@ -617,14 +1003,84 @@ impl Jsm {
|
||||
.await?;
|
||||
}
|
||||
db.forget(record.id, &old.object_key).await?;
|
||||
db.forget(record.id, &format!("pending:{}", old.object_key))
|
||||
.await?;
|
||||
if kind == "schedule" {
|
||||
let prefix = format!(
|
||||
"coverage:{}:",
|
||||
old.object_key.strip_prefix("schedule:").unwrap()
|
||||
);
|
||||
for child in db
|
||||
.objects(record.id)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|o| o.object_key.starts_with(&prefix))
|
||||
{
|
||||
db.forget(record.id, &child.object_key).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_name(team: Uuid, id: Uuid) -> String {
|
||||
format!("meguri-{}-{}", team.simple(), id.simple())
|
||||
fn milliseconds(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
DateTime::from_timestamp_millis(value.timestamp_millis()).unwrap()
|
||||
}
|
||||
|
||||
fn half_hour_floor(value: DateTime<Utc>, zone: chrono_tz::Tz) -> DateTime<Utc> {
|
||||
let local = value.with_timezone(&zone);
|
||||
value
|
||||
- chrono::Duration::minutes(i64::from(local.minute() % 30))
|
||||
- chrono::Duration::seconds(i64::from(local.second()))
|
||||
- chrono::Duration::nanoseconds(i64::from(local.nanosecond()))
|
||||
}
|
||||
|
||||
fn needs_precise_coverage(schedule: &super::PublishedSchedule) -> Result<bool> {
|
||||
let zone = schedule
|
||||
.timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()))?;
|
||||
Ok(schedule.periods.iter().any(|p| {
|
||||
[p.start, p.end].iter().any(|date| {
|
||||
let local = date.with_timezone(&zone);
|
||||
local.minute() % 30 != 0 || local.second() != 0 || local.nanosecond() != 0
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_precise_intervals(schedule: &super::PublishedSchedule) -> Result<()> {
|
||||
ensure(
|
||||
!schedule.periods.is_empty()
|
||||
&& schedule
|
||||
.periods
|
||||
.iter()
|
||||
.all(|p| milliseconds(p.start) < milliseconds(p.end)),
|
||||
crate::i18n::text("jsm_invalid_coverage_interval"),
|
||||
)
|
||||
}
|
||||
|
||||
fn coverage_alias(value: &Value) -> Result<&str> {
|
||||
value["alias"]
|
||||
.as_str()
|
||||
.filter(|v| safe_id(v))
|
||||
.ok_or_else(|| Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()))
|
||||
}
|
||||
|
||||
fn ownership_marker(workspace: Uuid, id: Uuid) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(workspace.as_bytes());
|
||||
digest.update(id.as_bytes());
|
||||
let digest = digest.finalize();
|
||||
format!(
|
||||
"[meguri:{:032x}]",
|
||||
u128::from_be_bytes(digest[..16].try_into().unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
fn owned_name(workspace: Uuid, id: Uuid, name: &str) -> String {
|
||||
format!("{name} {}", ownership_marker(workspace, id))
|
||||
}
|
||||
fn safe_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
@@ -646,9 +1102,45 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
expected
|
||||
.iter()
|
||||
.all(|(k, v)| equivalent(actual.get(k).unwrap_or(&Value::Null), v))
|
||||
if expected.contains_key("responder")
|
||||
&& expected.contains_key("startDate")
|
||||
&& !expected.contains_key("rotationIds")
|
||||
&& actual.get("rotationIds").is_some_and(|ids| {
|
||||
!ids.is_null() && ids.as_array().is_none_or(|ids| !ids.is_empty())
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
expected.iter().all(|(k, v)| {
|
||||
if k == "repeat"
|
||||
&& expected.contains_key("rules")
|
||||
&& actual.get(k).is_none()
|
||||
&& v.get("waitInterval").and_then(Value::as_u64) == Some(0)
|
||||
{
|
||||
// JSM omits disabled repeats in escalation GET responses.
|
||||
true
|
||||
} else if k == "rotations" {
|
||||
let Some(actual) = actual.get(k).and_then(Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(expected) = v.as_array() else {
|
||||
return false;
|
||||
};
|
||||
let mut remaining: Vec<_> = actual.iter().collect();
|
||||
for expected in expected {
|
||||
let Some(index) = remaining
|
||||
.iter()
|
||||
.position(|actual| equivalent(actual, expected))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
remaining.remove(index);
|
||||
}
|
||||
remaining.is_empty()
|
||||
} else {
|
||||
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))
|
||||
@@ -670,6 +1162,34 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn omitted_escalation_repeat_only_matches_disabled_repeats() {
|
||||
let mut expected = json!({"rules":[{"delay":0}],"repeat":{"count":1,"waitInterval":0,"resetRecipientStates":false,"closeAlertAfterAll":false}});
|
||||
let mut remote = json!({"rules":[{"delay":0}]});
|
||||
assert!(equivalent(&remote, &expected));
|
||||
remote["rules"][0]["delay"] = json!(10);
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
remote["rules"][0]["delay"] = json!(0);
|
||||
expected["repeat"]["waitInterval"] = json!(5);
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
remote["repeat"] = expected["repeat"].clone();
|
||||
assert!(equivalent(&remote, &expected));
|
||||
for (field, changed) in [
|
||||
("count", json!(2)),
|
||||
("waitInterval", json!(10)),
|
||||
("resetRecipientStates", json!(true)),
|
||||
("closeAlertAfterAll", json!(true)),
|
||||
] {
|
||||
let mut changed_remote = remote.clone();
|
||||
changed_remote["repeat"][field] = changed;
|
||||
assert!(!equivalent(&changed_remote, &expected));
|
||||
}
|
||||
expected["repeat"]["waitInterval"] = json!(0);
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
remote["repeat"] = Value::Null;
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
}
|
||||
|
||||
#[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"}]}]});
|
||||
|
||||
@@ -15,11 +15,14 @@ pub struct PublishedSchedule {
|
||||
pub name: String,
|
||||
pub timezone: String,
|
||||
pub periods: Vec<Assignment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring: Option<RecurringRotation>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Publication {
|
||||
pub workspace_id: Uuid,
|
||||
pub revision: i64,
|
||||
pub as_of: DateTime<Utc>,
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
pub schedules: Vec<PublishedSchedule>,
|
||||
@@ -149,6 +152,7 @@ pub fn compile(
|
||||
id: schedule.id,
|
||||
name: schedule.name.clone(),
|
||||
timezone: record.document.timezone.clone(),
|
||||
recurring: crate::engine::recurring_rotation(&record.document, schedule, &periods)?,
|
||||
periods,
|
||||
});
|
||||
}
|
||||
@@ -156,6 +160,7 @@ pub fn compile(
|
||||
Ok(Publication {
|
||||
workspace_id: record.id,
|
||||
revision: record.revision,
|
||||
as_of: now,
|
||||
from: timeline.from,
|
||||
to: timeline.to,
|
||||
schedules,
|
||||
@@ -230,6 +235,45 @@ pub async fn publish(
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn jsm_team_identifier_preserves_operations_prefix_and_rejects_invalid_paths() {
|
||||
let cloud = Uuid::new_v4().to_string();
|
||||
let team = Uuid::new_v4().to_string();
|
||||
for valid_team in [team.clone(), format!("og-{team}")] {
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
doc.integration = Integration::Jsm {
|
||||
cloud_id: cloud.clone(),
|
||||
team_id: valid_team.clone(),
|
||||
advanced_escalations: false,
|
||||
users: Default::default(),
|
||||
};
|
||||
doc.validate(&Default::default()).unwrap();
|
||||
let saved: WorkspaceDocument =
|
||||
serde_json::from_value(serde_json::to_value(&doc).unwrap()).unwrap();
|
||||
assert!(
|
||||
matches!(saved.integration, Integration::Jsm { team_id, .. } if team_id == valid_team)
|
||||
);
|
||||
for invalid in [
|
||||
"og-".to_owned(),
|
||||
format!("og-og-{team}"),
|
||||
format!("og-{team}/routing-rules"),
|
||||
format!("og-{team}?x=1"),
|
||||
] {
|
||||
if let Integration::Jsm { team_id, .. } = &mut doc.integration {
|
||||
*team_id = invalid;
|
||||
}
|
||||
assert!(doc.validate(&Default::default()).is_err());
|
||||
}
|
||||
if let Integration::Jsm {
|
||||
cloud_id, team_id, ..
|
||||
} = &mut doc.integration
|
||||
{
|
||||
*team_id = valid_team;
|
||||
*cloud_id = format!("og-{cloud}");
|
||||
}
|
||||
assert!(doc.validate(&Default::default()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_escalation_is_not_silently_dropped() {
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
doc.integration = Integration::Jsm {
|
||||
|
||||
+124
-74
@@ -38,10 +38,23 @@ pub async fn hash_password(password: String) -> Result<String> {
|
||||
.map_err(|e| Error::Internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub fn normalize_email(value: &str) -> Result<String> {
|
||||
let email = value.trim().to_lowercase();
|
||||
ensure(
|
||||
email.len() <= 254
|
||||
&& email.split('@').count() == 2
|
||||
&& !email.chars().any(char::is_whitespace)
|
||||
&& email_address::EmailAddress::is_valid(&email),
|
||||
crate::i18n::text("err_enter_a_valid_email_address_1577ea"),
|
||||
)?;
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
db: &Store,
|
||||
username: String,
|
||||
display_name: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<User> {
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
@@ -53,14 +66,20 @@ pub async fn register(
|
||||
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
|
||||
)?;
|
||||
validate_name(&display_name)?;
|
||||
let email = normalize_email(&email)?;
|
||||
let hash = hash_password(password).await?;
|
||||
let id = Uuid::new_v4();
|
||||
let user = User {
|
||||
language: None,
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
display_name,
|
||||
country: "CY".into(),
|
||||
timezone: None,
|
||||
person: Person {
|
||||
id,
|
||||
user_id: Some(id),
|
||||
display_name,
|
||||
email: Some(email.clone()),
|
||||
country: Some("CY".into()),
|
||||
},
|
||||
};
|
||||
let result = UserRow {
|
||||
language: None,
|
||||
@@ -68,7 +87,8 @@ pub async fn register(
|
||||
id: user.id.to_string(),
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
country: user.country.clone(),
|
||||
country: user.country.clone().unwrap_or_else(|| "CY".into()),
|
||||
email,
|
||||
timezone: None,
|
||||
password_hash: hash,
|
||||
created_at: Utc::now(),
|
||||
@@ -77,7 +97,7 @@ pub async fn register(
|
||||
.await;
|
||||
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
|
||||
crate::i18n::text("account_identity_taken").into(),
|
||||
));
|
||||
}
|
||||
result?;
|
||||
@@ -116,6 +136,7 @@ pub async fn login(
|
||||
}
|
||||
let row = row.unwrap();
|
||||
let user = store::user(row)?;
|
||||
crate::site::active(db, user.id).await?;
|
||||
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||
let csrf = Uuid::new_v4().to_string();
|
||||
SessionRow {
|
||||
@@ -132,7 +153,13 @@ pub async fn login(
|
||||
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);
|
||||
let mut doc = WorkspaceDocument::new(name);
|
||||
let key = actor.to_string();
|
||||
let account = query!(UserRow, $id == key)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
doc.people.push(store::user(account)?.person);
|
||||
db.db
|
||||
.raw_with(
|
||||
CREATE_WORKSPACE,
|
||||
@@ -199,6 +226,7 @@ pub async fn update_document(
|
||||
)?;
|
||||
}
|
||||
}
|
||||
store::account_people(&mut document, &db.directory(workspace).await?)?;
|
||||
let interval = db.working_interval(workspace).await?;
|
||||
document.timezone = interval.timezone.clone();
|
||||
for route in &mut document.routes {
|
||||
@@ -487,24 +515,13 @@ async fn prepare_absence(
|
||||
}
|
||||
record.document.people.push(person);
|
||||
}
|
||||
store::account_people(&mut record.document, &db.directory(workspace).await?)?;
|
||||
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(),
|
||||
);
|
||||
for legs in &plan.groups {
|
||||
if let Err(error) = validate_approval_access(&record.document, legs, &participants) {
|
||||
plan.errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,7 +535,9 @@ pub async fn preview_absence(
|
||||
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}))
|
||||
let members = db.member_ids(workspace).await?;
|
||||
let approval_groups:Vec<_>=plan.groups.iter().map(|legs|json!({"legs":legs,"required_approvers":if input.force {BTreeSet::new()} else {required_approvers(actor,legs,&members)}})).collect();
|
||||
Ok(json!({"revision":record.revision,"plan":plan,"approval_groups":approval_groups}))
|
||||
}
|
||||
|
||||
pub async fn create_absence(
|
||||
@@ -538,6 +557,7 @@ pub async fn create_absence(
|
||||
record.document.requests.len() + plan.groups.len() <= 5000,
|
||||
crate::i18n::text("err_replacement_history_limit_reached_3b1168"),
|
||||
)?;
|
||||
let members = db.member_ids(workspace).await?;
|
||||
record.document.absences.push(input.draft.absence.clone());
|
||||
for legs in &plan.groups {
|
||||
let mut request =
|
||||
@@ -545,20 +565,18 @@ pub async fn create_absence(
|
||||
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 {
|
||||
request.required_approvers = required_approvers(actor, legs, &members);
|
||||
let apply = input.force || request.required_approvers.is_empty();
|
||||
request.status = if apply {
|
||||
RequestStatus::Applied
|
||||
} else {
|
||||
RequestStatus::Pending
|
||||
};
|
||||
request.applied_at = input.force.then_some(now);
|
||||
request.applied_at = apply.then_some(now);
|
||||
request.forced_by = input.force.then_some(actor);
|
||||
request.force_reason = input.force.then_some(input.reason.clone());
|
||||
if input.force {
|
||||
if apply {
|
||||
crate::absence::validate_request(&record.document, &request, &members, now)?;
|
||||
engine::supersede_overrides(&mut record.document, &request.legs);
|
||||
}
|
||||
record.document.requests.push(request);
|
||||
@@ -577,11 +595,7 @@ pub async fn request_replacement(
|
||||
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 permission = replacement_permission(actor, &input);
|
||||
let mut record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
@@ -593,14 +607,12 @@ pub async fn request_replacement(
|
||||
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();
|
||||
validate_request_participation(actor, &input, &record.document)?;
|
||||
if !input.force {
|
||||
validate_approval_access(&record.document, &input.legs, &members)?;
|
||||
}
|
||||
let required_approvers = required_approvers(actor, &input.legs, &members);
|
||||
let apply = input.force || required_approvers.is_empty();
|
||||
let request = ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
@@ -611,17 +623,17 @@ pub async fn request_replacement(
|
||||
legs: input.legs,
|
||||
required_approvers,
|
||||
approvals: BTreeSet::new(),
|
||||
status: if input.force {
|
||||
status: if apply {
|
||||
RequestStatus::Applied
|
||||
} else {
|
||||
RequestStatus::Pending
|
||||
},
|
||||
applied_at: input.force.then_some(now),
|
||||
applied_at: apply.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 {
|
||||
if apply {
|
||||
engine::supersede_overrides(&mut record.document, &request.legs);
|
||||
}
|
||||
record.document.requests.push(request);
|
||||
@@ -641,25 +653,58 @@ pub async fn request_replacement(
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn validate_request_participation(actor: Uuid, input: &NewRequest) -> Result<()> {
|
||||
fn planning_contact(doc: &WorkspaceDocument, id: Uuid) -> bool {
|
||||
doc.person(id).is_some_and(|p| p.user_id.is_none())
|
||||
}
|
||||
fn required_approvers(
|
||||
actor: Uuid,
|
||||
legs: &[ReplacementLeg],
|
||||
members: &BTreeSet<Uuid>,
|
||||
) -> BTreeSet<Uuid> {
|
||||
legs.iter()
|
||||
.flat_map(|l| [l.from_user, l.to_user])
|
||||
.filter(|id| *id != actor && members.contains(id))
|
||||
.collect()
|
||||
}
|
||||
fn replacement_permission(actor: Uuid, input: &NewRequest) -> Permission {
|
||||
if input.force {
|
||||
Permission::ForceOverrides
|
||||
} else if input
|
||||
.legs
|
||||
.iter()
|
||||
.any(|l| l.from_user != actor && l.to_user != actor)
|
||||
{
|
||||
Permission::EditSchedules
|
||||
} else {
|
||||
Permission::RequestOverrides
|
||||
}
|
||||
}
|
||||
fn validate_request_participation(
|
||||
actor: Uuid,
|
||||
input: &NewRequest,
|
||||
doc: &WorkspaceDocument,
|
||||
) -> Result<()> {
|
||||
ensure(
|
||||
input.force
|
||||
|| input
|
||||
.legs
|
||||
.iter()
|
||||
.all(|leg| leg.from_user == actor || leg.to_user == actor),
|
||||
|| input.legs.iter().all(|leg| {
|
||||
leg.from_user == actor
|
||||
|| leg.to_user == actor
|
||||
|| planning_contact(doc, leg.from_user)
|
||||
|| planning_contact(doc, leg.to_user)
|
||||
}),
|
||||
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<()> {
|
||||
fn validate_approval_access(
|
||||
doc: &WorkspaceDocument,
|
||||
legs: &[ReplacementLeg],
|
||||
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"),
|
||||
legs.iter()
|
||||
.flat_map(|l| [l.from_user, l.to_user])
|
||||
.all(|id| members.contains(&id) || planning_contact(doc, id)),
|
||||
crate::i18n::text("replacement_account_access"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -678,17 +723,15 @@ pub async fn preview_replacement(
|
||||
workspace: Uuid,
|
||||
mut input: NewRequest,
|
||||
) -> Result<serde_json::Value> {
|
||||
let permission = if input.force {
|
||||
Permission::ForceOverrides
|
||||
} else {
|
||||
Permission::RequestOverrides
|
||||
};
|
||||
let permission = replacement_permission(actor, &input);
|
||||
let record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
validate_request_participation(actor, &input)?;
|
||||
validate_request_participation(actor, &input, &record.document)?;
|
||||
let members = db.member_ids(workspace).await?;
|
||||
validate_request_access(&input, &members)?;
|
||||
if !input.force {
|
||||
validate_approval_access(&record.document, &input.legs, &members)?;
|
||||
}
|
||||
ensure(
|
||||
input.reason.len() <= 1000,
|
||||
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
|
||||
@@ -696,7 +739,14 @@ pub async fn preview_replacement(
|
||||
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}))
|
||||
let approvers = if input.force {
|
||||
BTreeSet::new()
|
||||
} else {
|
||||
required_approvers(actor, &input.legs, &members)
|
||||
};
|
||||
Ok(
|
||||
serde_json::json!({"legs": input.legs,"required_approvers":approvers,"applies_immediately":approvers.is_empty()}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -950,7 +1000,7 @@ WITH workspace AS (
|
||||
), 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__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'draft',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
|
||||
"#;
|
||||
@@ -960,16 +1010,16 @@ WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KE
|
||||
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)
|
||||
COALESCE((SELECT jsonb_agg(CASE WHEN p->>'user_id'=$1 THEN p||jsonb_build_object('country',$3::text,'display_name',$2::text,'email',(SELECT email FROM changed)) 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',(SELECT email FROM changed),'country',$3::text)) END)::text
|
||||
WHERE EXISTS(SELECT 1 FROM previous JOIN changed USING(id) WHERE previous.country<>changed.country OR previous.display_name<>changed.display_name)
|
||||
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__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM changed_workspaces
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision
|
||||
)
|
||||
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
|
||||
SELECT id,$1,'person.profile_changed',jsonb_build_object('user_id',$1::text,'display_name',$2::text,'country',$3::text)::text,(SELECT effective_at FROM history WHERE workspace_id=changed_workspaces.id) FROM changed_workspaces
|
||||
"#;
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
//! Explicit installation administration; never an ambient workspace grant.
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result, ensure},
|
||||
models::*,
|
||||
store::{Store, user},
|
||||
};
|
||||
use cot::db::{Model, query};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthenticationSettings {
|
||||
pub oidc_enabled: bool,
|
||||
pub password_enabled: bool,
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
pub client_secret_env: String,
|
||||
pub button_label: String,
|
||||
pub allow_new_users: bool,
|
||||
}
|
||||
pub fn invalid() -> Error {
|
||||
Error::Validation(crate::i18n::text("site_invalid").into())
|
||||
}
|
||||
pub async fn settings(db: &Store) -> Result<(i64, AuthenticationSettings)> {
|
||||
let row = query!(SiteSettingsRow,$section=="authentication")
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
Ok((row.revision, serde_json::from_str(&row.payload)?))
|
||||
}
|
||||
pub async fn access(db: &Store, id: Uuid) -> Result<SiteAccessRow> {
|
||||
let id = id.to_string();
|
||||
Ok(query!(SiteAccessRow,$user_id==id.clone())
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.unwrap_or(SiteAccessRow {
|
||||
user_id: id,
|
||||
administrator: false,
|
||||
disabled: false,
|
||||
revision: 0,
|
||||
}))
|
||||
}
|
||||
pub async fn active(db: &Store, id: Uuid) -> Result<()> {
|
||||
if access(db, id).await?.disabled {
|
||||
Err(Error::Unauthorized)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub async fn require(db: &Store, id: Uuid) -> Result<()> {
|
||||
let a = access(db, id).await?;
|
||||
if a.administrator && !a.disabled {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Forbidden)
|
||||
}
|
||||
}
|
||||
pub async fn bootstrap(db: &Store, username: &str) -> Result<()> {
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
let row = query!(UserRow,$username==username)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.filter(|r| r.demo_deleted_at.is_none())
|
||||
.ok_or_else(invalid)?;
|
||||
db.db.raw_with(r#"WITH changed AS (INSERT INTO meguri__site_access(user_id,administrator) VALUES($1,true) ON CONFLICT(user_id) DO UPDATE SET administrator=true,disabled=false,revision=meguri__site_access.revision+1 WHERE NOT meguri__site_access.administrator OR meguri__site_access.disabled RETURNING user_id), version AS(UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND EXISTS(SELECT 1 FROM changed)) INSERT INTO meguri__site_audit(actor_id,action,details) SELECT NULL,'site.bootstrap',json_build_object('user_id',user_id)::text FROM changed"#,&[&row.id]).await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn overview(db: &Store, actor: Uuid) -> Result<Value> {
|
||||
require(db, actor).await?;
|
||||
let (revision, authentication) = settings(db).await?;
|
||||
let control = query!(SiteSettingsRow,$section=="control")
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let access_rows: std::collections::BTreeMap<_, _> = SiteAccessRow::objects()
|
||||
.all(&db.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| (a.user_id.clone(), a))
|
||||
.collect();
|
||||
let member_rows = WorkspaceMemberRow::objects().all(&db.db).await?;
|
||||
let mut users = vec![];
|
||||
for row in UserRow::objects().all(&db.db).await? {
|
||||
if row.demo_deleted_at.is_none() {
|
||||
let u = user(row)?;
|
||||
let a = access_rows
|
||||
.get(&u.id.to_string())
|
||||
.cloned()
|
||||
.unwrap_or(SiteAccessRow {
|
||||
user_id: u.id.to_string(),
|
||||
administrator: false,
|
||||
disabled: false,
|
||||
revision: 0,
|
||||
});
|
||||
users.push(json!({"user":u,"access":a_value(&a)}));
|
||||
}
|
||||
}
|
||||
users.sort_by_key(|v| {
|
||||
v["user"]["username"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
});
|
||||
let mut workspaces = vec![];
|
||||
for w in WorkspaceRow::objects().all(&db.db).await? {
|
||||
if w.demo_deleted_at.is_none() {
|
||||
let d: WorkspaceDocument = serde_json::from_str(&w.document)?;
|
||||
let id = w.id.clone();
|
||||
let members: Vec<_> = member_rows
|
||||
.iter()
|
||||
.filter(|m| m.workspace_id == id)
|
||||
.collect();
|
||||
workspaces.push(json!({"id":w.id,"name":d.name,"revision":w.revision,"owners":members.iter().filter(|m|m.is_owner).map(|m|m.user_id.clone()).collect::<Vec<_>>(),"members":members.len()}));
|
||||
}
|
||||
}
|
||||
workspaces.sort_by_key(|v| v["name"].as_str().unwrap_or_default().to_owned());
|
||||
let audit = SiteAuditRow::objects().all(&db.db).await?;
|
||||
let mut audit:Vec<_>=audit.into_iter().map(|a|json!({"id":a.id,"actor_id":a.actor_id,"action":a.action,"details":serde_json::from_str::<Value>(&a.details).unwrap_or(Value::Null),"created_at":a.created_at})).collect();
|
||||
audit.sort_by(|a, b| b["created_at"].as_str().cmp(&a["created_at"].as_str()));
|
||||
audit.truncate(100);
|
||||
Ok(
|
||||
json!({"revision":control.revision,"users":users,"workspaces":workspaces,"audit":audit,"settings":{"authentication":{"revision":revision,"value":authentication}},"authentication_applied":false}),
|
||||
)
|
||||
}
|
||||
fn a_value(a: &SiteAccessRow) -> Value {
|
||||
json!({"administrator":a.administrator,"disabled":a.disabled,"revision":a.revision})
|
||||
}
|
||||
// Every mutation checks the same control revision. Concurrent site writes cannot
|
||||
// both succeed from a stale administrator/last-admin snapshot.
|
||||
const GATE: &str = r#"WITH gate AS (UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND revision=$2 AND EXISTS(SELECT 1 FROM meguri__site_access WHERE user_id=$1 AND administrator AND NOT disabled) RETURNING revision)"#;
|
||||
pub async fn mutate(db: &Store, actor: Uuid, action: &str, body: Value) -> Result<()> {
|
||||
require(db, actor).await?;
|
||||
let rev = body["revision"].as_i64().ok_or_else(invalid)?;
|
||||
let actor = actor.to_string();
|
||||
let id = body["id"].as_str().unwrap_or_default().to_owned();
|
||||
let (sql, details) = match action {
|
||||
"user" => {
|
||||
let target = query!(UserRow,$id==id.clone())
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.filter(|u| u.demo_deleted_at.is_none())
|
||||
.ok_or(Error::NotFound)?;
|
||||
let name = body["display_name"].as_str().ok_or_else(invalid)?;
|
||||
validate_name(name)?;
|
||||
let admin = body["administrator"].as_bool().ok_or_else(invalid)?;
|
||||
let disabled = body["disabled"].as_bool().ok_or_else(invalid)?;
|
||||
if !admin || disabled {
|
||||
let active_ids: std::collections::BTreeSet<_> = UserRow::objects()
|
||||
.all(&db.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|u| u.demo_deleted_at.is_none())
|
||||
.map(|u| u.id)
|
||||
.collect();
|
||||
let active_admins = SiteAccessRow::objects()
|
||||
.all(&db.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|a| a.administrator && !a.disabled && active_ids.contains(&a.user_id))
|
||||
.collect::<Vec<_>>();
|
||||
ensure(
|
||||
!active_admins.iter().any(|a| a.user_id == target.id)
|
||||
|| active_admins.len() > 1,
|
||||
crate::i18n::text("site_last_admin"),
|
||||
)?;
|
||||
}
|
||||
(
|
||||
r#", changed AS (INSERT INTO meguri__site_access(user_id,administrator,disabled) SELECT $3,($4::jsonb->>'administrator')::boolean,($4::jsonb->>'disabled')::boolean FROM gate ON CONFLICT(user_id) DO UPDATE SET administrator=EXCLUDED.administrator,disabled=EXCLUDED.disabled,revision=meguri__site_access.revision+1 RETURNING user_id), profile AS(UPDATE meguri__users SET display_name=$4::jsonb->>'display_name' WHERE id IN(SELECT user_id FROM changed) RETURNING *), snapshots AS(
|
||||
UPDATE meguri__workspaces w SET revision=w.revision+1,document=jsonb_set(w.document::jsonb,'{people}',
|
||||
(SELECT jsonb_agg(CASE WHEN p->>'user_id'=u.id THEN p||jsonb_build_object('display_name',u.display_name) ELSE p END)
|
||||
FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p))::text
|
||||
FROM profile u WHERE EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p WHERE p->>'user_id'=u.id AND p->>'display_name' IS DISTINCT FROM u.display_name) RETURNING w.*
|
||||
), profile_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 snapshots),
|
||||
profile_jobs AS(UPDATE meguri__sync_jobs j SET requested_revision=s.revision FROM snapshots s WHERE j.workspace_id=s.id),
|
||||
profile_audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'person.profile_changed',$4,clock_timestamp() FROM snapshots), sessions AS(DELETE FROM meguri__sessions WHERE user_id IN(SELECT user_id FROM changed) AND ($4::jsonb->>'disabled')::boolean)"#,
|
||||
json!({"display_name":name,"administrator":admin,"disabled":disabled}),
|
||||
)
|
||||
}
|
||||
"workspace" => {
|
||||
let workspace = db
|
||||
.workspace(Uuid::parse_str(&id).map_err(|_| invalid())?)
|
||||
.await?;
|
||||
ensure(
|
||||
body["workspace_revision"].as_i64() == Some(workspace.revision),
|
||||
crate::i18n::text("site_stale"),
|
||||
)?;
|
||||
let name = body["name"].as_str().ok_or_else(invalid)?;
|
||||
validate_name(name)?;
|
||||
let owner = body["owner_id"].as_str().ok_or_else(invalid)?.to_owned();
|
||||
let owner_row = query!(UserRow,$id==owner.clone())
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.filter(|u| u.demo_deleted_at.is_none())
|
||||
.ok_or_else(invalid)?;
|
||||
active(db, Uuid::parse_str(&owner_row.id).map_err(|_| invalid())?).await?;
|
||||
let mut document = workspace.document.clone();
|
||||
crate::store::account_people(&mut document, &[user(owner_row)?])?;
|
||||
let mut members = db.member_ids(workspace.id).await?;
|
||||
members.insert(Uuid::parse_str(&owner).map_err(|_| invalid())?);
|
||||
document.validate(&members)?;
|
||||
document.name = name.into();
|
||||
// Add an owner explicitly, retaining existing owners and workspace grants.
|
||||
(
|
||||
r#", workspace AS(UPDATE meguri__workspaces SET document=($4::jsonb->'document')::text,revision=revision+1 WHERE id=$3 AND revision=($4::jsonb->>'workspace_revision')::bigint AND EXISTS(SELECT 1 FROM gate) RETURNING *), jobs AS(INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM workspace ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision), changed AS(INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT $4::jsonb->>'member_id',$3,$4::jsonb->>'owner_id',$4::jsonb->>'permissions',true FROM workspace ON CONFLICT(workspace_id,user_id) DO UPDATE SET is_owner=true RETURNING user_id), history AS(INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT $4::jsonb->>'history_id',id,revision,document,now(),$1 FROM workspace), audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'site.workspace_updated',$4,now() FROM workspace)"#,
|
||||
json!({"document":document,"name":name,"owner_id":owner,"workspace_revision":workspace.revision,"member_id":format!("{id}:{owner}"),"history_id":format!("{id}:{}",workspace.revision+1),"permissions":serde_json::to_string(&Permission::all())?}),
|
||||
)
|
||||
}
|
||||
"authentication" => {
|
||||
let cfg: AuthenticationSettings =
|
||||
serde_json::from_value(body["value"].clone()).map_err(|_| invalid())?;
|
||||
ensure(
|
||||
cfg.button_label.chars().count() <= 80
|
||||
&& cfg.client_id.len() <= 500
|
||||
&& cfg.issuer.len() <= 2048,
|
||||
crate::i18n::text("site_invalid"),
|
||||
)?;
|
||||
if cfg.oidc_enabled {
|
||||
ensure(
|
||||
!cfg.client_id.trim().is_empty() && !cfg.button_label.trim().is_empty(),
|
||||
crate::i18n::text("site_invalid"),
|
||||
)?;
|
||||
}
|
||||
if !cfg.issuer.is_empty() || cfg.oidc_enabled {
|
||||
let url = reqwest::Url::parse(&cfg.issuer).map_err(|_| invalid())?;
|
||||
ensure(
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none(),
|
||||
crate::i18n::text("site_invalid"),
|
||||
)?;
|
||||
}
|
||||
ensure(
|
||||
cfg.password_enabled || cfg.oidc_enabled,
|
||||
crate::i18n::text("site_auth_method"),
|
||||
)?;
|
||||
ensure(
|
||||
cfg.client_secret_env.starts_with("MEGURI_OIDC_")
|
||||
&& cfg
|
||||
.client_secret_env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
|
||||
crate::i18n::text("site_invalid"),
|
||||
)?;
|
||||
let (oldrev, _) = settings(db).await?;
|
||||
ensure(
|
||||
body["section_revision"].as_i64() == Some(oldrev),
|
||||
crate::i18n::text("site_stale"),
|
||||
)?;
|
||||
(
|
||||
r#", changed AS(UPDATE meguri__site_settings SET payload=($4::jsonb->'value')::text,revision=revision+1 WHERE section='authentication' AND EXISTS(SELECT 1 FROM gate) RETURNING section)"#,
|
||||
json!({"value":cfg}),
|
||||
)
|
||||
}
|
||||
_ => return Err(Error::NotFound),
|
||||
};
|
||||
let sql = format!(
|
||||
"{GATE}{sql} INSERT INTO meguri__site_audit(actor_id,action,details) SELECT $1,$5,jsonb_build_object('target',$3,'change',$4::jsonb)::text FROM changed"
|
||||
);
|
||||
let result = db
|
||||
.db
|
||||
.raw_with(
|
||||
&sql,
|
||||
&[
|
||||
&actor,
|
||||
&rev,
|
||||
&id,
|
||||
&details.to_string(),
|
||||
&format!("site.{action}"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if result.rows_affected().0 == 0 {
|
||||
return Err(Error::Conflict(crate::i18n::text("site_stale").into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+179
-23
@@ -30,13 +30,37 @@ pub fn uuid(value: &str) -> Result<Uuid> {
|
||||
pub fn user(row: UserRow) -> Result<User> {
|
||||
Ok(User {
|
||||
language: row.language,
|
||||
id: uuid(&row.id)?,
|
||||
person: Person {
|
||||
id: uuid(&row.id)?,
|
||||
user_id: Some(uuid(&row.id)?),
|
||||
display_name: row.display_name,
|
||||
email: Some(row.email),
|
||||
country: Some(row.country),
|
||||
},
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
country: row.country,
|
||||
timezone: row.timezone,
|
||||
})
|
||||
}
|
||||
/// Workspace copies of account people are immutable identity snapshots.
|
||||
pub fn account_people(document: &mut WorkspaceDocument, users: &[User]) -> Result<()> {
|
||||
for user in users {
|
||||
if let Some(person) = document.people.iter_mut().find(|p| p.id == user.id) {
|
||||
crate::error::ensure(
|
||||
person.user_id == Some(user.id)
|
||||
&& person
|
||||
.email
|
||||
.as_ref()
|
||||
.is_none_or(|e| Some(e) == user.email.as_ref()),
|
||||
crate::i18n::text("account_person_authoritative"),
|
||||
)?;
|
||||
*person = user.person.clone();
|
||||
} else {
|
||||
document.people.push(user.person.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
|
||||
if row.demo_deleted_at.is_some() {
|
||||
return Err(Error::NotFound);
|
||||
@@ -177,6 +201,22 @@ impl Store {
|
||||
details: Value,
|
||||
member: Option<(Uuid, Vec<Permission>)>,
|
||||
) -> Result<()> {
|
||||
let mut users = self.directory(record.id).await?;
|
||||
if let Some((id, _)) = &member {
|
||||
let key = id.to_string();
|
||||
if !users.iter().any(|u| u.id == *id) {
|
||||
users.push(user(
|
||||
query!(UserRow, $id == key)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
account_people(&mut record.document, &users)?;
|
||||
record
|
||||
.document
|
||||
.validate(&users.iter().map(|u| u.id).collect())?;
|
||||
let workspace = record.id.to_string();
|
||||
let actor = actor.to_string();
|
||||
let document = serde_json::to_string(&record.document)?;
|
||||
@@ -213,7 +253,7 @@ impl Store {
|
||||
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);
|
||||
names.insert(person.id.to_string(), person.display_name.clone());
|
||||
}
|
||||
let current = self.workspace(workspace).await?;
|
||||
crate::audit::remember(&mut names, ¤t.document);
|
||||
@@ -260,20 +300,79 @@ impl Store {
|
||||
output.sort_by_key(|e| std::cmp::Reverse(e["id"].as_i64().unwrap_or_default()));
|
||||
Ok(output)
|
||||
}
|
||||
pub async fn published_document(&self, workspace: Uuid) -> Result<Option<WorkspaceDocument>> {
|
||||
let id = workspace.to_string();
|
||||
query!(SyncJobRow, $workspace_id == id)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.and_then(|r| r.published_document)
|
||||
.map(|s| serde_json::from_str(&s).map_err(Into::into))
|
||||
.transpose()
|
||||
}
|
||||
pub async fn sync_status(&self, workspace: Uuid) -> Result<Value> {
|
||||
let workspace = workspace.to_string();
|
||||
let record = self.workspace(workspace).await?;
|
||||
let baseline = self.published_document(workspace).await?;
|
||||
let changed = serde_json::to_value(&record.document)? != serde_json::to_value(&baseline)?;
|
||||
let id = workspace.to_string();
|
||||
let mut status = match query!(SyncJobRow, $workspace_id == id)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
{
|
||||
None => json!({"state":"draft"}),
|
||||
Some(r) => {
|
||||
let publication_busy = r.lease_until.is_some_and(|until| until > Utc::now());
|
||||
json!({"publication_busy":publication_busy,"lease_until":r.lease_until,"requested_revision":r.requested_revision,"authorized_revision":r.authorized_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})
|
||||
}
|
||||
};
|
||||
status["has_changes"] = json!(changed);
|
||||
status["changes"] = publication_changes(&record.document, baseline.as_ref())?;
|
||||
Ok(status)
|
||||
}
|
||||
pub async fn shared_draft(&self, workspace: Uuid) -> Result<Value> {
|
||||
let id = workspace.to_string();
|
||||
Ok(
|
||||
match query!(SyncJobRow,$workspace_id==workspace)
|
||||
match query!(WorkspaceDraftRow, $workspace_id == id)
|
||||
.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})
|
||||
Some(row) => {
|
||||
json!({"revision":row.revision,"payload":serde_json::from_str::<Value>(&row.payload)?})
|
||||
}
|
||||
None => json!({"revision":0,"payload":null}),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub async fn save_shared_draft(
|
||||
&self,
|
||||
workspace: Uuid,
|
||||
actor: Uuid,
|
||||
revision: i64,
|
||||
payload: Value,
|
||||
) -> Result<Value> {
|
||||
self.require(workspace, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
// Deserialize into the bounded typed input; never store arbitrary integration secrets.
|
||||
let draft: crate::generation_service::Draft = serde_json::from_value(payload)?;
|
||||
self.prepare(workspace, draft.revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
crate::error::ensure(
|
||||
draft.variables.len() <= 100 && draft.rules.len() <= 50,
|
||||
crate::i18n::text("err_shared_draft_limit"),
|
||||
)?;
|
||||
let payload = serde_json::to_string(&draft)?;
|
||||
let changed = self.db.raw_with(r#"
|
||||
WITH allowed AS (SELECT id FROM meguri__workspaces WHERE id=$1 AND revision=$5 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 @> '["edit_schedules"]'::jsonb)))
|
||||
, saved AS (INSERT INTO meguri__workspace_drafts(workspace_id,revision,payload) SELECT id,1,$3 FROM allowed WHERE $2=0 OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET revision=meguri__workspace_drafts.revision+1,payload=EXCLUDED.payload WHERE meguri__workspace_drafts.revision=$2 RETURNING workspace_id,revision)
|
||||
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$4,'generation.draft_saved',jsonb_build_object('draft_revision',revision)::text,now() FROM saved
|
||||
"#, &[&workspace.to_string(), &revision, &payload, &actor.to_string(), &draft.revision]).await?;
|
||||
if changed.rows_affected().0 == 0 {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_shared_draft_stale").into(),
|
||||
));
|
||||
}
|
||||
Ok(json!({"revision":revision+1,"payload":draft}))
|
||||
}
|
||||
pub async fn historical_timeline(
|
||||
&self,
|
||||
workspace: &WorkspaceRecord,
|
||||
@@ -360,19 +459,20 @@ impl Store {
|
||||
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)
|
||||
pub async fn retry(&self, workspace: Uuid, actor: Uuid, revision: i64) -> Result<()> {
|
||||
self.prepare(workspace, revision, actor, Permission::ManageIntegration)
|
||||
.await?;
|
||||
let mut record = self.workspace(workspace).await?;
|
||||
self.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::ManageIntegration,
|
||||
"publication.requested",
|
||||
json!({}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
let result = self.db.raw_with(r#"
|
||||
WITH selected AS (SELECT w.* FROM meguri__workspaces w WHERE id=$1 AND revision=$3 AND demo_deleted_at IS NULL AND EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$2 AND (is_owner OR permissions::jsonb @> '["manage_integration"]'::jsonb)) FOR NO KEY UPDATE),
|
||||
queued AS (UPDATE meguri__sync_jobs j SET authorized_revision=w.revision,authorized_document=w.document,state='pending',attempts=0,next_attempt=now(),last_error=NULL FROM selected w WHERE j.workspace_id=w.id AND (lease_until IS NULL OR lease_until<now()) RETURNING j.workspace_id)
|
||||
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$2,'publication.requested',jsonb_build_object('revision',$3::bigint)::text,now() FROM queued
|
||||
"#, &[&workspace.to_string(), &actor.to_string(), &revision]).await?;
|
||||
if result.rows_affected().0 == 0 {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_publication_busy_or_stale").into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +480,7 @@ 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 ($5 <> 'generation.applied' OR ($6::jsonb->>'shared_revision') IS NULL OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1 AND revision=($6::jsonb->>'shared_revision')::bigint))
|
||||
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)
|
||||
@@ -390,9 +491,64 @@ WITH updated AS (
|
||||
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
|
||||
SELECT id,revision,'draft',0,now() FROM updated
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision
|
||||
)
|
||||
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
|
||||
"#;
|
||||
|
||||
/// Compare desired configuration with the last verified publication, never provider GETs.
|
||||
pub fn publication_changes(
|
||||
document: &WorkspaceDocument,
|
||||
baseline: Option<&WorkspaceDocument>,
|
||||
) -> Result<Value> {
|
||||
let desired = serde_json::to_value(document)?;
|
||||
let old = serde_json::to_value(baseline)?;
|
||||
let mut changes = vec![];
|
||||
let schedule_context_changed = [
|
||||
"timezone",
|
||||
"people",
|
||||
"holidays",
|
||||
"absences",
|
||||
"requests",
|
||||
"horizon_days",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| desired[key] != old[key]);
|
||||
for (collection, kind) in [
|
||||
("schedules", "schedule"),
|
||||
("escalations", "escalation"),
|
||||
("routes", "route"),
|
||||
] {
|
||||
let empty = vec![];
|
||||
let before = old[collection].as_array().unwrap_or(&empty);
|
||||
let after = desired[collection].as_array().unwrap_or(&empty);
|
||||
for resource in after {
|
||||
let previous = before.iter().find(|r| r["id"] == resource["id"]);
|
||||
let action = match previous {
|
||||
None => "create",
|
||||
Some(prior)
|
||||
if prior != resource
|
||||
|| (kind == "schedule" && schedule_context_changed)
|
||||
|| desired["integration"] != old["integration"] =>
|
||||
{
|
||||
"update"
|
||||
}
|
||||
_ => "unchanged",
|
||||
};
|
||||
changes.push(
|
||||
json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":action}),
|
||||
);
|
||||
}
|
||||
for resource in before
|
||||
.iter()
|
||||
.filter(|r| !after.iter().any(|n| n["id"] == r["id"]))
|
||||
{
|
||||
changes.push(
|
||||
json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":"remove"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(json!(changes))
|
||||
}
|
||||
+57
-4
@@ -38,6 +38,7 @@ impl App for MeguriApp {
|
||||
Route::with_handler("/", index),
|
||||
Route::with_handler("/assets/app.css", css),
|
||||
Route::with_handler("/assets/app.js", js),
|
||||
Route::with_handler("/assets/site.js", site_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),
|
||||
@@ -47,6 +48,8 @@ impl App for MeguriApp {
|
||||
Route::with_handler("/health/ready", ready),
|
||||
];
|
||||
for path in [
|
||||
"/api/site",
|
||||
"/api/site/{action}",
|
||||
"/api/session",
|
||||
"/api/register",
|
||||
"/api/login",
|
||||
@@ -62,8 +65,12 @@ impl App for MeguriApp {
|
||||
"/api/workspaces/{workspace}/routes/order",
|
||||
"/api/workspaces/{workspace}/absence-preview",
|
||||
"/api/workspaces/{workspace}/absences",
|
||||
"/api/workspaces/{workspace}/generation-export",
|
||||
"/api/workspaces/{workspace}/generation-import-preview",
|
||||
"/api/workspaces/{workspace}/generation-import",
|
||||
"/api/workspaces/{workspace}/generation-preview",
|
||||
"/api/workspaces/{workspace}/generation",
|
||||
"/api/workspaces/{workspace}/generation-draft",
|
||||
"/api/workspaces/{workspace}/members",
|
||||
"/api/workspaces/{workspace}/requests",
|
||||
"/api/workspaces/{workspace}/request-preview",
|
||||
@@ -108,8 +115,14 @@ fn secured(response: Response) -> cot::Result<Response> {
|
||||
.with_header("Cache-Control", "no-store")
|
||||
.into_response()
|
||||
}
|
||||
async fn site_js() -> cot::Result<Response> {
|
||||
Html::new(include_str!("../static/site.js"))
|
||||
.with_header("Content-Type", "application/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn index() -> cot::Result<Response> {
|
||||
let html = include_str!("../static/index.html")
|
||||
.replace("<!-- SITE_ADMIN -->", include_str!("../static/site.html"))
|
||||
.replace(
|
||||
"<!-- GENERATION_MAP -->",
|
||||
include_str!("../static/generation-map.html"),
|
||||
@@ -230,6 +243,7 @@ async fn authenticate(db: &Store, request: &Request) -> Result<Identity> {
|
||||
if user.demo_deleted_at.is_some() {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
crate::site::active(db, store::uuid(&user.id)?).await?;
|
||||
Ok(Identity {
|
||||
user: store::user(user)?,
|
||||
csrf: session.csrf,
|
||||
@@ -275,7 +289,7 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
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()}),
|
||||
json!({"site_admin":crate::site::require(&db,i.user.id).await.is_ok(),"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()}),
|
||||
@@ -316,6 +330,8 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
email: String,
|
||||
}
|
||||
let credentials: Credentials = decode(body)?;
|
||||
check_login_rate(&credentials.username)?;
|
||||
@@ -327,6 +343,7 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
&db,
|
||||
credentials.username.clone(),
|
||||
credentials.display_name,
|
||||
credentials.email,
|
||||
credentials.password.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -338,7 +355,7 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return response(json!({"user":user,"csrf":csrf}))?
|
||||
return response(json!({"site_admin":crate::site::require(&db,user.id).await.is_ok(),"user":user,"csrf":csrf}))?
|
||||
.with_header(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
@@ -350,6 +367,16 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
}
|
||||
let identity = identity?;
|
||||
let actor = identity.user.id;
|
||||
if path == "/api/site" && method == Method::GET {
|
||||
return response(crate::site::overview(&db, actor).await?);
|
||||
}
|
||||
if let Some(action) = path.strip_prefix("/api/site/") {
|
||||
if method == Method::PUT {
|
||||
crate::site::mutate(&db, actor, action, body).await?;
|
||||
return response(json!({"ok":true}));
|
||||
}
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
if path == "/api/logout" && method == Method::POST {
|
||||
let token = identity.token_hash;
|
||||
query!(SessionRow,$token_hash==token).delete(&db.db).await?;
|
||||
@@ -476,10 +503,36 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
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}),
|
||||
json!({"generation_draft":db.shared_draft(workspace).await?,"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::PUT && action == "generation-draft" {
|
||||
return response(
|
||||
db.save_shared_draft(workspace, actor, revision(&body)?, body["payload"].clone())
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if parts.len() == 4 && method == Method::POST && action == "generation-export" {
|
||||
return response(
|
||||
crate::generation_service::export_config(&db, actor, workspace, decode(body)?).await?,
|
||||
);
|
||||
}
|
||||
if parts.len() == 4
|
||||
&& method == Method::POST
|
||||
&& matches!(action, "generation-import-preview" | "generation-import")
|
||||
{
|
||||
return response(
|
||||
crate::generation_service::import_config(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
decode(body)?,
|
||||
action == "generation-import",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if parts.len() == 4 && method == Method::POST && action == "generation-preview" {
|
||||
return response(
|
||||
crate::generation_service::preview(&db, actor, workspace, decode(body)?).await?,
|
||||
@@ -711,7 +764,7 @@ async fn dispatch(request: Request) -> Result<Response> {
|
||||
)?);
|
||||
}
|
||||
if action == "sync" && method == Method::POST {
|
||||
db.retry(workspace, actor).await?;
|
||||
db.retry(workspace, actor, revision(&body)?).await?;
|
||||
return response(json!({"ok":true}));
|
||||
}
|
||||
Err(Error::NotFound)
|
||||
|
||||
+20
-6
@@ -23,12 +23,16 @@ pub async fn run(db: Store) {
|
||||
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.retain(|job| {
|
||||
job.authorized_revision.is_some()
|
||||
&& ["pending", "publishing", "failed", "conflict"].contains(&job.state.as_str())
|
||||
&& 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?;
|
||||
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() AND authorized_revision=$3 AND state IN ('pending','publishing','failed','conflict')",&[&token,&job.workspace_id,&job.authorized_revision]).await?;
|
||||
if claimed.rows_affected().0 == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -46,7 +50,7 @@ pub async fn tick(db: &Store) -> Result<()> {
|
||||
});
|
||||
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?;
|
||||
db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,published_document=authorized_document,state='synced',attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=now(),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(_));
|
||||
@@ -57,7 +61,7 @@ pub async fn tick(db: &Store) -> Result<()> {
|
||||
(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?;
|
||||
db.db.raw_with("UPDATE meguri__sync_jobs SET state=$1,last_error=$2,attempts=attempts+1,next_attempt=$3,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token]).await?;
|
||||
tracing::warn!(workspace=%job.workspace_id,%error,"publication did not complete");
|
||||
}
|
||||
}
|
||||
@@ -66,10 +70,20 @@ pub async fn tick(db: &Store) -> Result<()> {
|
||||
}
|
||||
|
||||
pub async fn publish_one(db: &Store, workspace: Uuid) -> Result<(i64, chrono::DateTime<Utc>)> {
|
||||
let record = db.workspace(workspace).await?;
|
||||
// Only the explicitly authorized snapshot may leave the application.
|
||||
let id = workspace.to_string();
|
||||
let job = query!(SyncJobRow, $workspace_id == id)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let record = crate::store::WorkspaceRecord {
|
||||
id: workspace,
|
||||
revision: job.authorized_revision.ok_or(Error::NotFound)?,
|
||||
document: serde_json::from_str(job.authorized_document.as_deref().ok_or(Error::NotFound)?)?,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let (from, to) = engine::publication_window(&record.document, now)?;
|
||||
let timeline = db.historical_timeline(&record, from, to).await?;
|
||||
let timeline = engine::timeline(&record.document, from, to)?;
|
||||
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