articles_rs/articles/
service.rs

1//! Article service module for managing articles and their associated images
2//! 
3//! This module provides the main service layer for interacting with articles,
4//! handling both article data and associated image operations through repositories.
5
6use chrono::{DateTime, Utc};
7use sqlx::postgres::PgPool;
8use std::collections::HashMap;
9use std::error;
10use std::io::ErrorKind;
11
12use uuid::Uuid;
13
14use crate::{
15    article_repository::{ArticleRepository, DbArticle},
16    config::DbConfig,
17    images_repository::{DbArticleImage, ImageRepository},
18    postgres_repository::PostgresRepository,
19};
20
21use super::{Article, ArticleKind};
22
23/// Service for managing articles and their associated images
24///
25/// Provides high-level operations for creating, reading, updating and deleting articles,
26/// while also handling associated image management.
27pub struct ArticleService {
28    article_repo: ArticleRepository,
29    image_repo: ImageRepository,
30}
31
32impl std::fmt::Debug for ArticleService {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("ArticleService")
35            .field("article_repo", &"ArticleRepository")
36            .field("image_repo", &"ImageRepository")
37            .finish()
38    }
39}
40
41impl ArticleService {
42    /// Creates a new ArticleService instance
43    ///
44    /// # Arguments
45    /// * `config` - Database configuration containing connection details
46    ///
47    /// # Returns
48    /// A new ArticleService instance with initialized repositories
49    pub async fn new(config: DbConfig) -> Self {
50        let url = &config.get_connection_url();
51        let pool = PgPool::connect(url)
52            .await
53            .expect("unable to connect to the database");
54        let article_repo = ArticleRepository::new(pool.clone());
55        let image_repo = ImageRepository::new(pool);
56
57        ArticleService {
58            article_repo,
59            image_repo,
60        }
61    }
62
63    /// Retrieves an article with its associated images
64    ///
65    /// # Arguments
66    /// * `article` - The database article to fetch images for
67    ///
68    /// # Returns
69    /// The article with its associated images loaded
70    async fn get_article_with_images(
71        &self,
72        article: DbArticle,
73    ) -> Result<Article, Box<dyn error::Error>> {
74        let mut filter = HashMap::new();
75        filter.insert(
76            "article_id".to_string(),
77            article.id.expect("could not get id").to_string(),
78        );
79        let images = self.image_repo.list(Some(filter)).await?;
80        let image_paths: Vec<String> = images.into_iter().map(|img| img.image_path).collect();
81
82        let mut found = Article::from(article);
83        found.add_images(image_paths);
84        Ok(found)
85    }
86
87    /// Retrieves an article by its slug
88    ///
89    /// # Arguments
90    /// * `slug` - The URL-friendly slug identifying the article
91    ///
92    /// # Returns
93    /// The article if found, otherwise an error
94    pub async fn get_article_by_slug(&self, slug: &str) -> Result<Article, Box<dyn error::Error>> {
95        match self.article_repo.find_by_name(slug.to_string()).await {
96            Ok(Some(article)) => self.get_article_with_images(article).await,
97            Ok(None) => Err(Box::new(std::io::Error::new(
98                ErrorKind::NotFound,
99                "Article not found",
100            ))),
101            Err(e) => Err(Box::new(e)),
102        }
103    }
104
105    /// Retrieves an article by its ID
106    ///
107    /// # Arguments
108    /// * `id` - The UUID of the article
109    ///
110    /// # Returns
111    /// The article if found, otherwise an error
112    pub async fn get_article_by_id(&self, id: Uuid) -> Result<Article, Box<dyn error::Error>> {
113        match self.article_repo.find_by_id(id).await {
114            Ok(Some(article)) => self.get_article_with_images(article).await,
115            Ok(None) => Err(Box::new(std::io::Error::new(
116                ErrorKind::NotFound,
117                "Article not found",
118            ))),
119            Err(e) => Err(Box::new(e)),
120        }
121    }
122
123    /// Deletes an article and its associated images
124    ///
125    /// # Arguments
126    /// * `id` - The UUID of the article to delete
127    ///
128    /// # Returns
129    /// Ok(()) if successful, otherwise an error
130    pub async fn delete_article(&self, id: Uuid) -> Result<(), Box<dyn error::Error>> {
131        let mut filter = HashMap::new();
132        filter.insert("article_id".to_string(), id.to_string());
133        self.image_repo.delete_all(Some(filter)).await?;
134
135        match self.article_repo.delete(id).await {
136            Ok(_) => Ok(()),
137            Err(e) => Err(Box::new(e)),
138        }
139    }
140
141    /// Lists articles based on optional filters
142    ///
143    /// # Arguments
144    /// * `filter` - Optional HashMap of filter criteria
145    ///
146    /// # Returns
147    /// Vector of articles matching the filter criteria
148    pub async fn list_articles(
149        &self,
150        filter: Option<HashMap<String, String>>,
151    ) -> Result<Vec<Article>, Box<dyn error::Error>> {
152        match self.article_repo.list(filter).await {
153            Ok(v) => {
154                let articles = v.into_iter().map(Article::from).collect();
155                Ok(articles)
156            }
157            Err(e) => Err(Box::new(e)),
158        }
159    }
160
161    /// Creates a new article
162    ///
163    /// # Arguments
164    /// * `title` - Article title
165    /// * `slug` - URL-friendly slug
166    /// * `description` - Article description
167    /// * `author` - Author string (can be comma-separated or array format)
168    /// * `kind` - Optional article type
169    ///
170    /// # Returns
171    /// The UUID of the created article
172    pub async fn create_article(
173        &self,
174        title: &str,
175        slug: &str,
176        description: &str,
177        author: &str,
178        kind: Option<ArticleKind>,
179    ) -> Result<Uuid, Box<dyn error::Error>> {
180        let authors = if author.contains('[') && author.contains(']') {
181            author
182                .trim_matches(|c| c == '[' || c == ']')
183                .split(',')
184                .map(|s| {
185                    s.trim_matches('\'')
186                        .trim()
187                        .trim_matches('(')
188                        .trim_matches(')')
189                })
190                .collect::<Vec<&str>>()
191        } else {
192            author
193                .split(',')
194                .map(|s| s.trim().trim_matches('(').trim_matches(')'))
195                .collect::<Vec<&str>>()
196        };
197
198        let mut article = Article::new(title, slug, description, authors);
199        if let Some(k) = kind {
200            article.kind = k;
201        }
202        match self.article_repo.create(&DbArticle::from(article)).await {
203            Ok(id) => Ok(id),
204            Err(e) => Err(Box::new(e)),
205        }
206    }
207
208    /// Updates an existing article and its associated images
209    ///
210    /// # Arguments
211    /// * `id` - Article UUID
212    /// * `title` - New title
213    /// * `slug` - New slug
214    /// * `description` - New description
215    /// * `author` - New author list
216    /// * `status` - Optional new status
217    /// * `created` - Optional new creation date
218    /// * `contents` - New content
219    /// * `images` - New list of image paths
220    /// * `source` - New source
221    /// * `publish` - Optional new publish date
222    /// * `kind` - Optional new article type
223    ///
224    /// # Returns
225    /// Ok(()) if successful, otherwise an error
226    pub async fn update_article(
227        &self,
228        id: Uuid,
229        title: &str,
230        slug: &str,
231        description: &str,
232        author: Vec<String>,
233        status: Option<&str>,
234        created: Option<DateTime<Utc>>,
235        contents: &str,
236        images: Vec<String>,
237        source: &str,
238        publish: Option<DateTime<Utc>>,
239        kind: Option<ArticleKind>,
240    ) -> Result<(), Box<dyn error::Error>> {
241        let mut article = self
242            .article_repo
243            .find_by_id(id)
244            .await?
245            .ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "Article not found"))?;
246
247        article.title = title.to_string();
248        article.slug = slug.to_string();
249        article.description = description.to_string();
250        article.author = author.join(",");
251        article.content = contents.to_string();
252        article.source = source.to_string();
253
254        if let Some(s) = status {
255            article.status = s.to_string();
256        }
257        if let Some(d) = created {
258            article.created = d;
259        }
260        if let Some(p) = publish {
261            article.published = Some(p);
262        }
263        if let Some(k) = kind {
264            article.kind = match k {
265                ArticleKind::POST => String::from("POST"),
266                ArticleKind::LINK => String::from("LINK"),
267            };
268        }
269
270        self.article_repo.update(&article).await?;
271        let mut filter = HashMap::new();
272        filter.insert("article_id".to_string(), id.to_string());
273        let current_images = self.image_repo.list(Some(filter)).await?;
274        let current_paths: Vec<String> = current_images
275            .iter()
276            .map(|img| img.image_path.clone())
277            .collect();
278
279        // Add new images
280        for image_path in &images {
281            if !image_path.is_empty() && !current_paths.contains(image_path) {
282                let image = DbArticleImage {
283                    id: None,
284                    article_id: id,
285                    image_path: image_path.clone(),
286                    visible: true,
287                    created_at: Utc::now(),
288                };
289                self.image_repo.create(&image).await?;
290            }
291        }
292
293        // Remove images not in new list
294        for current_image in current_images {
295            if !images.contains(&current_image.image_path) {
296                self.image_repo.delete(current_image.id.unwrap()).await?;
297            }
298        }
299
300        Ok(())
301    }
302}