NEXT
Checks / test (push) Failing after 2m25s

This commit is contained in:
Ultradesu committed 2026-10-07 15:30:31 +01:00
1 parent e37d583179
commit 5260c2828b
65 files changed
+8469 -380

No files matched your search

+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}))
}
+4
View File
@@ -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;
+5
View File
@@ -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" {
+8
View File
@@ -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(())
}
+23
View File
@@ -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(())
}
+342
View File
@@ -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(())
}
+87
View File
@@ -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(())
}
+42
View File
@@ -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
View File
@@ -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)
}
+537
View File
@@ -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
View File
@@ -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) = &current {
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(&current["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"}]}]});
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &current.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
View File
@@ -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
View File
@@ -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))