Skip to main content

max / makenotwork

9.4 KB · 320 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::*;
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>> = std::collections::HashMap::new();
109 for p in posts {
110 map.entry(p.project_id).or_default().push(p);
111 }
112 Ok(map)
113 }
114
115 /// List published blog posts in a project (for public pages), newest first.
116 #[tracing::instrument(skip_all)]
117 pub async fn get_published_blog_posts_by_project(
118 pool: &PgPool,
119 project_id: ProjectId,
120 ) -> Result<Vec<DbBlogPost>> {
121 let posts = sqlx::query_as::<_, DbBlogPost>(
122 "SELECT * FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 500",
123 )
124 .bind(project_id)
125 .fetch_all(pool)
126 .await?;
127
128 Ok(posts)
129 }
130
131 /// Fetch the single landing-page "Last shipped" post: the most recent
132 /// published, landing-flagged post belonging to a public project with the
133 /// given slug (CHANGELOG_PROJECT_SLUG). Returns `None` when nothing qualifies,
134 /// which the landing route uses to suppress the velocity line entirely.
135 ///
136 /// `show_on_landing` is set on rows across every project, but the slug join
137 /// confines the landing reader to the changelog project, so the flag is inert
138 /// elsewhere.
139 #[tracing::instrument(skip_all)]
140 pub async fn get_landing_changelog_post(
141 pool: &PgPool,
142 changelog_slug: &str,
143 ) -> Result<Option<DbBlogPost>> {
144 let post = sqlx::query_as::<_, DbBlogPost>(
145 r#"
146 SELECT bp.*
147 FROM blog_posts bp
148 JOIN projects p ON p.id = bp.project_id
149 WHERE p.slug = $1
150 AND p.is_public = true
151 AND bp.show_on_landing = true
152 AND bp.published_at IS NOT NULL
153 ORDER BY bp.published_at DESC
154 LIMIT 1
155 "#,
156 )
157 .bind(changelog_slug)
158 .fetch_optional(pool)
159 .await?;
160
161 Ok(post)
162 }
163
164 /// Update a blog post's fields.
165 ///
166 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
167 /// `Some(Some(dt))` = set schedule. When a schedule is set, `published_at` stays NULL
168 /// (the scheduler will set it when the time comes).
169 ///
170 /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update.
171 #[allow(clippy::too_many_arguments)]
172 #[tracing::instrument(skip_all)]
173 pub async fn update_blog_post(
174 pool: &PgPool,
175 id: BlogPostId,
176 title: &str,
177 slug: &Slug,
178 body_markdown: &str,
179 body_html: &str,
180 publish: bool,
181 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
182 web_only: Option<bool>,
183 show_on_landing: Option<bool>,
184 ) -> Result<DbBlogPost> {
185 let update_publish_at = publish_at.is_some();
186 let publish_at_value = publish_at.flatten();
187
188 // Four-way CASE for published_at:
189 // 1. Scheduling (publish_at is being set) → keep NULL (scheduler handles it)
190 // 2. First publish (publish=true, published_at IS NULL) → set to NOW()
191 // 3. Unpublish (publish=false) → clear to NULL
192 // 4. Re-save while published → preserve existing timestamp
193 let post = sqlx::query_as::<_, DbBlogPost>(
194 r#"
195 UPDATE blog_posts
196 SET title = $2,
197 slug = $3,
198 body_markdown = $4,
199 body_html = $5,
200 published_at = CASE
201 WHEN $7 = true AND $8 IS NOT NULL THEN NULL
202 WHEN $6 = true AND published_at IS NULL THEN NOW()
203 WHEN $6 = false THEN NULL
204 ELSE published_at
205 END,
206 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
207 web_only = COALESCE($9, web_only),
208 show_on_landing = COALESCE($10, show_on_landing),
209 updated_at = NOW()
210 WHERE id = $1
211 RETURNING *
212 "#,
213 )
214 .bind(id)
215 .bind(title)
216 .bind(slug)
217 .bind(body_markdown)
218 .bind(body_html)
219 .bind(publish)
220 .bind(update_publish_at)
221 .bind(publish_at_value)
222 .bind(web_only)
223 .bind(show_on_landing)
224 .fetch_one(pool)
225 .await?;
226
227 Ok(post)
228 }
229
230 /// Publish all blog posts whose scheduled publish time has passed.
231 ///
232 /// Atomically sets `published_at = NOW()` and clears `publish_at`, returning
233 /// the newly published posts for logging.
234 #[tracing::instrument(skip_all)]
235 pub async fn publish_scheduled_blog_posts(pool: &PgPool) -> Result<Vec<DbBlogPost>> {
236 let posts = sqlx::query_as::<_, DbBlogPost>(
237 r#"
238 UPDATE blog_posts
239 SET published_at = NOW(), publish_at = NULL, updated_at = NOW()
240 WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND published_at IS NULL
241 RETURNING *
242 "#,
243 )
244 .fetch_all(pool)
245 .await?;
246
247 Ok(posts)
248 }
249
250 /// Permanently delete a blog post by ID.
251 #[tracing::instrument(skip_all)]
252 pub async fn delete_blog_post(pool: &PgPool, id: BlogPostId) -> Result<()> {
253 sqlx::query("DELETE FROM blog_posts WHERE id = $1")
254 .bind(id)
255 .execute(pool)
256 .await?;
257
258 Ok(())
259 }
260
261 /// Set the linked MT thread ID for a blog post.
262 #[tracing::instrument(skip_all)]
263 pub async fn set_mt_thread_id(
264 pool: &PgPool,
265 blog_post_id: BlogPostId,
266 thread_id: MtThreadId,
267 ) -> Result<()> {
268 sqlx::query("UPDATE blog_posts SET mt_thread_id = $2 WHERE id = $1")
269 .bind(blog_post_id)
270 .bind(thread_id)
271 .execute(pool)
272 .await?;
273 Ok(())
274 }
275
276 /// Check if a project has any published blog posts.
277 #[tracing::instrument(skip_all)]
278 pub async fn has_published_posts(pool: &PgPool, project_id: ProjectId) -> Result<bool> {
279 let exists: bool = sqlx::query_scalar(
280 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL)",
281 )
282 .bind(project_id)
283 .fetch_one(pool)
284 .await?;
285
286 Ok(exists)
287 }
288
289 /// Atomically mark a blog post as having had its release announced.
290 /// Returns false if already announced (prevents duplicate announcements on unpublish/republish).
291 #[tracing::instrument(skip_all)]
292 pub async fn mark_blog_post_announced(pool: &PgPool, post_id: BlogPostId) -> Result<bool> {
293 let result = sqlx::query(
294 "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1 AND release_announced_at IS NULL",
295 )
296 .bind(post_id)
297 .execute(pool)
298 .await?;
299
300 Ok(result.rows_affected() > 0)
301 }
302
303 /// Check if a slug already exists for a project.
304 #[tracing::instrument(skip_all)]
305 pub async fn blog_post_slug_exists(
306 pool: &PgPool,
307 project_id: ProjectId,
308 slug: &Slug,
309 ) -> Result<bool> {
310 let exists: bool = sqlx::query_scalar(
311 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND slug = $2)",
312 )
313 .bind(project_id)
314 .bind(slug)
315 .fetch_one(pool)
316 .await?;
317
318 Ok(exists)
319 }
320