Skip to main content

koprogo_api/infrastructure/database/repositories/
organization_repository_impl.rs

1use crate::application::ports::OrganizationRepository;
2use crate::domain::entities::{Organization, SubscriptionPlan};
3use crate::infrastructure::pool::DbPool;
4use async_trait::async_trait;
5use sqlx::Row;
6use uuid::Uuid;
7
8pub struct PostgresOrganizationRepository {
9    pool: DbPool,
10}
11
12impl PostgresOrganizationRepository {
13    pub fn new(pool: DbPool) -> Self {
14        Self { pool }
15    }
16}
17
18#[async_trait]
19impl OrganizationRepository for PostgresOrganizationRepository {
20    async fn create(&self, org: &Organization) -> Result<Organization, String> {
21        sqlx::query!(
22            r#"
23            INSERT INTO organizations (id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at, updated_at)
24            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
25            "#,
26            org.id,
27            org.name,
28            org.slug,
29            org.contact_email,
30            org.contact_phone,
31            org.subscription_plan.to_string(),
32            org.max_buildings,
33            org.max_users,
34            org.is_active,
35            org.created_at,
36            org.updated_at,
37        )
38        .execute(&self.pool)
39        .await
40        .map_err(|e| format!("Failed to create organization: {}", e))?;
41
42        Ok(org.clone())
43    }
44
45    async fn find_by_id(&self, id: Uuid) -> Result<Option<Organization>, String> {
46        let result = sqlx::query!(
47            r#"
48            SELECT id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at, updated_at
49            FROM organizations
50            WHERE id = $1
51            "#,
52            id
53        )
54        .fetch_optional(&self.pool)
55        .await
56        .map_err(|e| format!("Failed to find organization: {}", e))?;
57
58        match result {
59            Some(row) => {
60                let subscription_plan = row
61                    .subscription_plan
62                    .parse::<SubscriptionPlan>()
63                    .map_err(|e| format!("Invalid subscription plan: {}", e))?;
64
65                Ok(Some(Organization {
66                    id: row.id,
67                    name: row.name,
68                    slug: row.slug,
69                    contact_email: row.contact_email,
70                    contact_phone: row.contact_phone,
71                    subscription_plan,
72                    max_buildings: row.max_buildings,
73                    max_users: row.max_users,
74                    is_active: row.is_active,
75                    created_at: row.created_at,
76                    updated_at: row.updated_at,
77                }))
78            }
79            None => Ok(None),
80        }
81    }
82
83    async fn find_by_slug(&self, slug: &str) -> Result<Option<Organization>, String> {
84        let result = sqlx::query!(
85            r#"
86            SELECT id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at, updated_at
87            FROM organizations
88            WHERE slug = $1
89            "#,
90            slug
91        )
92        .fetch_optional(&self.pool)
93        .await
94        .map_err(|e| format!("Failed to find organization by slug: {}", e))?;
95
96        match result {
97            Some(row) => {
98                let subscription_plan = row
99                    .subscription_plan
100                    .parse::<SubscriptionPlan>()
101                    .map_err(|e| format!("Invalid subscription plan: {}", e))?;
102
103                Ok(Some(Organization {
104                    id: row.id,
105                    name: row.name,
106                    slug: row.slug,
107                    contact_email: row.contact_email,
108                    contact_phone: row.contact_phone,
109                    subscription_plan,
110                    max_buildings: row.max_buildings,
111                    max_users: row.max_users,
112                    is_active: row.is_active,
113                    created_at: row.created_at,
114                    updated_at: row.updated_at,
115                }))
116            }
117            None => Ok(None),
118        }
119    }
120
121    /// Requêtes vérifiées à l'EXÉCUTION, contrairement au reste du fichier.
122    ///
123    /// Les sept autres emploient `sqlx::query!`, qui exige le cache `.sqlx`.
124    /// Ce cache est suivi par git et `sqlx prepare` le vide avant de le
125    /// refaire : le régénérer pour deux requêtes ferait porter à ce
126    /// chantier le risque de perdre les entrées de tout le monde. Le
127    /// filtre est couvert par les tests d'intégration, qui exécutent le SQL
128    /// pour de vrai. Même raison que `module_registry_impl.rs`.
129    async fn find_page(
130        &self,
131        recherche: Option<String>,
132        limit: i64,
133        offset: i64,
134    ) -> Result<Vec<Organization>, String> {
135        // `ILIKE` sur le nom, le slug ET le courriel de contact : un
136        // administrateur cherche l'un des trois sans savoir lequel il a sous
137        // les yeux.
138        //
139        // Le courriel a été ajouté en alignant `OrganizationList`, dont le
140        // filtre CLIENT le cherchait déjà. S'en tenir au nom et au slug
141        // aurait rétréci en silence ce qu'un administrateur peut trouver —
142        // le genre de perte qu'un remplacement « équivalent » fait passer
143        // inaperçue.
144        //
145        // Le motif est LIÉ, jamais concaténé : une recherche est une donnée
146        // d'utilisateur.
147        let motif = recherche
148            .map(|r| r.trim().to_string())
149            .filter(|r| !r.is_empty())
150            .map(|r| format!("%{r}%"));
151
152        let rows = sqlx::query(
153            "SELECT id, name, slug, contact_email, contact_phone, subscription_plan, \
154                    max_buildings, max_users, is_active, created_at, updated_at \
155             FROM organizations \
156             WHERE $1::text IS NULL OR name ILIKE $1 OR slug ILIKE $1 OR contact_email ILIKE $1 \
157             ORDER BY name ASC \
158             LIMIT $2 OFFSET $3",
159        )
160        .bind(motif.as_deref())
161        .bind(limit)
162        .bind(offset)
163        .fetch_all(&self.pool)
164        .await
165        .map_err(|e| format!("Failed to fetch organizations page: {e}"))?;
166
167        Ok(rows
168            .into_iter()
169            .filter_map(|row| {
170                let plan: String = row.get("subscription_plan");
171                let subscription_plan = plan.parse::<SubscriptionPlan>().ok()?;
172                Some(Organization {
173                    id: row.get("id"),
174                    name: row.get("name"),
175                    slug: row.get("slug"),
176                    contact_email: row.get("contact_email"),
177                    contact_phone: row.get("contact_phone"),
178                    subscription_plan,
179                    max_buildings: row.get("max_buildings"),
180                    max_users: row.get("max_users"),
181                    is_active: row.get("is_active"),
182                    created_at: row.get("created_at"),
183                    updated_at: row.get("updated_at"),
184                })
185            })
186            .collect())
187    }
188
189    async fn count_matching(&self, recherche: Option<String>) -> Result<i64, String> {
190        let motif = recherche
191            .map(|r| r.trim().to_string())
192            .filter(|r| !r.is_empty())
193            .map(|r| format!("%{r}%"));
194
195        let total: i64 = sqlx::query_scalar(
196            "SELECT COUNT(*) FROM organizations \
197             WHERE $1::text IS NULL OR name ILIKE $1 OR slug ILIKE $1 OR contact_email ILIKE $1",
198        )
199        .bind(motif.as_deref())
200        .fetch_one(&self.pool)
201        .await
202        .map_err(|e| format!("Failed to count organizations: {e}"))?;
203
204        Ok(total)
205    }
206
207    async fn find_all(&self) -> Result<Vec<Organization>, String> {
208        let rows = sqlx::query!(
209            r#"
210            SELECT id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at, updated_at
211            FROM organizations
212            ORDER BY created_at DESC
213            "#
214        )
215        .fetch_all(&self.pool)
216        .await
217        .map_err(|e| format!("Failed to fetch organizations: {}", e))?;
218
219        let orgs = rows
220            .into_iter()
221            .filter_map(|row| {
222                let subscription_plan = row.subscription_plan.parse::<SubscriptionPlan>().ok()?;
223                Some(Organization {
224                    id: row.id,
225                    name: row.name,
226                    slug: row.slug,
227                    contact_email: row.contact_email,
228                    contact_phone: row.contact_phone,
229                    subscription_plan,
230                    max_buildings: row.max_buildings,
231                    max_users: row.max_users,
232                    is_active: row.is_active,
233                    created_at: row.created_at,
234                    updated_at: row.updated_at,
235                })
236            })
237            .collect();
238
239        Ok(orgs)
240    }
241
242    async fn update(&self, org: &Organization) -> Result<Organization, String> {
243        sqlx::query!(
244            r#"
245            UPDATE organizations
246            SET name = $2, slug = $3, contact_email = $4, contact_phone = $5,
247                subscription_plan = $6, max_buildings = $7, max_users = $8,
248                is_active = $9, updated_at = $10
249            WHERE id = $1
250            "#,
251            org.id,
252            org.name,
253            org.slug,
254            org.contact_email,
255            org.contact_phone,
256            org.subscription_plan.to_string(),
257            org.max_buildings,
258            org.max_users,
259            org.is_active,
260            org.updated_at,
261        )
262        .execute(&self.pool)
263        .await
264        .map_err(|e| format!("Failed to update organization: {}", e))?;
265
266        Ok(org.clone())
267    }
268
269    async fn delete(&self, id: Uuid) -> Result<bool, String> {
270        let result = sqlx::query!(
271            r#"
272            DELETE FROM organizations
273            WHERE id = $1
274            "#,
275            id
276        )
277        .execute(&self.pool)
278        .await
279        .map_err(|e| format!("Failed to delete organization: {}", e))?;
280
281        Ok(result.rows_affected() > 0)
282    }
283
284    async fn count_buildings(&self, org_id: Uuid) -> Result<i64, String> {
285        // Post-#602 : buildings.organization_id was dropped ; resolve via acps.
286        let result = sqlx::query!(
287            r#"
288            SELECT COUNT(*) as count
289            FROM buildings b
290            JOIN acps a ON a.id = b.acp_id
291            WHERE a.organization_id = $1
292            "#,
293            org_id
294        )
295        .fetch_one(&self.pool)
296        .await
297        .map_err(|e| format!("Failed to count buildings: {}", e))?;
298
299        Ok(result.count.unwrap_or(0))
300    }
301}