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