1use rusqlite::{params, Connection, OptionalExtension};
16
17pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum BoardGate {
53 Open,
55}
56
57pub fn check_post_allowed(_gate: BoardGate, _user_id: i64) -> Result<(), &'static str> {
59 Ok(())
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Thread {
65 pub id: i64,
67 pub board_id: i64,
69 pub author_user_id: i64,
71 pub title: String,
73 pub body: String,
75 pub created_ms: i64,
77 pub locked: bool,
79}
80
81pub 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
87pub 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
97pub 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
108pub 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
117pub 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}