haste_repository/pg/models/
tenant.rs1use 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 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 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}