v3 : serveur des objections, des coquilles validées et de la messagerie (lot F)

Objections « redit » / « contredit » à la place des signalements : la
dernière arrivée des deux versions n'est plus votable (repli, panier,
chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un
texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article
visé (proposition liée) ou en reformulant ; la personne qui objecte lève
ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection
est levée. Un administrateur peut lever.

Coquilles : la sienne passe tout de suite ; celle d'un autre attend
l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur
garde la main tant que le texte n'est pas en vigueur. En vigueur, seule
une coquille validée corrige (auteur de la version, administrateur pour
un texte semé). Deux coquilles en attente : la seconde se mesure au texte
voté ; à l'application, celle qui ne colle plus est périmée.

Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque
personne ne lit que les siens. Échéances par le balayage existant, avec
rattrapage au démarrage et horloge injectable (route de dev). Migration
0008 additive : les signalements deviennent des « contredit », sans perte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 02:45:48 +01:00
co-authored by Claude Opus 5.5
parent 70dbd712c8
commit 3f1f968f16
17 changed files with 2754 additions and 238 deletions
+238
View File
@@ -0,0 +1,238 @@
//! 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) 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::{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,
}
impl Topic {
pub fn as_str(self) -> &'static str {
match self {
Self::Objection => "objection",
Self::Typo => "typo",
}
}
}
/// 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),
},
_ => 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 })))
}