1use dioxus::prelude::*;
9
10#[cfg(feature = "server")]
11use super::helpers::{get_current_admin_user, row_to_post_list_item};
12use super::types::PostListResponse;
13#[cfg(feature = "server")]
14use crate::api::error::AppError;
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17
18#[cfg(feature = "server")]
23const MAX_PER_PAGE: i32 = 50;
24
25#[cfg(feature = "server")]
31const MAX_PAGE: i32 = 10_000;
32
33#[cfg(feature = "server")]
37fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) {
38 (page.clamp(1, MAX_PAGE), per_page.clamp(1, MAX_PER_PAGE))
39}
40
41#[server(ListPublishedPosts, "/api")]
45pub async fn list_published_posts(
46 page: i32,
47 per_page: i32,
48) -> Result<PostListResponse, ServerFnError> {
49 let (page, per_page) = clamp_pagination(page, per_page);
51
52 #[cfg(feature = "server")]
53 {
54 let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
55 if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await {
56 return Ok(PostListResponse {
57 posts: cached_posts,
58 total: cached_total,
59 });
60 }
61
62 let client = get_conn().await.map_err(AppError::db_conn)?;
63
64 let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
66 cached_total
67 } else {
68 let count_row = client
69 .query_one(
70 "SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
71 &[],
72 )
73 .await
74 .map_err(AppError::query)?;
75 let total: i64 = count_row.get(0);
76 crate::cache::set_total_published_posts(total).await;
77 total
78 };
79
80 let offset = ((page - 1).max(0) as i64) * (per_page as i64);
81 let limit = per_page as i64;
82 let rows = client
83 .query(
84 "SELECT
85 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
86 p.published_at, p.created_at, p.updated_at, p.cover_image,
87 p.word_count, p.reading_time,
88 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
89 FROM posts p
90 LEFT JOIN post_tags pt ON p.id = pt.post_id
91 LEFT JOIN tags t ON pt.tag_id = t.id
92 WHERE p.status = 'published' AND p.deleted_at IS NULL
93 GROUP BY p.id
94 ORDER BY p.published_at DESC
95 LIMIT $1 OFFSET $2",
96 &[&limit, &offset],
97 )
98 .await
99 .map_err(AppError::query)?;
100
101 let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
102
103 crate::cache::set_post_list(&cache_key, posts.clone(), total).await;
104 Ok(PostListResponse { posts, total })
105 }
106
107 #[cfg(not(feature = "server"))]
108 {
109 Ok(PostListResponse {
110 posts: Vec::new(),
111 total: 0,
112 })
113 }
114}
115
116#[server(ListPosts, "/api")]
120pub async fn list_posts(
121 page: i32,
122 per_page: i32,
123 search: Option<String>,
124) -> Result<PostListResponse, ServerFnError> {
125 let (page, per_page) = clamp_pagination(page, per_page);
127 let _user = get_current_admin_user().await?;
128
129 #[cfg(feature = "server")]
130 {
131 let client = get_conn().await.map_err(AppError::db_conn)?;
132
133 let title_filter: Option<String> = search
137 .as_deref()
138 .map(str::trim)
139 .filter(|s| !s.is_empty())
140 .map(|s| {
141 let truncated: String = s.chars().take(200).collect();
142 crate::utils::server::escape_like_pattern(&truncated)
143 });
144
145 let offset = ((page - 1).max(0) as i64) * (per_page as i64);
146 let limit = per_page as i64;
147
148 let (total, rows) = if let Some(esc) = title_filter {
151 let pattern = format!("%{esc}%");
152 let total: i64 = client
153 .query_one(
154 "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND title ILIKE $1 ESCAPE '\\'",
155 &[&pattern],
156 )
157 .await
158 .map_err(AppError::query)?
159 .get(0);
160 let rows = client
161 .query(
162 "SELECT
163 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
164 p.published_at, p.created_at, p.updated_at, p.cover_image,
165 p.word_count, p.reading_time,
166 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
167 FROM posts p
168 LEFT JOIN post_tags pt ON p.id = pt.post_id
169 LEFT JOIN tags t ON pt.tag_id = t.id
170 WHERE p.deleted_at IS NULL AND p.title ILIKE $3 ESCAPE '\\'
171 GROUP BY p.id
172 ORDER BY p.created_at DESC
173 LIMIT $1 OFFSET $2",
174 &[&limit, &offset, &pattern],
175 )
176 .await
177 .map_err(AppError::query)?;
178 (total, rows)
179 } else {
180 let total: i64 = client
181 .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
182 .await
183 .map_err(AppError::query)?
184 .get(0);
185 let rows = client
186 .query(
187 "SELECT
188 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
189 p.published_at, p.created_at, p.updated_at, p.cover_image,
190 p.word_count, p.reading_time,
191 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
192 FROM posts p
193 LEFT JOIN post_tags pt ON p.id = pt.post_id
194 LEFT JOIN tags t ON pt.tag_id = t.id
195 WHERE p.deleted_at IS NULL
196 GROUP BY p.id
197 ORDER BY p.created_at DESC
198 LIMIT $1 OFFSET $2",
199 &[&limit, &offset],
200 )
201 .await
202 .map_err(AppError::query)?;
203 (total, rows)
204 };
205
206 let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
207
208 Ok(PostListResponse { posts, total })
209 }
210
211 #[cfg(not(feature = "server"))]
212 {
213 Ok(PostListResponse {
214 posts: Vec::new(),
215 total: 0,
216 })
217 }
218}
219
220#[server(ListDeletedPosts, "/api")]
224pub async fn list_deleted_posts(
225 page: i32,
226 per_page: i32,
227) -> Result<PostListResponse, ServerFnError> {
228 let (page, per_page) = clamp_pagination(page, per_page);
230 let _user = get_current_admin_user().await?;
231
232 #[cfg(feature = "server")]
233 {
234 let client = get_conn().await.map_err(AppError::db_conn)?;
235
236 let count_row = client
237 .query_one(
238 "SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL",
239 &[],
240 )
241 .await
242 .map_err(AppError::query)?;
243 let total: i64 = count_row.get(0);
244
245 let offset = ((page - 1).max(0) as i64) * (per_page as i64);
246 let limit = per_page as i64;
247 let rows = client
248 .query(
249 "SELECT
250 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
251 p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
252 p.word_count, p.reading_time,
253 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
254 FROM posts p
255 LEFT JOIN post_tags pt ON p.id = pt.post_id
256 LEFT JOIN tags t ON pt.tag_id = t.id
257 WHERE p.deleted_at IS NOT NULL
258 GROUP BY p.id
259 ORDER BY p.deleted_at DESC
260 LIMIT $1 OFFSET $2",
261 &[&limit, &offset],
262 )
263 .await
264 .map_err(AppError::query)?;
265
266 let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
267
268 Ok(PostListResponse { posts, total })
269 }
270
271 #[cfg(not(feature = "server"))]
272 {
273 Ok(PostListResponse {
274 posts: Vec::new(),
275 total: 0,
276 })
277 }
278}
279
280#[server(GetPostsByTag, "/api")]
283pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
284 #[cfg(feature = "server")]
285 {
286 let client = get_conn().await.map_err(AppError::db_conn)?;
287
288 if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
289 {
290 return Ok(PostListResponse {
291 posts: cached_posts,
292 total: cached_total,
293 });
294 }
295
296 let total: i64 = client
298 .query_one(
299 "SELECT COUNT(*) FROM posts p
300 JOIN post_tags pt ON p.id = pt.post_id
301 JOIN tags t ON pt.tag_id = t.id
302 WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
303 &[&tag_name],
304 )
305 .await
306 .map_err(AppError::query)?
307 .get(0);
308
309 let rows = client
310 .query(
311 "SELECT
312 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
313 p.published_at, p.created_at, p.updated_at, p.cover_image,
314 p.word_count, p.reading_time,
315 COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
316 FROM posts p
317 JOIN post_tags pt ON p.id = pt.post_id
318 JOIN tags t ON pt.tag_id = t.id
319 LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
320 LEFT JOIN tags t2 ON pt2.tag_id = t2.id
321 WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
322 GROUP BY p.id
323 ORDER BY p.published_at DESC
324 LIMIT 200",
325 &[&tag_name],
326 )
327 .await
328 .map_err(AppError::query)?;
329
330 let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
331
332 crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
334 Ok(PostListResponse { posts, total })
335 }
336
337 #[cfg(not(feature = "server"))]
338 {
339 Ok(PostListResponse {
340 posts: Vec::new(),
341 total: 0,
342 })
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn clamp_pagination_keeps_valid_values() {
352 assert_eq!(clamp_pagination(1, 10), (1, 10));
353 assert_eq!(clamp_pagination(3, 20), (3, 20));
354 }
355
356 #[test]
357 fn clamp_pagination_clamps_oversized_per_page() {
358 assert_eq!(clamp_pagination(1, 1_000_000_000), (1, MAX_PER_PAGE));
360 assert_eq!(clamp_pagination(2, 51), (2, MAX_PER_PAGE));
361 }
362
363 #[test]
364 fn clamp_pagination_clamps_non_positive() {
365 assert_eq!(clamp_pagination(0, 10), (1, 10));
366 assert_eq!(clamp_pagination(-5, 10), (1, 10));
367 assert_eq!(clamp_pagination(1, 0), (1, 1));
368 assert_eq!(clamp_pagination(1, -100), (1, 1));
369 }
370
371 #[test]
372 fn clamp_pagination_clamps_oversized_page() {
373 assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10));
375 assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10));
376 }
377
378 #[test]
379 fn clamp_pagination_max_page_boundary() {
380 assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10));
381 assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10));
382 }
383
384 #[test]
385 fn clamp_pagination_max_per_page_boundary() {
386 assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE));
387 assert_eq!(clamp_pagination(1, MAX_PER_PAGE - 1), (1, MAX_PER_PAGE - 1));
388 }
389}