Skip to main content

haste_repository/pg/models/
tenant.rs

1use crate::{
2    admin::TenantModelAdmin,
3    pg::{PGConnection, StoreError},
4    types::tenant::{CreateTenant, Tenant, TenantSearchClaims},
5    utilities::{generate_hostname_id, validate_hostname_id},
6};
7use haste_fhir_operation_error::OperationOutcomeError;
8use haste_jwt::{TenantId, claims::SubscriptionTier};
9use sqlx::{PgExecutor, QueryBuilder};
10
11fn validate_tenant_customization(
12    subscription_tier: &str,
13    display_name: Option<&String>,
14    logo_data: Option<&Vec<u8>>,
15    logo_content_type: Option<&String>,
16) -> Result<(), OperationOutcomeError> {
17    if logo_data.is_some() != logo_content_type.is_some() {
18        return Err(OperationOutcomeError::error(
19            haste_fhir_model::r4::generated::terminology::IssueType::invalid(),
20            "Tenant logo data and content type must be provided together".to_string(),
21        ));
22    }
23
24    if let Some(content_type) = logo_content_type
25        && !content_type.starts_with("image/")
26    {
27        return Err(OperationOutcomeError::error(
28            haste_fhir_model::r4::generated::terminology::IssueType::invalid(),
29            "Tenant logo content type must be an image MIME type".to_string(),
30        ));
31    }
32
33    let wants_customization =
34        display_name.is_some() || logo_data.is_some() || logo_content_type.is_some();
35
36    if wants_customization {
37        // An unrecognized tier is not silently treated as permissive.
38        let tier = SubscriptionTier::try_from(subscription_tier.to_string())?;
39
40        if !haste_subscription::allows_tenant_customization(&tier) {
41            return Err(OperationOutcomeError::error(
42                haste_fhir_model::r4::generated::terminology::IssueType::forbidden(),
43                "Tenant customization requires a paid subscription tier".to_string(),
44            ));
45        }
46    }
47
48    Ok(())
49}
50
51async fn create_tenant<'e, E>(
52    executor: E,
53    tenant: CreateTenant,
54) -> Result<Tenant, OperationOutcomeError>
55where
56    E: PgExecutor<'e>,
57{
58    let id = tenant
59        .id
60        .unwrap_or_else(|| TenantId::new(generate_hostname_id(None)));
61
62    // The system tenant only ever comes from a migration.
63    if id == TenantId::System {
64        return Err(OperationOutcomeError::error(
65            haste_fhir_model::r4::generated::terminology::IssueType::forbidden(),
66            "The system tenant cannot be created".to_string(),
67        ));
68    }
69
70    validate_hostname_id(id.as_ref())?;
71
72    validate_tenant_customization(
73        tenant.subscription_tier.as_deref().unwrap_or("free"),
74        tenant.display_name.as_ref(),
75        tenant.logo_data.as_ref(),
76        tenant.logo_content_type.as_ref(),
77    )?;
78
79    let result = sqlx::query_as::<_, Tenant>(
80        r"
81            INSERT INTO tenants (id, subscription_tier, display_name, logo_data, logo_content_type)
82            VALUES ($1, $2, $3, $4, $5)
83            RETURNING id, subscription_tier, display_name, logo_data, logo_content_type
84        ",
85    )
86    .bind(id)
87    .bind(
88        tenant
89            .subscription_tier
90            .unwrap_or_else(|| "free".to_string()),
91    )
92    .bind(tenant.display_name)
93    .bind(tenant.logo_data)
94    .bind(tenant.logo_content_type)
95    .fetch_one(executor)
96    .await;
97
98    match result {
99        Ok(tenant) => Ok(tenant),
100        Err(e) => {
101            if let sqlx::Error::Database(db_error) = &e
102                && db_error.code().as_deref() == Some("23505")
103            {
104                println!("Duplicate tenant ID detected");
105                Err(StoreError::Duplicate.into())
106            } else {
107                Err(StoreError::SQLXError(e).into())
108            }
109        }
110    }
111}
112
113async fn read_tenant<'a, 'e, E>(
114    executor: E,
115    id: &'a str,
116) -> Result<Option<Tenant>, OperationOutcomeError>
117where
118    E: PgExecutor<'e>,
119{
120    let tenant = sqlx::query_as::<_, Tenant>(
121        r"
122            SELECT id, subscription_tier, display_name, logo_data, logo_content_type
123            FROM tenants
124            WHERE id = $1
125        ",
126    )
127    .bind(id)
128    .fetch_optional(executor)
129    .await
130    .map_err(StoreError::SQLXError)?;
131
132    Ok(tenant)
133}
134
135async fn update_tenant<'e, E>(executor: E, tenant: Tenant) -> Result<Tenant, OperationOutcomeError>
136where
137    E: PgExecutor<'e>,
138{
139    validate_tenant_customization(
140        &tenant.subscription_tier,
141        tenant.display_name.as_ref(),
142        tenant.logo_data.as_ref(),
143        tenant.logo_content_type.as_ref(),
144    )?;
145
146    let updated_tenant = sqlx::query_as::<_, Tenant>(
147        r"
148            UPDATE tenants
149            SET subscription_tier = $1, display_name = $2, logo_data = $3, logo_content_type = $4
150            WHERE id = $5
151            RETURNING id, subscription_tier, display_name, logo_data, logo_content_type
152        ",
153    )
154    .bind(tenant.subscription_tier)
155    .bind(tenant.display_name)
156    .bind(tenant.logo_data)
157    .bind(tenant.logo_content_type)
158    .bind(tenant.id)
159    .fetch_one(executor)
160    .await
161    .map_err(StoreError::SQLXError)?;
162
163    Ok(updated_tenant)
164}
165
166async fn delete_tenant<'a, 'e, E>(executor: E, id: &'a str) -> Result<(), OperationOutcomeError>
167where
168    E: PgExecutor<'e>,
169{
170    sqlx::query(
171        r"
172            DELETE FROM tenants
173            WHERE id = $1
174        ",
175    )
176    .bind(id)
177    .execute(executor)
178    .await
179    .map_err(StoreError::SQLXError)?;
180
181    Ok(())
182}
183
184async fn search_tenant<'a, 'e, E>(
185    executor: E,
186    clauses: &'a TenantSearchClaims,
187) -> Result<Vec<Tenant>, OperationOutcomeError>
188where
189    E: PgExecutor<'e>,
190{
191    let mut query_builder: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
192        r"SELECT id, subscription_tier, display_name, logo_data, logo_content_type FROM tenants WHERE ",
193    );
194
195    if let Some(subscription_tier) = clauses.subscription_tier.as_ref() {
196        query_builder
197            .push(" subscription_tier = ")
198            .push_bind(subscription_tier);
199    }
200
201    let query = query_builder.build_query_as::<Tenant>();
202
203    let tenants: Vec<Tenant> = query.fetch_all(executor).await.map_err(StoreError::from)?;
204
205    Ok(tenants)
206}
207
208impl<Key: AsRef<str> + Send + Sync>
209    TenantModelAdmin<CreateTenant, Tenant, TenantSearchClaims, Tenant, Key> for PGConnection
210{
211    async fn create(
212        &self,
213        _tenant: &TenantId,
214        new_tenant: CreateTenant,
215    ) -> Result<Tenant, OperationOutcomeError> {
216        match self {
217            PGConnection::Pool(pool, _) => create_tenant(pool, new_tenant).await,
218            PGConnection::Transaction(tx, _, _) => {
219                let mut tx = tx.lock().await;
220                create_tenant(&mut **tx, new_tenant).await
221            }
222        }
223    }
224
225    async fn read(
226        &self,
227        _tenant: &TenantId,
228        id: &Key,
229    ) -> Result<Option<Tenant>, haste_fhir_operation_error::OperationOutcomeError> {
230        match self {
231            PGConnection::Pool(pool, _) => read_tenant(pool, id.as_ref()).await,
232            PGConnection::Transaction(tx, _, _) => {
233                let mut tx = tx.lock().await;
234                read_tenant(&mut **tx, id.as_ref()).await
235            }
236        }
237    }
238
239    async fn update(
240        &self,
241        _tenant: &TenantId,
242        model: Tenant,
243    ) -> Result<Tenant, haste_fhir_operation_error::OperationOutcomeError> {
244        match self {
245            PGConnection::Pool(pool, _) => update_tenant(pool, model).await,
246            PGConnection::Transaction(tx, _, _) => {
247                let mut tx = tx.lock().await;
248                update_tenant(&mut **tx, model).await
249            }
250        }
251    }
252
253    async fn delete(
254        &self,
255        _tenant: &TenantId,
256        id: &Key,
257    ) -> Result<(), haste_fhir_operation_error::OperationOutcomeError> {
258        match self {
259            PGConnection::Pool(pool, _) => delete_tenant(pool, id.as_ref()).await,
260            PGConnection::Transaction(tx, _, _) => {
261                let mut tx = tx.lock().await;
262                delete_tenant(&mut **tx, id.as_ref()).await
263            }
264        }
265    }
266
267    async fn search(
268        &self,
269        _tenant: &TenantId,
270        claims: &TenantSearchClaims,
271    ) -> Result<Vec<Tenant>, OperationOutcomeError> {
272        match self {
273            PGConnection::Pool(pool, _) => search_tenant(pool, claims).await,
274            PGConnection::Transaction(tx, _, _) => {
275                let mut tx = tx.lock().await;
276                search_tenant(&mut **tx, claims).await
277            }
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::validate_tenant_customization;
285
286    #[test]
287    fn free_tenants_cannot_set_branding() {
288        let result =
289            validate_tenant_customization("free", Some(&"Example Health".to_string()), None, None);
290
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn paid_tenants_can_set_branding() {
296        let result = validate_tenant_customization(
297            "professional",
298            Some(&"Example Health".to_string()),
299            Some(&vec![1, 2, 3]),
300            Some(&"image/png".to_string()),
301        );
302
303        assert!(result.is_ok());
304    }
305}