Skip to main content

koprogo_api/application/use_cases/
organization_use_cases.rs

1use crate::application::ports::OrganizationRepository;
2use crate::domain::entities::{Organization, SubscriptionPlan};
3use chrono::Utc;
4use std::sync::Arc;
5use uuid::Uuid;
6use validator::Validate;
7
8pub struct OrganizationUseCases {
9    repo: Arc<dyn OrganizationRepository>,
10}
11
12impl OrganizationUseCases {
13    pub fn new(repo: Arc<dyn OrganizationRepository>) -> Self {
14        Self { repo }
15    }
16
17    pub async fn list_all(&self) -> Result<Vec<Organization>, String> {
18        self.repo.find_all().await
19    }
20
21    /// Une page d'organisations, et le total qui va avec.
22    ///
23    /// Le total n'est pas un ornement : sans lui, l'appelant reçoit un
24    /// fragment sans savoir la taille du tout, et c'est exactement ainsi
25    /// qu'une liste tronquée passe pour complète.
26    pub async fn list_page(
27        &self,
28        recherche: Option<String>,
29        limit: i64,
30        offset: i64,
31    ) -> Result<(Vec<Organization>, i64), String> {
32        let page = self
33            .repo
34            .find_page(recherche.clone(), limit, offset)
35            .await?;
36        let total = self.repo.count_matching(recherche).await?;
37        Ok((page, total))
38    }
39
40    pub async fn create(
41        &self,
42        name: String,
43        slug: String,
44        contact_email: String,
45        contact_phone: Option<String>,
46        subscription_plan: String,
47    ) -> Result<Organization, String> {
48        let plan = subscription_plan
49            .parse::<SubscriptionPlan>()
50            .map_err(|_| "invalid_plan".to_string())?;
51
52        let (max_buildings, max_users) = plan_limits(&plan);
53
54        let org = Organization {
55            id: Uuid::new_v4(),
56            name: name.trim().to_string(),
57            slug: slug.trim().to_lowercase(),
58            contact_email: contact_email.trim().to_lowercase(),
59            contact_phone,
60            subscription_plan: plan,
61            max_buildings,
62            max_users,
63            is_active: true,
64            created_at: Utc::now(),
65            updated_at: Utc::now(),
66        };
67
68        org.validate()
69            .map_err(|e| format!("validation_error:{}", e))?;
70
71        self.repo.create(&org).await
72    }
73
74    pub async fn update(
75        &self,
76        id: Uuid,
77        name: String,
78        slug: String,
79        contact_email: String,
80        contact_phone: Option<String>,
81        subscription_plan: String,
82    ) -> Result<Organization, String> {
83        let mut org = self
84            .repo
85            .find_by_id(id)
86            .await?
87            .ok_or_else(|| "not_found".to_string())?;
88
89        let plan = subscription_plan
90            .parse::<SubscriptionPlan>()
91            .map_err(|_| "invalid_plan".to_string())?;
92
93        let (max_buildings, max_users) = plan_limits(&plan);
94
95        org.name = name.trim().to_string();
96        org.slug = slug.trim().to_lowercase();
97        org.contact_email = contact_email.trim().to_lowercase();
98        org.contact_phone = contact_phone;
99        org.subscription_plan = plan;
100        org.max_buildings = max_buildings;
101        org.max_users = max_users;
102        org.updated_at = Utc::now();
103
104        org.validate()
105            .map_err(|e| format!("validation_error:{}", e))?;
106
107        self.repo.update(&org).await
108    }
109
110    pub async fn activate(&self, id: Uuid) -> Result<Organization, String> {
111        let mut org = self
112            .repo
113            .find_by_id(id)
114            .await?
115            .ok_or_else(|| "not_found".to_string())?;
116        org.activate();
117        self.repo.update(&org).await
118    }
119
120    pub async fn suspend(&self, id: Uuid) -> Result<Organization, String> {
121        let mut org = self
122            .repo
123            .find_by_id(id)
124            .await?
125            .ok_or_else(|| "not_found".to_string())?;
126        org.deactivate();
127        self.repo.update(&org).await
128    }
129
130    pub async fn delete(&self, id: Uuid) -> Result<bool, String> {
131        self.repo.delete(id).await
132    }
133}
134
135fn plan_limits(plan: &SubscriptionPlan) -> (i32, i32) {
136    match plan {
137        SubscriptionPlan::Free => (1, 3),
138        SubscriptionPlan::Starter => (5, 10),
139        SubscriptionPlan::Professional => (20, 50),
140        SubscriptionPlan::Enterprise => (999, 999),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use async_trait::async_trait;
148    use chrono::Utc;
149
150    struct MockOrgRepository {
151        orgs: Vec<Organization>,
152    }
153
154    fn make_org(name: &str) -> Organization {
155        Organization {
156            id: Uuid::new_v4(),
157            name: name.to_string(),
158            slug: name.to_lowercase().replace(' ', "-"),
159            contact_email: "test@test.com".to_string(),
160            contact_phone: None,
161            subscription_plan: SubscriptionPlan::Free,
162            max_buildings: 1,
163            max_users: 3,
164            is_active: true,
165            created_at: Utc::now(),
166            updated_at: Utc::now(),
167        }
168    }
169
170    impl MockOrgRepository {
171        fn filtrer<'a>(
172            orgs: &'a [Organization],
173            recherche: Option<&'a str>,
174        ) -> impl Iterator<Item = &'a Organization> {
175            let motif = recherche
176                .map(str::trim)
177                .filter(|r| !r.is_empty())
178                .map(|r| r.to_lowercase());
179            orgs.iter().filter(move |o| match &motif {
180                None => true,
181                Some(m) => o.name.to_lowercase().contains(m) || o.slug.to_lowercase().contains(m),
182            })
183        }
184    }
185
186    #[async_trait]
187    impl OrganizationRepository for MockOrgRepository {
188        async fn create(&self, org: &Organization) -> Result<Organization, String> {
189            Ok(org.clone())
190        }
191        async fn find_by_id(&self, id: Uuid) -> Result<Option<Organization>, String> {
192            Ok(self.orgs.iter().find(|o| o.id == id).cloned())
193        }
194        async fn find_by_slug(&self, slug: &str) -> Result<Option<Organization>, String> {
195            Ok(self.orgs.iter().find(|o| o.slug == slug).cloned())
196        }
197        async fn find_all(&self) -> Result<Vec<Organization>, String> {
198            Ok(self.orgs.clone())
199        }
200
201        async fn find_page(
202            &self,
203            recherche: Option<String>,
204            limit: i64,
205            offset: i64,
206        ) -> Result<Vec<Organization>, String> {
207            // Le double reproduit le filtre du vrai dépôt — nom OU slug,
208            // sans distinction de casse. S'il se contentait de rendre tout,
209            // les tests passeraient sans rien éprouver du filtrage.
210            Ok(Self::filtrer(&self.orgs, recherche.as_deref())
211                .skip(offset.max(0) as usize)
212                .take(limit.max(0) as usize)
213                .cloned()
214                .collect())
215        }
216
217        async fn count_matching(&self, recherche: Option<String>) -> Result<i64, String> {
218            Ok(Self::filtrer(&self.orgs, recherche.as_deref()).count() as i64)
219        }
220        async fn update(&self, org: &Organization) -> Result<Organization, String> {
221            Ok(org.clone())
222        }
223        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
224            Ok(true)
225        }
226        async fn count_buildings(&self, _org_id: Uuid) -> Result<i64, String> {
227            Ok(0)
228        }
229    }
230
231    #[tokio::test]
232    async fn test_list_all() {
233        let org = make_org("TestOrg");
234        let repo = Arc::new(MockOrgRepository { orgs: vec![org] });
235        let uc = OrganizationUseCases::new(repo);
236        let result = uc.list_all().await.unwrap();
237        assert_eq!(result.len(), 1);
238    }
239
240    #[tokio::test]
241    async fn test_create_invalid_plan_returns_error() {
242        let repo = Arc::new(MockOrgRepository { orgs: vec![] });
243        let uc = OrganizationUseCases::new(repo);
244        let result = uc
245            .create(
246                "Test".to_string(),
247                "test".to_string(),
248                "a@b.com".to_string(),
249                None,
250                "invalid_plan".to_string(),
251            )
252            .await;
253        assert!(result.is_err());
254        assert_eq!(result.unwrap_err(), "invalid_plan");
255    }
256
257    #[tokio::test]
258    async fn test_activate_not_found_returns_error() {
259        let repo = Arc::new(MockOrgRepository { orgs: vec![] });
260        let uc = OrganizationUseCases::new(repo);
261        let result = uc.activate(Uuid::new_v4()).await;
262        assert!(result.is_err());
263        assert_eq!(result.unwrap_err(), "not_found");
264    }
265
266    #[tokio::test]
267    async fn test_suspend_org() {
268        let org = make_org("ActiveOrg");
269        let id = org.id;
270        let repo = Arc::new(MockOrgRepository { orgs: vec![org] });
271        let uc = OrganizationUseCases::new(repo);
272        let result = uc.suspend(id).await.unwrap();
273        assert!(!result.is_active);
274    }
275}