2026-08-30 12:34:34 +02:00
|
|
|
//! Announced pages (`Question.event`): keeps one `portal_events` record
|
|
|
|
|
//! per event question and, once a window closes, moves it to
|
|
|
|
|
//! `awaiting_summary` - the "post what happened" task - publishing the
|
|
|
|
|
//! transition on `portal.answers.submitted` like any desk decision, so
|
|
|
|
|
//! the desktop notifier and n8n hear it. Runs on a one-minute tick;
|
|
|
|
|
//! every step is idempotent, so a restart or a content reload in the
|
|
|
|
|
//! middle changes nothing.
|
|
|
|
|
|
|
|
|
|
use crate::answers::{store_answer, Answer};
|
|
|
|
|
use crate::content::EVENTS_BUCKET;
|
|
|
|
|
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
|
|
|
|
use crate::server::AppState;
|
|
|
|
|
|
|
|
|
|
/// Alternative name stamped on the auto-created record and on the
|
|
|
|
|
/// transition event - what an n8n workflow gates on.
|
|
|
|
|
pub const EVENT_ALTERNATIVE: &str = "Announced";
|
|
|
|
|
pub const SUMMARY_DUE_LABEL: &str = "Summary due";
|
|
|
|
|
|
|
|
|
|
/// Record id for an event question: its id with `/` folded to `-`
|
|
|
|
|
/// (`/events/opening` -> `events-opening`), so one page is one record
|
|
|
|
|
/// however many times the sweeper runs.
|
|
|
|
|
pub fn record_id(question_id: &str) -> String {
|
|
|
|
|
question_id.trim_matches('/').replace('/', "-")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn run(state: AppState) {
|
|
|
|
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
|
|
|
|
loop {
|
|
|
|
|
tick.tick().await;
|
|
|
|
|
if let Err(e) = sweep(&state).await {
|
|
|
|
|
tracing::warn!(error = %e, "event sweep failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One pass: create missing records, close ended windows.
|
|
|
|
|
pub async fn sweep(state: &AppState) -> anyhow::Result<()> {
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
let questions = state.questions.load();
|
|
|
|
|
let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect();
|
|
|
|
|
if events.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let aggregates = state.aggregates.load();
|
|
|
|
|
let schema = aggregates
|
|
|
|
|
.get(EVENTS_BUCKET)
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("no {EVENTS_BUCKET} schema"))?;
|
|
|
|
|
let store = match state.jetstream.get_key_value(EVENTS_BUCKET).await {
|
|
|
|
|
Ok(store) => Some(store),
|
|
|
|
|
Err(_) => None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for q in events {
|
|
|
|
|
let event = q.event.as_ref().expect("filtered");
|
|
|
|
|
let (starts, ends) = match event.window_ms() {
|
|
|
|
|
Ok(w) => w,
|
|
|
|
|
Err(_) => continue, // validated on load; belt and braces
|
|
|
|
|
};
|
|
|
|
|
let id = record_id(&q.id);
|
|
|
|
|
let existing: Option<Answer> = match &store {
|
|
|
|
|
Some(store) => store
|
|
|
|
|
.get(&id)
|
|
|
|
|
.await?
|
|
|
|
|
.and_then(|bytes| serde_json::from_slice(&bytes).ok()),
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
|
|
|
|
let responses = serde_json::json!({
|
|
|
|
|
"name": q.name,
|
|
|
|
|
"starts": event.starts,
|
|
|
|
|
"starts_ms": starts,
|
|
|
|
|
"ends_ms": ends,
|
|
|
|
|
"place": event.place,
|
|
|
|
|
"page": q.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let answer = match existing {
|
|
|
|
|
None => {
|
|
|
|
|
store_answer(
|
|
|
|
|
&state.jetstream,
|
|
|
|
|
&aggregates,
|
|
|
|
|
EVENTS_BUCKET,
|
|
|
|
|
id.clone(),
|
|
|
|
|
&q.id,
|
|
|
|
|
EVENT_ALTERNATIVE,
|
|
|
|
|
&responses,
|
|
|
|
|
now_ms,
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
tracing::info!(question = %q.id, "event record created");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
Some(a) => a,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-30 23:13:29 +02:00
|
|
|
// Content is the source of truth while the window is still
|
|
|
|
|
// open: a corrected date or place flows into the record, so the
|
|
|
|
|
// followup fires on the schedule the page actually announces.
|
|
|
|
|
if answer.state == schema.initial && answer.responses != responses {
|
|
|
|
|
let mut refreshed = answer.clone();
|
|
|
|
|
refreshed.responses = responses.clone();
|
|
|
|
|
if let Some(store) = &store {
|
|
|
|
|
store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?;
|
|
|
|
|
tracing::info!(question = %q.id, "event record refreshed from content");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 12:34:34 +02:00
|
|
|
if answer.state == schema.initial && now_ms >= ends {
|
|
|
|
|
let target = "awaiting_summary";
|
|
|
|
|
let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" });
|
|
|
|
|
match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await {
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
|
|
|
|
|
crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
|
crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => return Err(anyhow::anyhow!("{e}")),
|
|
|
|
|
}
|
|
|
|
|
let mut answer = answer;
|
|
|
|
|
answer.state = target.to_string();
|
|
|
|
|
answer.decided_ms = Some(now_ms);
|
|
|
|
|
answer.decided_by = Some("portal".to_string());
|
|
|
|
|
let store = state.jetstream.get_key_value(EVENTS_BUCKET).await?;
|
|
|
|
|
store.put(&id, serde_json::to_vec(&answer)?.into()).await?;
|
|
|
|
|
|
|
|
|
|
let parent_hashes = vec![id.clone()];
|
|
|
|
|
let chain_hash = crate::chain::hash_node(&q.id, &parent_hashes, &payload, now_ms);
|
|
|
|
|
emit_answer_submitted(
|
|
|
|
|
&state.nats,
|
|
|
|
|
&AnswerSubmitted {
|
|
|
|
|
chain_hash,
|
|
|
|
|
parent_hashes,
|
|
|
|
|
question_id: q.id.clone(),
|
|
|
|
|
alternative: SUMMARY_DUE_LABEL.to_string(),
|
|
|
|
|
responses: payload,
|
|
|
|
|
timestamp_ms: now_ms,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
tracing::info!(question = %q.id, "event ended - summary due");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::record_id;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn record_id_is_stable_and_flat() {
|
|
|
|
|
assert_eq!(record_id("/events/opening"), "events-opening");
|
|
|
|
|
assert_eq!(record_id("/"), "");
|
|
|
|
|
assert_eq!(record_id("/opening"), "opening");
|
|
|
|
|
}
|
|
|
|
|
}
|