Files
decision/crates/ld-server/src/messages.rs
T

248 lines
8.3 KiB
Rust

//! 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<i64> {
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<String>,
read_at: Option<String>,
done_at: Option<String>,
outcome: Option<String>,
}
#[derive(sqlx::FromRow)]
struct DocLine {
group_slug: String,
slug: String,
title: String,
}
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
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<AppState>, User(me): User) -> AppResult<Json<Value>> {
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<AppState>, User(me): User) -> AppResult<Json<Value>> {
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<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
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<AppState>, User(me): User) -> AppResult<Json<Value>> {
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 })))
}