Skip to main content

max / makenotwork

10.4 KB · 339 lines History Blame Raw
1 //! Blog post CRUD with Markdown rendering and publish/unpublish lifecycle.
2
3 use chrono::Utc;
4 use sqlx::PgPool;
5
6 use super::models::DbBlogPost;
7 use super::validated_types::Slug;
8 use super::{BlogPostId, MtThreadId, ProjectId, UserId};
9 use crate::error::Result;
10
11 /// Insert a new blog post and return the created row.
12 #[allow(clippy::too_many_arguments)]
13 #[tracing::instrument(skip_all)]
14 pub async fn create_blog_post(
15 pool: &PgPool,
16 project_id: ProjectId,
17 author_id: UserId,
18 title: &str,
19 slug: &Slug,
20 body_markdown: &str,
21 body_html: &str,
22 publish: bool,
23 web_only: bool,
24 show_on_landing: bool,
25 ) -> Result<DbBlogPost> {
26 let published_at = if publish { Some(Utc::now()) } else { None };
27
28 let post = sqlx::query_as::<_, DbBlogPost>(
29 r"
30 INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only, show_on_landing)
31 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
32 RETURNING *
33 ",
34 )
35 .bind(project_id)
36 .bind(author_id)
37 .bind(title)
38 .bind(slug)
39 .bind(body_markdown)
40 .bind(body_html)
41 .bind(published_at)
42 .bind(web_only)
43 .bind(show_on_landing)
44 .fetch_one(pool)
45 .await?;
46
47 Ok(post)
48 }
49
50 /// Fetch a blog post by primary key. Returns `None` if not found.
51 #[tracing::instrument(skip_all)]
52 pub async fn get_blog_post_by_id(pool: &PgPool, id: BlogPostId) -> Result<Option<DbBlogPost>> {
53 let post = sqlx::query_as::<_, DbBlogPost>("SELECT * FROM blog_posts WHERE id = $1")
54 .bind(id)
55 .fetch_optional(pool)
56 .await?;
57
58 Ok(post)
59 }
60
61 /// Fetch a blog post by project and slug. Returns `None` if not found.
62 #[tracing::instrument(skip_all)]
63 pub async fn get_blog_post_by_slug(
64 pool: &PgPool,
65 project_id: ProjectId,
66 slug: &Slug,
67 ) -> Result<Option<DbBlogPost>> {
68 let post = sqlx::query_as::<_, DbBlogPost>(
69 "SELECT * FROM blog_posts WHERE project_id = $1 AND slug = $2",
70 )
71 .bind(project_id)
72 .bind(slug)
73 .fetch_optional(pool)
74 .await?;
75
76 Ok(post)
77 }
78
79 /// List all blog posts in a project (for dashboard), newest first.
80 #[tracing::instrument(skip_all)]
81 pub async fn get_blog_posts_by_project(
82 pool: &PgPool,
83 project_id: ProjectId,
84 ) -> Result<Vec<DbBlogPost>> {
85 let posts = sqlx::query_as::<_, DbBlogPost>(
86 "SELECT * FROM blog_posts WHERE project_id = $1 ORDER BY created_at DESC LIMIT 500",
87 )
88 .bind(project_id)
89 .fetch_all(pool)
90 .await?;
91
92 Ok(posts)
93 }
94
95 /// Batch-load blog posts for multiple projects, grouped by project_id.
96 #[tracing::instrument(skip_all)]
97 pub async fn get_blog_posts_by_projects(
98 pool: &PgPool,
99 project_ids: &[ProjectId],
100 ) -> Result<std::collections::HashMap<ProjectId, Vec<DbBlogPost>>> {
101 let posts = sqlx::query_as::<_, DbBlogPost>(
102 "SELECT * FROM blog_posts WHERE project_id = ANY($1) ORDER BY project_id, created_at DESC",
103 )
104 .bind(project_ids)
105 .fetch_all(pool)
106 .await?;
107
108 let mut map: std::collections::HashMap<ProjectId, Vec<DbBlogPost>> =
109 std::collections::HashMap::new();
110 for p in posts {
111 map.entry(p.project_id).or_default().push(p);
112 }
113 Ok(map)
114 }
115
116 /// List published blog posts in a project (for public pages), newest first.
117 #[tracing::instrument(skip_all)]
118 pub async fn get_published_blog_posts_by_project(
119 pool: &PgPool,
120 project_id: ProjectId,
121 ) -> Result<Vec<DbBlogPost>> {
122 let posts = sqlx::query_as::<_, DbBlogPost>(
123 "SELECT * FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 500",
124 )
125 .bind(project_id)
126 .fetch_all(pool)
127 .await?;
128
129 Ok(posts)
130 }
131
132 /// Fetch the single landing-page "Last shipped" post: the most recent
133 /// published, landing-flagged post belonging to a public project with the
134 /// given slug (CHANGELOG_PROJECT_SLUG). Returns `None` when nothing qualifies,
135 /// which the landing route uses to suppress the velocity line entirely.
136 ///
137 /// `show_on_landing` is set on rows across every project, but the slug join
138 /// confines the landing reader to the changelog project, so the flag is inert
139 /// elsewhere.
140 #[tracing::instrument(skip_all)]
141 pub async fn get_landing_changelog_post(
142 pool: &PgPool,
143 changelog_slug: &str,
144 ) -> Result<Option<DbBlogPost>> {
145 let post = sqlx::query_as::<_, DbBlogPost>(
146 r"
147 SELECT bp.*
148 FROM blog_posts bp
149 JOIN projects p ON p.id = bp.project_id
150 WHERE p.slug = $1
151 AND p.is_public = true
152 AND bp.show_on_landing = true
153 AND bp.published_at IS NOT NULL
154 ORDER BY bp.published_at DESC
155 LIMIT 1
156 ",
157 )
158 .bind(changelog_slug)
159 .fetch_optional(pool)
160 .await?;
161
162 Ok(post)
163 }
164
165 /// Update a blog post's fields.
166 ///
167 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
168 /// `Some(Some(dt))` = set schedule. When a schedule is set, `published_at` stays NULL
169 /// (the scheduler will set it when the time comes).
170 ///
171 /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update.
172 ///
173 /// `publish` uses `Option<bool>`: `None` = leave `published_at` untouched (a
174 /// background auto-save passes `None` so it can never silently unpublish a live
175 /// post), `Some(true)` = publish, `Some(false)` = unpublish.
176 #[allow(clippy::too_many_arguments)]
177 #[tracing::instrument(skip_all)]
178 pub async fn update_blog_post(
179 pool: &PgPool,
180 id: BlogPostId,
181 title: &str,
182 slug: &Slug,
183 body_markdown: &str,
184 body_html: &str,
185 publish: Option<bool>,
186 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
187 web_only: Option<bool>,
188 show_on_landing: Option<bool>,
189 ) -> Result<DbBlogPost> {
190 let update_publish_at = publish_at.is_some();
191 let publish_at_value = publish_at.flatten();
192 let change_publish = publish.is_some();
193 let publish_value = publish.unwrap_or(false);
194
195 // Five-way CASE for published_at:
196 // 1. Scheduling (publish_at is being set) → keep NULL (scheduler handles it)
197 // 2. Explicit first publish (publish=Some(true), published_at IS NULL) → set to NOW()
198 // 3. Explicit unpublish (publish=Some(false)) → clear to NULL
199 // 4. No publish-state change (publish=None, e.g. auto-save) → preserve
200 // 5. Re-save while published (publish=Some(true), already published) → preserve
201 let post = sqlx::query_as::<_, DbBlogPost>(
202 r"
203 UPDATE blog_posts
204 SET title = $2,
205 slug = $3,
206 body_markdown = $4,
207 body_html = $5,
208 published_at = CASE
209 WHEN $7 = true AND $8 IS NOT NULL THEN NULL
210 WHEN $11 = true AND $6 = true AND published_at IS NULL THEN NOW()
211 WHEN $11 = true AND $6 = false THEN NULL
212 ELSE published_at
213 END,
214 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
215 web_only = COALESCE($9, web_only),
216 show_on_landing = COALESCE($10, show_on_landing),
217 updated_at = NOW()
218 WHERE id = $1
219 RETURNING *
220 ",
221 )
222 .bind(id)
223 .bind(title)
224 .bind(slug)
225 .bind(body_markdown)
226 .bind(body_html)
227 .bind(publish_value)
228 .bind(update_publish_at)
229 .bind(publish_at_value)
230 .bind(web_only)
231 .bind(show_on_landing)
232 .bind(change_publish)
233 .fetch_one(pool)
234 .await?;
235
236 Ok(post)
237 }
238
239 /// Publish all blog posts whose scheduled publish time has passed.
240 ///
241 /// Atomically sets `published_at = NOW()` and clears `publish_at`, returning
242 /// the newly published posts for logging.
243 #[tracing::instrument(skip_all)]
244 pub async fn publish_scheduled_blog_posts(pool: &PgPool) -> Result<Vec<DbBlogPost>> {
245 let posts = sqlx::query_as::<_, DbBlogPost>(
246 r"
247 UPDATE blog_posts
248 SET published_at = NOW(), publish_at = NULL, updated_at = NOW()
249 WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND published_at IS NULL
250 RETURNING *
251 ",
252 )
253 .fetch_all(pool)
254 .await?;
255
256 Ok(posts)
257 }
258
259 /// Permanently delete a blog post owned by `owner_id`.
260 ///
261 /// Ownership is scoped IN the SQL (`project_id IN (SELECT id FROM projects WHERE
262 /// user_id = $2)`) so the delete can't remove another user's post even if a
263 /// caller skips the upstream ownership check (Sec-M2 defense in depth, mirroring
264 /// `media_files::delete`). Returns `true` if a post was deleted, `false` if none
265 /// matched for `owner_id`.
266 #[tracing::instrument(skip_all)]
267 pub async fn delete_blog_post(pool: &PgPool, id: BlogPostId, owner_id: UserId) -> Result<bool> {
268 let res = sqlx::query(
269 "DELETE FROM blog_posts \
270 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
271 )
272 .bind(id)
273 .bind(owner_id)
274 .execute(pool)
275 .await?;
276
277 Ok(res.rows_affected() > 0)
278 }
279
280 /// Set the linked MT thread ID for a blog post.
281 #[tracing::instrument(skip_all)]
282 pub async fn set_mt_thread_id(
283 pool: &PgPool,
284 blog_post_id: BlogPostId,
285 thread_id: MtThreadId,
286 ) -> Result<()> {
287 sqlx::query("UPDATE blog_posts SET mt_thread_id = $2 WHERE id = $1")
288 .bind(blog_post_id)
289 .bind(thread_id)
290 .execute(pool)
291 .await?;
292 Ok(())
293 }
294
295 /// Check if a project has any published blog posts.
296 #[tracing::instrument(skip_all)]
297 pub async fn has_published_posts(pool: &PgPool, project_id: ProjectId) -> Result<bool> {
298 let exists: bool = sqlx::query_scalar(
299 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL)",
300 )
301 .bind(project_id)
302 .fetch_one(pool)
303 .await?;
304
305 Ok(exists)
306 }
307
308 /// Atomically mark a blog post as having had its release announced.
309 /// Returns false if already announced (prevents duplicate announcements on unpublish/republish).
310 #[tracing::instrument(skip_all)]
311 pub async fn mark_blog_post_announced(pool: &PgPool, post_id: BlogPostId) -> Result<bool> {
312 let result = sqlx::query(
313 "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1 AND release_announced_at IS NULL",
314 )
315 .bind(post_id)
316 .execute(pool)
317 .await?;
318
319 Ok(result.rows_affected() > 0)
320 }
321
322 /// Check if a slug already exists for a project.
323 #[tracing::instrument(skip_all)]
324 pub async fn blog_post_slug_exists(
325 pool: &PgPool,
326 project_id: ProjectId,
327 slug: &Slug,
328 ) -> Result<bool> {
329 let exists: bool = sqlx::query_scalar(
330 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND slug = $2)",
331 )
332 .bind(project_id)
333 .bind(slug)
334 .fetch_one(pool)
335 .await?;
336
337 Ok(exists)
338 }
339