Skip to main content

max / makenotwork

10.7 KB · 297 lines History Blame Raw
1 //! post writes. Removal and restore cascade to the thread when the post is the OP
2
3 use super::{PgPool, Uuid};
4
5 /// Insert a reply and bump the thread's last_activity_at atomically.
6 ///
7 /// Opening posts are created with the thread via [`super::create_thread_with_op`];
8 /// this is for replies only. The display reply count derives from the
9 /// denormalized `threads.post_count`, which the m029 trigger maintains on every
10 /// post INSERT/DELETE/removed_at change, no counter to update by hand here.
11 #[tracing::instrument(skip_all)]
12 pub async fn create_post(
13 pool: &PgPool,
14 thread_id: Uuid,
15 author_id: Uuid,
16 body_markdown: &str,
17 body_html: &str,
18 ) -> Result<Uuid, sqlx::Error> {
19 let mut tx = pool.begin().await?;
20 let post_id = create_post_tx(&mut tx, thread_id, author_id, body_markdown, body_html)
21 .await?
22 // The pool-taking wrapper is for callers that have already established the
23 // thread is live (tests, simple internal paths); a locked/deleted thread
24 // here is a "row not inserted" error rather than a silent no-op.
25 .ok_or(sqlx::Error::RowNotFound)?;
26 tx.commit().await?;
27 Ok(post_id)
28 }
29
30 /// Insert a reply and bump `last_activity_at` on the caller's transaction.
31 ///
32 /// Lets a handler fold the reply *and its mentions* into one atomic unit so a
33 /// mid-sequence failure can't leave a committed reply whose @mentions silently
34 /// vanished (ultra-fuzz M-St1). The pool-taking [`create_post`] wraps this in
35 /// its own transaction for the simple, mention-free callers (and tests).
36 #[tracing::instrument(skip_all)]
37 pub async fn create_post_tx(
38 conn: &mut sqlx::PgConnection,
39 thread_id: Uuid,
40 author_id: Uuid,
41 body_markdown: &str,
42 body_html: &str,
43 ) -> Result<Option<Uuid>, sqlx::Error> {
44 // Insert only while the thread is still live and unlocked, in the same
45 // statement that reads that state, closing the TOCTOU between a handler's
46 // snapshot-time `locked`/`deleted_at` check and this insert. Without the
47 // guard a concurrent lock or soft-delete in the gap would let the reply
48 // commit (and the m029 post_count trigger increment a deleted thread). None
49 // = the thread was locked/deleted meanwhile; the caller surfaces it. This
50 // mirrors the conditional-write pattern `mod_remove_post_cascade` uses.
51 let post_id = sqlx::query_scalar!(
52 "INSERT INTO posts (thread_id, author_id, body_markdown, body_html)
53 SELECT $1, $2, $3, $4
54 WHERE EXISTS (
55 SELECT 1 FROM threads
56 WHERE id = $1 AND deleted_at IS NULL AND locked = false
57 )
58 RETURNING id",
59 thread_id,
60 author_id,
61 body_markdown,
62 body_html,
63 )
64 .fetch_optional(&mut *conn)
65 .await?;
66
67 // Only bump activity if the reply actually landed.
68 if post_id.is_some() {
69 sqlx::query!(
70 "UPDATE threads SET last_activity_at = now() WHERE id = $1",
71 thread_id
72 )
73 .execute(&mut *conn)
74 .await?;
75 }
76
77 Ok(post_id)
78 }
79
80 /// Idempotent reply insert keyed on `external_ref`, for the internal API.
81 ///
82 /// Mirrors [`super::create_thread_with_op_external_ref`]: a retried or replayed
83 /// server→server reply carrying the same `external_ref` inserts at most once.
84 /// `ON CONFLICT` on the partial unique index (m030) makes the repeat a no-op,
85 /// so the m029 post_count trigger fires exactly once and `last_activity_at` is
86 /// bumped only on the fresh insert. Returns `(post_id, created)`, `created` is
87 /// false when an existing post was returned. User-facing replies use the plain
88 /// [`create_post`] (no ref); this path is internal-only.
89 #[tracing::instrument(skip_all)]
90 pub async fn create_post_external_ref(
91 pool: &PgPool,
92 thread_id: Uuid,
93 author_id: Uuid,
94 external_ref: &str,
95 body_markdown: &str,
96 body_html: &str,
97 ) -> Result<(Uuid, bool), sqlx::Error> {
98 let mut tx = pool.begin().await?;
99
100 let inserted = sqlx::query_scalar!(
101 "INSERT INTO posts (thread_id, author_id, body_markdown, body_html, external_ref)
102 VALUES ($1, $2, $3, $4, $5)
103 ON CONFLICT (external_ref) WHERE external_ref IS NOT NULL DO NOTHING
104 RETURNING id",
105 thread_id,
106 author_id,
107 body_markdown,
108 body_html,
109 external_ref,
110 )
111 .fetch_optional(&mut *tx)
112 .await?;
113
114 let result = match inserted {
115 Some(id) => {
116 // Fresh reply: bump thread activity (the trigger already counted it).
117 sqlx::query!(
118 "UPDATE threads SET last_activity_at = now() WHERE id = $1",
119 thread_id
120 )
121 .execute(&mut *tx)
122 .await?;
123 (id, true)
124 }
125 None => {
126 // Replay: the reply already exists. Return its id, don't re-bump.
127 let existing =
128 sqlx::query_scalar!("SELECT id FROM posts WHERE external_ref = $1", external_ref,)
129 .fetch_one(&mut *tx)
130 .await?;
131 (existing, false)
132 }
133 };
134
135 tx.commit().await?;
136 Ok(result)
137 }
138
139 /// Test-only: mod-remove a single post without the OP-cascade.
140 ///
141 /// Production code must use [`mod_remove_post_cascade`], which also soft-deletes
142 /// the thread when the post is its opening post, removing an OP without the
143 /// cascade leaves a headless, still-repliable thread. This non-cascade variant
144 /// exists solely so the integration suite can stage a removed post directly; it
145 /// is gated behind the `test-support` feature so handler code cannot reach it.
146 #[cfg(feature = "test-support")]
147 #[tracing::instrument(skip_all)]
148 pub async fn mod_remove_post(
149 pool: &PgPool,
150 post_id: Uuid,
151 removed_by_id: Uuid,
152 ) -> Result<bool, sqlx::Error> {
153 let result = sqlx::query!(
154 "UPDATE posts SET removed_by = $2, removed_at = now()
155 WHERE id = $1 AND removed_at IS NULL",
156 post_id,
157 removed_by_id,
158 )
159 .execute(pool)
160 .await?;
161 Ok(result.rows_affected() > 0)
162 }
163
164 /// Outcome of [`mod_remove_post_cascade`].
165 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
166 pub struct PostRemoval {
167 /// The post was removed by this call (false if it was already removed).
168 pub post_removed: bool,
169 /// The post was the thread's opening post, so the whole thread was
170 /// soft-deleted as well.
171 pub thread_removed: bool,
172 }
173
174 /// Mod-remove a post and, if it is the thread's opening post, soft-delete the
175 /// whole thread in the same transaction.
176 ///
177 /// The opening post is the earliest post in the thread (by `created_at`, `id`
178 /// as tiebreak), counted regardless of removal state so the identity is stable.
179 /// Removing an OP without this cascade would leave a headless, still-repliable
180 /// thread; cascading deletes it so listings, the thread view, and the reply
181 /// path all treat it as gone. Returns false flags when the post was already
182 /// removed or was not the OP, callers can log a thread-deletion accordingly.
183 #[tracing::instrument(skip_all)]
184 pub async fn mod_remove_post_cascade(
185 conn: &mut sqlx::PgConnection,
186 post_id: Uuid,
187 removed_by_id: Uuid,
188 ) -> Result<PostRemoval, sqlx::Error> {
189 let post_removed = sqlx::query!(
190 "UPDATE posts SET removed_by = $2, removed_at = now()
191 WHERE id = $1 AND removed_at IS NULL",
192 post_id,
193 removed_by_id,
194 )
195 .execute(&mut *conn)
196 .await?
197 .rows_affected()
198 > 0;
199
200 let mut thread_removed = false;
201 if post_removed {
202 // Soft-delete the thread only when this post is its opening post.
203 thread_removed = sqlx::query!(
204 "UPDATE threads SET deleted_at = now()
205 WHERE deleted_at IS NULL
206 AND id = (SELECT thread_id FROM posts WHERE id = $1)
207 AND $1 = (
208 SELECT id FROM posts
209 WHERE thread_id = (SELECT thread_id FROM posts WHERE id = $1)
210 ORDER BY created_at ASC, id ASC
211 LIMIT 1
212 )",
213 post_id,
214 )
215 .execute(&mut *conn)
216 .await?
217 .rows_affected()
218 > 0;
219 }
220
221 Ok(PostRemoval {
222 post_removed,
223 thread_removed,
224 })
225 }
226
227 /// Outcome of [`restore_post_cascade`].
228 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
229 pub struct PostRestore {
230 /// The post was restored by this call (false if it was not removed).
231 pub post_restored: bool,
232 /// The post was the thread's opening post and the thread had been
233 /// soft-deleted alongside it, so the thread was restored too.
234 pub thread_restored: bool,
235 }
236
237 /// Reverse a removal: clear `removed_at`/`removed_by`, and if this post is the
238 /// thread's opening post and the thread was soft-deleted with it, un-delete the
239 /// thread in the same transaction.
240 ///
241 /// Mirrors [`mod_remove_post_cascade`] arm for arm, including the OP identity
242 /// rule (earliest by `created_at`, `id` as tiebreak, counted regardless of
243 /// removal state). Both the mod path and the flag-threshold auto-hide land in
244 /// the same `removed_at` state, so one reversal covers both; the caller decides
245 /// what to write to the mod log.
246 ///
247 /// Deliberately does NOT clear `deleted_at` on the post. That column is the
248 /// author's own soft-delete, a different actor's decision, and a moderator
249 /// undoing a moderator action must not also undo an author's. It is dormant
250 /// today (migration 031); the separation matters on the day it is not.
251 ///
252 /// Reachability caveat: a mod-removed OP cascades the thread to `deleted_at`,
253 /// and every thread loader filters on that, so such a post has no surface to be
254 /// restored from yet. Auto-hidden OPs never cascade, so they stay reachable.
255 /// The thread-restore arm here is what a future deleted-threads surface would
256 /// need, and keeps the reversal total rather than partial.
257 #[tracing::instrument(skip_all)]
258 pub async fn restore_post_cascade(
259 conn: &mut sqlx::PgConnection,
260 post_id: Uuid,
261 ) -> Result<PostRestore, sqlx::Error> {
262 let post_restored = sqlx::query!(
263 "UPDATE posts SET removed_by = NULL, removed_at = NULL
264 WHERE id = $1 AND removed_at IS NOT NULL",
265 post_id,
266 )
267 .execute(&mut *conn)
268 .await?
269 .rows_affected()
270 > 0;
271
272 let mut thread_restored = false;
273 if post_restored {
274 thread_restored = sqlx::query!(
275 "UPDATE threads SET deleted_at = NULL
276 WHERE deleted_at IS NOT NULL
277 AND id = (SELECT thread_id FROM posts WHERE id = $1)
278 AND $1 = (
279 SELECT id FROM posts
280 WHERE thread_id = (SELECT thread_id FROM posts WHERE id = $1)
281 ORDER BY created_at ASC, id ASC
282 LIMIT 1
283 )",
284 post_id,
285 )
286 .execute(&mut *conn)
287 .await?
288 .rows_affected()
289 > 0;
290 }
291
292 Ok(PostRestore {
293 post_restored,
294 thread_restored,
295 })
296 }
297