forked from yvv/decision
248 lines
8.3 KiB
Rust
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 })))
|
|
}
|