Skip to main content

mail4agent_server/
public_forum.rs

1//! Public forum store (plaintext, server-side state). Seam only: storage and a
2//! small API, no HTTP routes yet.
3//!
4//! Same boundary rule as [`crate::public_channels`]: tables `pf_boards`,
5//! `pf_threads`, `pf_replies` belong to this module; the closed store
6//! (`events`, `relations`, `receipts`, `txn_dedup`, keys) never reads or
7//! writes them and [`crate::retention`] never deletes them. A forum board is
8//! not a room and a thread is not an event. The chart's own forum (social.db,
9//! forum v1) is a different product and is not copied here; this is the
10//! neutral open store both MLC and m4a can land on.
11//!
12//! Role gates are a named seam: [`BoardGate`] has only `Open` today;
13//! `check_post_allowed` is the single place a role/paywall check will go.
14
15use rusqlite::{params, Connection, OptionalExtension};
16
17/// DDL. Called from `create_matrix_schema` beside the other stores.
18pub fn create_forum_schema(conn: &Connection) -> rusqlite::Result<()> {
19    conn.execute_batch(
20        r#"
21        CREATE TABLE IF NOT EXISTS pf_boards (
22            id         INTEGER PRIMARY KEY AUTOINCREMENT,
23            slug       TEXT NOT NULL UNIQUE,
24            title      TEXT NOT NULL,
25            gate       TEXT NOT NULL DEFAULT 'open',
26            created_ms INTEGER NOT NULL
27        );
28        CREATE TABLE IF NOT EXISTS pf_threads (
29            id             INTEGER PRIMARY KEY AUTOINCREMENT,
30            board_id       INTEGER NOT NULL REFERENCES pf_boards(id),
31            author_user_id INTEGER NOT NULL,
32            title          TEXT NOT NULL,
33            body           TEXT NOT NULL,
34            created_ms     INTEGER NOT NULL,
35            locked         INTEGER NOT NULL DEFAULT 0
36        );
37        CREATE INDEX IF NOT EXISTS idx_pf_threads_board ON pf_threads(board_id, id);
38        CREATE TABLE IF NOT EXISTS pf_replies (
39            id             INTEGER PRIMARY KEY AUTOINCREMENT,
40            thread_id      INTEGER NOT NULL REFERENCES pf_threads(id),
41            author_user_id INTEGER NOT NULL,
42            body           TEXT NOT NULL,
43            created_ms     INTEGER NOT NULL
44        );
45        CREATE INDEX IF NOT EXISTS idx_pf_replies_thread ON pf_replies(thread_id, id);
46        "#,
47    )
48}
49
50/// Who may post on a board. Only `Open` exists; role/paywall gates plug in here later.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BoardGate {
53    /// Any signed-in user.
54    Open,
55}
56
57/// The one place a role gate will be enforced.
58pub fn check_post_allowed(_gate: BoardGate, _user_id: i64) -> Result<(), &'static str> {
59    Ok(())
60}
61
62/// A thread row.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Thread {
65    /// Thread id.
66    pub id: i64,
67    /// Board id.
68    pub board_id: i64,
69    /// Author.
70    pub author_user_id: i64,
71    /// Title.
72    pub title: String,
73    /// Opening post body (plaintext).
74    pub body: String,
75    /// Creation time, ms.
76    pub created_ms: i64,
77    /// Locked threads take no replies.
78    pub locked: bool,
79}
80
81/// Creates a board (idempotent per slug) and returns its id.
82pub fn create_board(conn: &Connection, slug: &str, title: &str, now_ms: i64) -> rusqlite::Result<i64> {
83    conn.execute("INSERT OR IGNORE INTO pf_boards (slug, title, created_ms) VALUES (?1, ?2, ?3)", params![slug, title, now_ms])?;
84    conn.query_row("SELECT id FROM pf_boards WHERE slug = ?1", params![slug], |r| r.get(0))
85}
86
87/// Opens a thread on a board.
88pub fn create_thread(conn: &Connection, board_id: i64, user_id: i64, title: &str, body: &str, now_ms: i64) -> rusqlite::Result<i64> {
89    check_post_allowed(BoardGate::Open, user_id).map_err(|_| rusqlite::Error::InvalidQuery)?;
90    conn.execute(
91        "INSERT INTO pf_threads (board_id, author_user_id, title, body, created_ms) VALUES (?1,?2,?3,?4,?5)",
92        params![board_id, user_id, title, body, now_ms],
93    )?;
94    Ok(conn.last_insert_rowid())
95}
96
97/// Replies to a thread. `Ok(None)` when the thread is locked or absent.
98pub fn reply(conn: &Connection, thread_id: i64, user_id: i64, body: &str, now_ms: i64) -> rusqlite::Result<Option<i64>> {
99    let locked: Option<i64> = conn.query_row("SELECT locked FROM pf_threads WHERE id = ?1", params![thread_id], |r| r.get(0)).optional()?;
100    if locked != Some(0) {
101        return Ok(None);
102    }
103    check_post_allowed(BoardGate::Open, user_id).map_err(|_| rusqlite::Error::InvalidQuery)?;
104    conn.execute("INSERT INTO pf_replies (thread_id, author_user_id, body, created_ms) VALUES (?1,?2,?3,?4)", params![thread_id, user_id, body, now_ms])?;
105    Ok(Some(conn.last_insert_rowid()))
106}
107
108/// Newest threads first.
109pub fn list_threads(conn: &Connection, board_id: i64, limit: i64) -> rusqlite::Result<Vec<Thread>> {
110    let mut stmt = conn.prepare("SELECT id, board_id, author_user_id, title, body, created_ms, locked FROM pf_threads WHERE board_id = ?1 ORDER BY id DESC LIMIT ?2")?;
111    let rows = stmt.query_map(params![board_id, limit], |r| {
112        Ok(Thread { id: r.get(0)?, board_id: r.get(1)?, author_user_id: r.get(2)?, title: r.get(3)?, body: r.get(4)?, created_ms: r.get(5)?, locked: r.get::<_, i64>(6)? != 0 })
113    })?;
114    rows.collect()
115}
116
117/// Replies in posting order: `(id, author, body, created_ms)`.
118pub fn list_replies(conn: &Connection, thread_id: i64) -> rusqlite::Result<Vec<(i64, i64, String, i64)>> {
119    let mut stmt = conn.prepare("SELECT id, author_user_id, body, created_ms FROM pf_replies WHERE thread_id = ?1 ORDER BY id")?;
120    let rows = stmt.query_map(params![thread_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?;
121    rows.collect()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn db() -> Connection {
129        let conn = Connection::open_in_memory().unwrap();
130        crate::store::create_matrix_schema(&conn).unwrap();
131        crate::keys::create_matrix_keys_schema(&conn).unwrap();
132        crate::retention::create_retention_schema(&conn).unwrap();
133        conn
134    }
135
136    #[test]
137    fn board_thread_reply_roundtrip_and_lock() {
138        let conn = db();
139        let board = create_board(&conn, "general", "General", 1).unwrap();
140        assert_eq!(create_board(&conn, "general", "x", 2).unwrap(), board);
141        let t = create_thread(&conn, board, 7, "hello", "first post", 3).unwrap();
142        assert!(reply(&conn, t, 8, "re", 4).unwrap().is_some());
143        assert_eq!(list_replies(&conn, t).unwrap().len(), 1);
144        assert_eq!(list_threads(&conn, board, 10).unwrap()[0].body, "first post");
145        conn.execute("UPDATE pf_threads SET locked = 1 WHERE id = ?1", params![t]).unwrap();
146        assert!(reply(&conn, t, 8, "late", 5).unwrap().is_none());
147    }
148
149    #[test]
150    fn closed_store_and_retention_never_touch_the_forum() {
151        let mut conn = db();
152        let board = create_board(&conn, "b", "B", 1).unwrap();
153        create_thread(&conn, board, 1, "t", "body", 1).unwrap();
154        let policy = crate::retention::RetentionPolicy { ttl_ms: 1, ack_grace_ms: 0, keep_last: 0, stale_device_ms: 1 };
155        crate::retention::purge_delivered_events(&mut conn, 10_000_000, &policy).unwrap();
156        let n: i64 = conn.query_row("SELECT COUNT(*) FROM pf_threads", [], |r| r.get(0)).unwrap();
157        assert_eq!(n, 1);
158        for table in ["events", "relations", "receipts", "txn_dedup"] {
159            let c: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)).unwrap();
160            assert_eq!(c, 0, "{table}");
161        }
162    }
163}