//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the //! application, in the menu, with the number of unread messages — no mail. //! //! A message is about a subject (an objection, a typo fix, a ballot set aside — //! ADR 0012) and carries the //! gestures that subject asks of its recipient now — lift or reiterate, //! validate or refuse — and its deadline. A message dealt with offers no //! gesture any more. Its text is told by the interface, in the language of the //! reader, from the subject. Each person reads their own messages only //! (`trustin_access::reads_message`); acting on a subject marks one's messages //! about it as read. use crate::AppState; use crate::auth::{IdentityRow, User}; use crate::error::{AppError, AppResult}; use crate::{ballots, docs, objections, typos, util}; use axum::Json; use axum::extract::{Path, State}; use serde_json::{Value, json}; /// The most recent messages shown in the box. const SHOWN: i64 = 100; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Topic { Objection, Typo, /// A ballot set aside: never seen on the chain in time, refused, or its *for* /// proven while an objection held the version back (ADR 0012). Ballot, } impl Topic { pub fn as_str(self) -> &'static str { match self { Self::Objection => "objection", Self::Typo => "typo", Self::Ballot => "ballot", } } } /// Puts a message in someone's box. #[allow(clippy::too_many_arguments)] pub async fn notify( state: &AppState, recipient: &str, doc_id: &str, topic: Topic, subject_id: &str, kind: &str, cycle: i64, due: Option<&str>, ) -> AppResult<()> { sqlx::query( "INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(util::id()) .bind(recipient) .bind(doc_id) .bind(topic.as_str()) .bind(subject_id) .bind(kind) .bind(cycle) .bind(state.clock.now()) .bind(due) .execute(&state.db) .await?; Ok(()) } /// The messages about a subject (of some kinds, or all) are dealt with: what became of it. pub async fn close( state: &AppState, topic: Topic, subject_id: &str, kinds: Option<&[&str]>, outcome: &str, ) -> AppResult<()> { let now = state.clock.now(); match kinds { None => { sqlx::query( "UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL", ) .bind(&now) .bind(outcome) .bind(topic.as_str()) .bind(subject_id) .execute(&state.db) .await?; } Some(kinds) => { for kind in kinds { sqlx::query( "UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL", ) .bind(&now) .bind(outcome) .bind(topic.as_str()) .bind(subject_id) .bind(kind) .execute(&state.db) .await?; } } } Ok(()) } /// Someone acted on a subject: their messages about it are read. pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> { sqlx::query( "UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL", ) .bind(state.clock.now()) .bind(recipient) .bind(topic.as_str()) .bind(subject_id) .execute(&state.db) .await?; Ok(()) } async fn unread_of(state: &AppState, recipient: &str) -> AppResult { Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL") .bind(recipient) .fetch_one(&state.db) .await?) } #[derive(sqlx::FromRow)] struct MessageRow { id: String, recipient_id: String, doc_id: String, topic: String, subject_id: String, kind: String, cycle: i64, created_at: String, due_at: Option, read_at: Option, done_at: Option, outcome: Option, } #[derive(sqlx::FromRow)] struct DocLine { group_slug: String, slug: String, title: String, } async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult> { let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?") .bind(&m.doc_id) .fetch_optional(&state.db) .await?; let Some(doc) = doc else { return Ok(None) }; // A message tells what is in a group: shown only while its recipient opens it. if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? { return Ok(None); } let (subject, gestures) = match m.topic.as_str() { "objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? { Some((v, g)) => (json!({ "objection": v }), g), None => return Ok(None), }, "typo" => match typos::for_message(state, me, &m.subject_id).await? { Some((v, g)) => (json!({ "typo": v }), g), None => return Ok(None), }, "ballot" => match ballots::for_message(state, me, &m.subject_id).await? { Some(v) => (json!({ "ballot": v }), Vec::new()), None => return Ok(None), }, _ => return Ok(None), }; let mut out = json!({ "id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at, "dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(), "outcome": m.outcome, "gestures": gestures, "doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) }, }); if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) { o.extend(s); } Ok(Some(out)) } /// `GET /api/messages` — one's own box, newest first. pub async fn list(State(state): State, User(me): User) -> AppResult> { let rows = sqlx::query_as::<_, MessageRow>( "SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?", ) .bind(&me.id) .bind(SHOWN) .fetch_all(&state.db) .await?; let mut out = Vec::with_capacity(rows.len()); for m in &rows { if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) { continue; } if let Some(v) = view(&state, &me, m).await? { out.push(v); } } Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out }))) } /// `GET /api/messages/unread` — the number next to « Messages » in the menu. pub async fn unread(State(state): State, User(me): User) -> AppResult> { Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? }))) } /// `POST /api/messages/{id}/read` — one's own message only (404 otherwise). pub async fn read(State(state): State, User(me): User, Path(id): Path) -> AppResult> { let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?") .bind(&id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound)?; if !trustin_access::reads_message(&me.to_identity(), &recipient) { return Err(AppError::NotFound); } sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL") .bind(state.clock.now()) .bind(&id) .execute(&state.db) .await?; Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? }))) } /// `POST /api/messages/read` — every message of one's box. pub async fn read_all(State(state): State, User(me): User) -> AppResult> { sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL") .bind(state.clock.now()) .bind(&me.id) .execute(&state.db) .await?; Ok(Json(json!({ "unread": 0 }))) }