1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::ids::{TaskId, WorkspaceId, encode_crockford};
6
7use super::schedule::{RecurrenceScheduleError, slot_values};
8use super::{RecurrenceSchedule, RecurrenceSeriesId};
9
10const TASK_ID_DOMAIN: &[u8] = b"aven recurrence task v1";
11const TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence task change v1";
12const OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence occurrence change v1";
13const TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence task field version v1";
14const OCCURRENCE_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence occurrence field version v1";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct RecurrenceOccurrenceIdentity {
18 pub task_id: TaskId,
19 pub task_change_id: String,
20 pub occurrence_change_id: String,
21 pub created_at: String,
22 pub updated_at: String,
23 pub occurrence_link: RecurrenceOccurrenceLink,
24 pub field_version_seeds: RecurrenceFieldVersionSeeds,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RecurrenceOccurrenceLink {
29 pub workspace_id: WorkspaceId,
30 pub series_id: RecurrenceSeriesId,
31 pub slot_on: NaiveDate,
32 pub task_id: TaskId,
33 pub projected_at: String,
34 pub change_id: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RecurrenceFieldVersionSeeds {
39 pub task: String,
40 pub occurrence: String,
41}
42
43pub fn derive_occurrence_identity(
44 workspace_id: &WorkspaceId,
45 series_id: &RecurrenceSeriesId,
46 schedule: &RecurrenceSchedule,
47 slot_on: NaiveDate,
48) -> Result<RecurrenceOccurrenceIdentity, RecurrenceScheduleError> {
49 let slot = slot_values(schedule, slot_on)?;
50 let slot_text = slot_on.format("%Y-%m-%d").to_string();
51 let components = [
52 workspace_id.as_str(),
53 series_id.as_str(),
54 slot_text.as_str(),
55 ];
56 let task_id = derive_id(TASK_ID_DOMAIN, &components)
57 .parse::<TaskId>()
58 .expect("derived recurrence task ID is valid");
59 let task_change_id = derive_id(TASK_CHANGE_DOMAIN, &components);
60 let occurrence_change_id = derive_id(OCCURRENCE_CHANGE_DOMAIN, &components);
61 let field_version_seeds = RecurrenceFieldVersionSeeds {
62 task: derive_id(TASK_FIELD_VERSION_DOMAIN, &components),
63 occurrence: derive_id(OCCURRENCE_FIELD_VERSION_DOMAIN, &components),
64 };
65 let occurrence_link = RecurrenceOccurrenceLink {
66 workspace_id: workspace_id.clone(),
67 series_id: series_id.clone(),
68 slot_on,
69 task_id: task_id.clone(),
70 projected_at: slot.boundary_at.clone(),
71 change_id: occurrence_change_id.clone(),
72 };
73 Ok(RecurrenceOccurrenceIdentity {
74 task_id,
75 task_change_id,
76 occurrence_change_id,
77 created_at: slot.boundary_at.clone(),
78 updated_at: slot.boundary_at,
79 occurrence_link,
80 field_version_seeds,
81 })
82}
83
84fn derive_id(domain: &[u8], components: &[&str]) -> String {
85 let mut hasher = Sha256::new();
86 hasher.update(domain);
87 for component in components {
88 hasher.update(component.as_bytes());
89 }
90 let digest = hasher.finalize();
91 let mut bytes = [0u8; 10];
92 bytes.copy_from_slice(&digest[..10]);
93 encode_crockford(&bytes)
94}
95
96const PROPOSAL_DOMAIN: &[u8] = b"aven recurrence proposal v1\0";
97const PROPOSAL_TASK_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal task change v1";
98const PROPOSAL_OCCURRENCE_CHANGE_DOMAIN: &[u8] = b"aven recurrence proposal occurrence change v1";
99const PROPOSAL_TASK_FIELD_VERSION_DOMAIN: &[u8] = b"aven recurrence proposal task field version v1";
100
101#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RecurrenceProposalIds {
106 pub task_change_id: String,
107 pub occurrence_change_id: String,
108 pub task_field_version_seed: String,
109}
110
111pub fn derive_proposal_ids(payload: &serde_json::Value) -> anyhow::Result<RecurrenceProposalIds> {
116 use anyhow::{Context, ensure};
117 let text = |key: &str| {
118 payload[key]
119 .as_str()
120 .with_context(|| format!("error invalid-sync-change recurrence-proposal field={key}"))
121 };
122 let labels = payload["labels"]
123 .as_array()
124 .context("error invalid-sync-change recurrence-proposal field=labels")?
125 .iter()
126 .map(|label| {
127 label
128 .as_str()
129 .context("error invalid-sync-change recurrence-proposal field=labels")
130 })
131 .collect::<anyhow::Result<Vec<_>>>()?;
132 ensure!(
133 labels.windows(2).all(|pair| pair[0] < pair[1]),
134 "error invalid-sync-change recurrence-proposal field=labels"
135 );
136 let metadata = payload["metadata"]
137 .as_array()
138 .context("error invalid-sync-change recurrence-proposal field=metadata")?
139 .iter()
140 .map(|entry| {
141 let value = |key: &str| {
142 entry[key]
143 .as_str()
144 .context("error invalid-sync-change recurrence-proposal field=metadata")
145 };
146 Ok([value("field_id")?, value("key")?, value("value")?])
147 })
148 .collect::<anyhow::Result<Vec<_>>>()?;
149 let mut field_ids = metadata.iter().map(|entry| entry[0]).collect::<Vec<_>>();
150 field_ids.sort_unstable();
151 field_ids.dedup();
152 ensure!(
153 metadata.windows(2).all(|pair| pair[0][1] < pair[1][1])
154 && field_ids.len() == metadata.len(),
155 "error invalid-sync-change recurrence-proposal field=metadata"
156 );
157 let input = serde_json::json!([
158 text("workspace_id")?,
159 text("series_id")?,
160 text("slot_on")?,
161 text("title")?,
162 text("description")?,
163 text("project_id")?,
164 text("status")?,
165 text("priority")?,
166 labels,
167 metadata,
168 text("available_local_time")?,
169 text("due_policy")?,
170 ]);
171 let mut hasher = Sha256::new();
172 hasher.update(PROPOSAL_DOMAIN);
173 hasher.update(serde_json::to_vec(&input)?);
174 let digest = hex::encode(hasher.finalize());
175 Ok(proposal_ids_for_task_change(derive_id(
176 PROPOSAL_TASK_CHANGE_DOMAIN,
177 &[digest.as_str()],
178 )))
179}
180
181pub fn proposal_ids_for_task_change(task_change_id: String) -> RecurrenceProposalIds {
184 RecurrenceProposalIds {
185 occurrence_change_id: derive_id(PROPOSAL_OCCURRENCE_CHANGE_DOMAIN, &[&task_change_id]),
186 task_field_version_seed: derive_id(PROPOSAL_TASK_FIELD_VERSION_DOMAIN, &[&task_change_id]),
187 task_change_id,
188 }
189}
190
191impl RecurrenceOccurrenceIdentity {
192 pub fn is_proposal(&self, task_change_id: &str) -> bool {
195 task_change_id != self.task_change_id
196 }
197
198 pub fn generated_task_ids(
200 &self,
201 payload: &serde_json::Value,
202 ) -> anyhow::Result<RecurrenceProposalIds> {
203 match payload["task_change_id"].as_str() {
204 Some(id) if !self.is_proposal(id) => Ok(self.projection_ids(id)),
205 _ => derive_proposal_ids(payload),
206 }
207 }
208
209 pub fn projection_ids(&self, task_change_id: &str) -> RecurrenceProposalIds {
211 if self.is_proposal(task_change_id) {
212 return proposal_ids_for_task_change(task_change_id.to_owned());
213 }
214 RecurrenceProposalIds {
215 task_change_id: self.task_change_id.clone(),
216 occurrence_change_id: self.occurrence_change_id.clone(),
217 task_field_version_seed: self.field_version_seeds.task.clone(),
218 }
219 }
220
221 pub fn stored_generation_ids(
224 &self,
225 payload: &serde_json::Value,
226 slot_on: NaiveDate,
227 projection: bool,
228 ) -> Option<RecurrenceProposalIds> {
229 let ids = if projection {
230 self.projection_ids(payload["task_change_id"].as_str()?)
231 } else {
232 self.generated_task_ids(payload).ok()?
233 };
234 let slot = slot_on.format("%Y-%m-%d").to_string();
235 let matches = [
236 ("task_id", self.task_id.as_str()),
237 ("series_id", self.occurrence_link.series_id.as_str()),
238 ("slot_on", slot.as_str()),
239 ("task_change_id", ids.task_change_id.as_str()),
240 ("occurrence_change_id", ids.occurrence_change_id.as_str()),
241 (
242 "task_field_version_seed",
243 ids.task_field_version_seed.as_str(),
244 ),
245 (
246 "occurrence_field_version_seed",
247 self.field_version_seeds.occurrence.as_str(),
248 ),
249 ]
250 .into_iter()
251 .all(|(key, expected)| payload[key].as_str() == Some(expected))
252 && (!projection
253 || payload["projected_at"].as_str()
254 == Some(self.occurrence_link.projected_at.as_str()));
255 matches.then_some(ids)
256 }
257}