Files
portal/src/announce.rs
T
Bendik LynghaugandClaude Opus 5 44c7ee6870
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
Publish release / publish (push) Successful in 1m52s
Render an Apple configuration profile from the server's PACC document
iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:18:46 +02:00

184 lines
6.5 KiB
Rust

//! 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,
0,
)
.await?;
tracing::info!(question = %q.id, "event record created");
continue;
}
Some(a) => a,
};
// 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");
}
}
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,
storyline: 0,
},
)
.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");
}
}