use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::ids::{TaskId, WorkspaceId, encode_crockford};
use super::schedule::{RecurrenceScheduleError, slot_values};
use super::{RecurrenceSchedule, RecurrenceSeriesId};
const TASK_ID_DOMAIN: &[u8] = b"aven recurrence task v1";
const TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence task change v1";
const OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence occurrence change v1";
const TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence task field version v1";
const OCCURRENCE_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence occurrence field version v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceOccurrenceIdentity {
pub task_id: TaskId,
pub task_change_id: String,
pub occurrence_change_id: String,
pub created_at: String,
pub updated_at: String,
pub occurrence_link: RecurrenceOccurrenceLink,
pub field_version_seeds: RecurrenceFieldVersionSeeds,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceOccurrenceLink {
pub workspace_id: WorkspaceId,
pub series_id: RecurrenceSeriesId,
pub slot_on: NaiveDate,
pub task_id: TaskId,
pub projected_at: String,
pub change_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceFieldVersionSeeds {
pub task: String,
pub occurrence: String,
}
pub fn derive_occurrence_identity(
workspace_id: &WorkspaceId,
series_id: &RecurrenceSeriesId,
schedule: &RecurrenceSchedule,
slot_on: NaiveDate,
) -> Result<RecurrenceOccurrenceIdentity, RecurrenceScheduleError> {
let slot = slot_values(schedule, slot_on)?;
let slot_text = slot_on.format("%Y-%m-%d").to_string();
let components = [
workspace_id.as_str(),
series_id.as_str(),
slot_text.as_str(),
];
let task_id = derive_id(TASK_ID_DOMAIN, &components)
.parse::<TaskId>()
.expect("derived recurrence task ID is valid");
let task_change_id = derive_id(TASK_CHANGE_DOMAIN, &components);
let occurrence_change_id = derive_id(OCCURRENCE_CHANGE_DOMAIN, &components);
let field_version_seeds = RecurrenceFieldVersionSeeds {
task: derive_id(TASK_FIELD_VERSION_DOMAIN, &components),
occurrence: derive_id(OCCURRENCE_FIELD_VERSION_DOMAIN, &components),
};
let occurrence_link = RecurrenceOccurrenceLink {
workspace_id: workspace_id.clone(),
series_id: series_id.clone(),
slot_on,
task_id: task_id.clone(),
projected_at: slot.boundary_at.clone(),
change_id: occurrence_change_id.clone(),
};
Ok(RecurrenceOccurrenceIdentity {
task_id,
task_change_id,
occurrence_change_id,
created_at: slot.boundary_at.clone(),
updated_at: slot.boundary_at,
occurrence_link,
field_version_seeds,
})
}
fn derive_id(domain: &[u8], components: &[&str]) -> String {
let mut hasher = Sha256::new();
hasher.update(domain);
for component in components {
hasher.update(component.as_bytes());
}
let digest = hasher.finalize();
let mut bytes = [0u8; 10];
bytes.copy_from_slice(&digest[..10]);
encode_crockford(&bytes)
}
const PROPOSAL_DOMAIN: &[u8] = b"aven recurrence proposal v1\0";
const PROPOSAL_TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal task change v1";
const PROPOSAL_OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal occurrence change v1";
const PROPOSAL_TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence proposal task field version v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecurrenceProposalIds {
pub task_change_id: String,
pub occurrence_change_id: String,
pub task_field_version_seed: String,
}
pub fn derive_proposal_ids(payload: &serde_json::Value) -> anyhow::Result<RecurrenceProposalIds> {
use anyhow::{Context, ensure};
let text = |key: &str| {
payload[key]
.as_str()
.with_context(|| format!("error invalid-sync-change recurrence-proposal field={key}"))
};
let labels = payload["labels"]
.as_array()
.context("error invalid-sync-change recurrence-proposal field=labels")?
.iter()
.map(|label| {
label
.as_str()
.context("error invalid-sync-change recurrence-proposal field=labels")
})
.collect::<anyhow::Result<Vec<_>>>()?;
ensure!(
labels.windows(2).all(|pair| pair[0] < pair[1]),
"error invalid-sync-change recurrence-proposal field=labels"
);
let metadata = payload["metadata"]
.as_array()
.context("error invalid-sync-change recurrence-proposal field=metadata")?
.iter()
.map(|entry| {
let value = |key: &str| {
entry[key]
.as_str()
.context("error invalid-sync-change recurrence-proposal field=metadata")
};
Ok([value("field_id")?, value("key")?, value("value")?])
})
.collect::<anyhow::Result<Vec<_>>>()?;
let mut field_ids = metadata.iter().map(|entry| entry[0]).collect::<Vec<_>>();
field_ids.sort_unstable();
field_ids.dedup();
ensure!(
metadata.windows(2).all(|pair| pair[0][1] < pair[1][1])
&& field_ids.len() == metadata.len(),
"error invalid-sync-change recurrence-proposal field=metadata"
);
let input = serde_json::json!([
text("workspace_id")?,
text("series_id")?,
text("slot_on")?,
text("title")?,
text("description")?,
text("project_id")?,
text("status")?,
text("priority")?,
labels,
metadata,
text("available_local_time")?,
text("due_policy")?,
]);
let mut hasher = Sha256::new();
hasher.update(PROPOSAL_DOMAIN);
hasher.update(serde_json::to_vec(&input)?);
let digest = hex::encode(hasher.finalize());
Ok(proposal_ids_for_task_change(derive_id(
PROPOSAL_TASK_CHANGE_DOMAIN,
&[digest.as_str()],
)))
}
pub fn proposal_ids_for_task_change(task_change_id: String) -> RecurrenceProposalIds {
RecurrenceProposalIds {
occurrence_change_id: derive_id(PROPOSAL_OCCURRENCE_CHANGE_DOMAIN, &[&task_change_id]),
task_field_version_seed: derive_id(PROPOSAL_TASK_FIELD_VERSION_DOMAIN, &[&task_change_id]),
task_change_id,
}
}
impl RecurrenceOccurrenceIdentity {
pub fn is_proposal(&self, task_change_id: &str) -> bool {
task_change_id != self.task_change_id
}
pub fn generated_task_ids(
&self,
payload: &serde_json::Value,
) -> anyhow::Result<RecurrenceProposalIds> {
match payload["task_change_id"].as_str() {
Some(id) if !self.is_proposal(id) => Ok(self.projection_ids(id)),
_ => derive_proposal_ids(payload),
}
}
pub fn projection_ids(&self, task_change_id: &str) -> RecurrenceProposalIds {
if self.is_proposal(task_change_id) {
return proposal_ids_for_task_change(task_change_id.to_owned());
}
RecurrenceProposalIds {
task_change_id: self.task_change_id.clone(),
occurrence_change_id: self.occurrence_change_id.clone(),
task_field_version_seed: self.field_version_seeds.task.clone(),
}
}
pub fn stored_generation_ids(
&self,
payload: &serde_json::Value,
slot_on: NaiveDate,
projection: bool,
) -> Option<RecurrenceProposalIds> {
let ids = if projection {
self.projection_ids(payload["task_change_id"].as_str()?)
} else {
self.generated_task_ids(payload).ok()?
};
let slot = slot_on.format("%Y-%m-%d").to_string();
let matches = [
("task_id", self.task_id.as_str()),
("series_id", self.occurrence_link.series_id.as_str()),
("slot_on", slot.as_str()),
("task_change_id", ids.task_change_id.as_str()),
("occurrence_change_id", ids.occurrence_change_id.as_str()),
(
"task_field_version_seed",
ids.task_field_version_seed.as_str(),
),
(
"occurrence_field_version_seed",
self.field_version_seeds.occurrence.as_str(),
),
]
.into_iter()
.all(|(key, expected)| payload[key].as_str() == Some(expected))
&& (!projection
|| payload["projected_at"].as_str()
== Some(self.occurrence_link.projected_at.as_str()));
matches.then_some(ids)
}
}