Skip to main content

max / makenotwork

8.1 KB · 282 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 ) -> Result<DbBlogPost> {
25 let published_at = if publish { Some(Utc::now()) } else { None };
26
27 let post = sqlx::query_as::<_, DbBlogPost>(
28 r#"
29 INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only)
30 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
31 RETURNING *
32 "#,
33 )
34 .bind(project_id)
35 .bind(author_id)
36 .bind(title)
37 .bind(slug)
38 .bind(body_markdown)
39 .bind(body_html)
40 .bind(published_at)
41 .bind(web_only)
42 .fetch_one(pool)
43 .await?;
44
45 Ok(post)
46 }
47
48 /// Fetch a blog post by primary key. Returns `None` if not found.
49 #[tracing::instrument(skip_all)]
50 pub async fn get_blog_post_by_id(pool: &PgPool, id: BlogPostId) -> Result<Option<DbBlogPost>> {
51 let post = sqlx::query_as::<_, DbBlogPost>("SELECT * FROM blog_posts WHERE id = $1")
52 .bind(id)
53 .fetch_optional(pool)
54 .await?;
55
56 Ok(post)
57 }
58
59 /// Fetch a blog post by project and slug. Returns `None` if not found.
60 #[tracing::instrument(skip_all)]
61 pub async fn get_blog_post_by_slug(
62 pool: &PgPool,
63 project_id: ProjectId,
64 slug: &Slug,
65 ) -> Result<Option<DbBlogPost>> {
66 let post = sqlx::query_as::<_, DbBlogPost>(
67 "SELECT * FROM blog_posts WHERE project_id = $1 AND slug = $2",
68 )
69 .bind(project_id)
70 .bind(slug)
71 .fetch_optional(pool)
72 .await?;
73
74 Ok(post)
75 }
76
77 /// List all blog posts in a project (for dashboard), newest first.
78 #[tracing::instrument(skip_all)]
79 pub async fn get_blog_posts_by_project(
80 pool: &PgPool,
81 project_id: ProjectId,
82 ) -> Result<Vec<DbBlogPost>> {
83 let posts = sqlx::query_as::<_, DbBlogPost>(
84 "SELECT * FROM blog_posts WHERE project_id = $1 ORDER BY created_at DESC LIMIT 500",
85 )
86 .bind(project_id)
87 .fetch_all(pool)
88 .await?;
89
90 Ok(posts)
91 }
92
93 /// Batch-load blog posts for multiple projects, grouped by project_id.
94 #[tracing::instrument(skip_all)]
95 pub async fn get_blog_posts_by_projects(
96 pool: &PgPool,
97 project_ids: &[ProjectId],
98 ) -> Result<std::collections::HashMap<ProjectId, Vec<DbBlogPost>>> {
99 let posts = sqlx::query_as::<_, DbBlogPost>(
100 "SELECT * FROM blog_posts WHERE project_id = ANY($1) ORDER BY project_id, created_at DESC",
101 )
102 .bind(project_ids)
103 .fetch_all(pool)
104 .await?;
105
106 let mut map: std::collections::HashMap<ProjectId, Vec<DbBlogPost>> = std::collections::HashMap::new();
107 for p in posts {
108 map.entry(p.project_id).or_default().push(p);
109 }
110 Ok(map)
111 }
112
113 /// List published blog posts in a project (for public pages), newest first.
114 #[tracing::instrument(skip_all)]
115 pub async fn get_published_blog_posts_by_project(
116 pool: &PgPool,
117 project_id: ProjectId,
118 ) -> Result<Vec<DbBlogPost>> {
119 let posts = sqlx::query_as::<_, DbBlogPost>(
120 "SELECT * FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 500",
121 )
122 .bind(project_id)
123 .fetch_all(pool)
124 .await?;
125
126 Ok(posts)
127 }
128
129 /// Update a blog post's fields.
130 ///
131 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
132 /// `Some(Some(dt))` = set schedule. When a schedule is set, `published_at` stays NULL
133 /// (the scheduler will set it when the time comes).
134 ///
135 /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update.
136 #[allow(clippy::too_many_arguments)]
137 #[tracing::instrument(skip_all)]
138 pub async fn update_blog_post(
139 pool: &PgPool,
140 id: BlogPostId,
141 title: &str,
142 slug: &Slug,
143 body_markdown: &str,
144 body_html: &str,
145 publish: bool,
146 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
147 web_only: Option<bool>,
148 ) -> Result<DbBlogPost> {
149 let update_publish_at = publish_at.is_some();
150 let publish_at_value = publish_at.flatten();
151
152 // Four-way CASE for published_at:
153 // 1. Scheduling (publish_at is being set) → keep NULL (scheduler handles it)
154 // 2. First publish (publish=true, published_at IS NULL) → set to NOW()
155 // 3. Unpublish (publish=false) → clear to NULL
156 // 4. Re-save while published → preserve existing timestamp
157 let post = sqlx::query_as::<_, DbBlogPost>(
158 r#"
159 UPDATE blog_posts
160 SET title = $2,
161 slug = $3,
162 body_markdown = $4,
163 body_html = $5,
164 published_at = CASE
165 WHEN $7 = true AND $8 IS NOT NULL THEN NULL
166 WHEN $6 = true AND published_at IS NULL THEN NOW()
167 WHEN $6 = false THEN NULL
168 ELSE published_at
169 END,
170 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
171 web_only = COALESCE($9, web_only),
172 updated_at = NOW()
173 WHERE id = $1
174 RETURNING *
175 "#,
176 )
177 .bind(id)
178 .bind(title)
179 .bind(slug)
180 .bind(body_markdown)
181 .bind(body_html)
182 .bind(publish)
183 .bind(update_publish_at)
184 .bind(publish_at_value)
185 .bind(web_only)
186 .fetch_one(pool)
187 .await?;
188
189 Ok(post)
190 }
191
192 /// Publish all blog posts whose scheduled publish time has passed.
193 ///
194 /// Atomically sets `published_at = NOW()` and clears `publish_at`, returning
195 /// the newly published posts for logging.
196 #[tracing::instrument(skip_all)]
197 pub async fn publish_scheduled_blog_posts(pool: &PgPool) -> Result<Vec<DbBlogPost>> {
198 let posts = sqlx::query_as::<_, DbBlogPost>(
199 r#"
200 UPDATE blog_posts
201 SET published_at = NOW(), publish_at = NULL, updated_at = NOW()
202 WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND published_at IS NULL
203 RETURNING *
204 "#,
205 )
206 .fetch_all(pool)
207 .await?;
208
209 Ok(posts)
210 }
211
212 /// Permanently delete a blog post by ID.
213 #[tracing::instrument(skip_all)]
214 pub async fn delete_blog_post(pool: &PgPool, id: BlogPostId) -> Result<()> {
215 sqlx::query("DELETE FROM blog_posts WHERE id = $1")
216 .bind(id)
217 .execute(pool)
218 .await?;
219
220 Ok(())
221 }
222
223 /// Set the linked MT thread ID for a blog post.
224 #[tracing::instrument(skip_all)]
225 pub async fn set_mt_thread_id(
226 pool: &PgPool,
227 blog_post_id: BlogPostId,
228 thread_id: MtThreadId,
229 ) -> Result<()> {
230 sqlx::query("UPDATE blog_posts SET mt_thread_id = $2 WHERE id = $1")
231 .bind(blog_post_id)
232 .bind(thread_id)
233 .execute(pool)
234 .await?;
235 Ok(())
236 }
237
238 /// Check if a project has any published blog posts.
239 #[tracing::instrument(skip_all)]
240 pub async fn has_published_posts(pool: &PgPool, project_id: ProjectId) -> Result<bool> {
241 let exists: bool = sqlx::query_scalar(
242 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND published_at IS NOT NULL)",
243 )
244 .bind(project_id)
245 .fetch_one(pool)
246 .await?;
247
248 Ok(exists)
249 }
250
251 /// Atomically mark a blog post as having had its release announced.
252 /// Returns false if already announced (prevents duplicate announcements on unpublish/republish).
253 #[tracing::instrument(skip_all)]
254 pub async fn mark_blog_post_announced(pool: &PgPool, post_id: BlogPostId) -> Result<bool> {
255 let result = sqlx::query(
256 "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1 AND release_announced_at IS NULL",
257 )
258 .bind(post_id)
259 .execute(pool)
260 .await?;
261
262 Ok(result.rows_affected() > 0)
263 }
264
265 /// Check if a slug already exists for a project.
266 #[tracing::instrument(skip_all)]
267 pub async fn blog_post_slug_exists(
268 pool: &PgPool,
269 project_id: ProjectId,
270 slug: &Slug,
271 ) -> Result<bool> {
272 let exists: bool = sqlx::query_scalar(
273 "SELECT EXISTS(SELECT 1 FROM blog_posts WHERE project_id = $1 AND slug = $2)",
274 )
275 .bind(project_id)
276 .bind(slug)
277 .fetch_one(pool)
278 .await?;
279
280 Ok(exists)
281 }
282