1use sofie::core::{Cast, RuleText, SqlValue};
2use sofie::db::{Aggregate, ExpectedRows, Outcome, Plan, Reply, Stamp, Step, Uuid};
3use sofie::http::Area;
4use sofie::prelude::*;
5
6use sofie_identity::AccountId;
7
8#[derive(Aggregate)]
11#[owner]
12#[aggregate(behaviors(SoftDelete))]
13pub struct Organization {
14 meta: Meta,
15 name: String,
16}
17
18pub type OrganizationId = Id<Organization>;
19
20impl Organization {
21 #[must_use]
22 pub fn name(&self) -> &str {
23 &self.name
24 }
25
26 pub(crate) fn named(name: &str) -> Result<Self> {
27 Ok(Self {
28 meta: Meta::new()?,
29 name: valid_name(name)?,
30 })
31 }
32
33 pub(crate) fn rename(&mut self, name: &str) -> Result<()> {
34 self.name = valid_name(name)?;
35 Ok(())
36 }
37
38 pub(crate) fn dissolve(&mut self) -> Result<()> {
39 self.delete();
40 self.meta.raise(OrganizationDeleted {
41 organization: self.id().uuid(),
42 })
43 }
44}
45
46fn valid_name(name: &str) -> Result<String> {
47 let name = name.trim();
48 if name.is_empty()
49 || name.chars().count() > 100
50 || name.chars().any(sofie::core::misleads_display)
51 {
52 return Err(Error::invalid(
53 "name",
54 "is 1 to 100 characters, none hidden",
55 ));
56 }
57 Ok(name.to_owned())
58}
59
60#[derive(Event)]
63pub struct OrganizationDeleted {
64 pub organization: Uuid,
65}
66
67pub trait OrganizationArea: Area<Principal = Organization> {
68 const HOME: &'static str;
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Enum)]
72pub enum OrganizationRole {
73 Owner,
75 Admin,
77 Member,
78}
79
80const MANAGING: RuleText = RuleText::new(
81 "tenancy.rule.managing",
82 "Only an owner or an admin of the organization manages its members.",
83);
84const OWNER_ROLE: RuleText = RuleText::new(
85 "tenancy.rule.owner_role",
86 "Only an owner gives or takes the owner role.",
87);
88
89impl OrganizationRole {
90 #[must_use]
91 pub fn manages_members(self) -> bool {
92 matches!(self, Self::Owner | Self::Admin)
93 }
94
95 pub(crate) fn managing(self, touching_owner: bool) -> Result<()> {
96 if !self.manages_members() {
97 return Err(MANAGING.into());
98 }
99 if touching_owner && self != Self::Owner {
100 return Err(OWNER_ROLE.into());
101 }
102 Ok(())
103 }
104
105 pub(crate) fn outranks(self, other: Self) -> bool {
106 let rank = |role: Self| match role {
107 Self::Owner => 2,
108 Self::Admin => 1,
109 Self::Member => 0,
110 };
111 rank(self) > rank(other)
112 }
113}
114
115#[derive(Event)]
117pub struct MemberJoined {
118 pub organization: Uuid,
119 pub account: Uuid,
120 pub role: OrganizationRole,
121}
122
123#[derive(Event)]
124pub struct MemberRoleChanged {
125 pub organization: Uuid,
126 pub account: Uuid,
127 pub role: OrganizationRole,
128}
129
130#[derive(Event)]
132pub struct MemberLeft {
133 pub organization: Uuid,
134 pub account: Uuid,
135}
136
137#[derive(Aggregate)]
139#[aggregate(behaviors(SoftDelete))]
140pub struct OrganizationMember {
141 meta: Meta,
142 #[owner]
143 organization: OrganizationId,
144 account: AccountId,
145 role: OrganizationRole,
146}
147
148impl OrganizationMember {
149 pub(crate) fn new(
150 organization: OrganizationId,
151 account: AccountId,
152 role: OrganizationRole,
153 ) -> Result<Self> {
154 let mut member = Self {
155 meta: Meta::new()?,
156 organization,
157 account,
158 role,
159 };
160 member.meta.raise(MemberJoined {
161 organization: organization.uuid(),
162 account: account.uuid(),
163 role,
164 })?;
165 Ok(member)
166 }
167
168 #[must_use]
169 pub fn organization(&self) -> OrganizationId {
170 self.organization
171 }
172
173 #[must_use]
174 pub fn account(&self) -> AccountId {
175 self.account
176 }
177
178 #[must_use]
179 pub fn role(&self) -> OrganizationRole {
180 self.role
181 }
182
183 pub(crate) fn assign(&mut self, role: OrganizationRole) -> Result<()> {
184 if self.role == role {
185 return Ok(());
186 }
187 self.role = role;
188 self.meta.raise(MemberRoleChanged {
189 organization: self.organization.uuid(),
190 account: self.account.uuid(),
191 role,
192 })
193 }
194
195 pub(crate) fn leave(&mut self) -> Result<()> {
196 self.delete();
197 self.meta.raise(MemberLeft {
198 organization: self.organization.uuid(),
199 account: self.account.uuid(),
200 })
201 }
202}
203
204pub(crate) async fn membership(
205 read: &Read,
206 organization: OrganizationId,
207 account: AccountId,
208) -> Result<Option<OrganizationMember>> {
209 OrganizationMember::query()
210 .where_(OrganizationMember::ORGANIZATION.eq(organization))
211 .where_(OrganizationMember::ACCOUNT.eq(account))
212 .first(read)
213 .await
214}
215
216pub(crate) enum Kept {
217 AnOwner,
218 Nothing,
220 #[cfg(feature = "billing")]
221 Seats {
222 at_most: u32,
223 now: sofie::core::Instant,
224 },
225}
226
227#[cfg(feature = "billing")]
233pub(crate) async fn seats_kept(
234 read: &Read,
235 billing: &sofie::billing::Billing,
236 organization: OrganizationId,
237 now: sofie::core::Instant,
238 adding: u64,
239) -> Result<Kept> {
240 let members = OrganizationMember::query()
241 .where_(OrganizationMember::ORGANIZATION.eq(organization))
242 .count(read)
243 .await?;
244 let pending = crate::Invitation::query()
245 .scope(|query| crate::invitation::pending(query, organization, now))
246 .count(read)
247 .await?;
248 let seats = billing
249 .seats(sofie::billing::Subscriber::from(organization))
250 .await?;
251 let filled = members.saturating_add(pending).saturating_add(adding);
252 let filled = u32::try_from(filled).unwrap_or(u32::MAX);
253 if !seats.allows(filled) {
254 return Err(SEATS_TAKEN.into());
255 }
256 Ok(match seats {
257 sofie::billing::Seats::AtMost(at_most) => Kept::Seats { at_most, now },
258 sofie::billing::Seats::Unlimited => Kept::AnOwner,
259 })
260}
261
262#[cfg(feature = "billing")]
265pub(crate) async fn cancel_deleted_plan(
266 changed: sofie::billing::SubscriptionChanged,
267 read: Read,
268 billing: sofie::billing::Billing,
269 key: sofie::db::IdempotencyKey,
270) -> Result<()> {
271 if changed.status == sofie::billing::Status::Canceled {
272 return Ok(());
273 }
274 let deleted = Organization::query()
275 .with_trashed()
276 .where_(Organization::ID.eq(OrganizationId::from_uuid(changed.subscriber)))
277 .first(&read)
278 .await?
279 .is_some_and(|organization| organization.is_deleted());
280 if !deleted {
281 return Ok(());
282 }
283 billing
284 .cancel_at_provider(sofie::billing::Subscriber::new(changed.subscriber), &key)
285 .await
286}
287
288#[cfg(feature = "billing")]
291pub(crate) async fn cancel_plan_of_deleted(
292 deleted: OrganizationDeleted,
293 billing: sofie::billing::Billing,
294 key: sofie::db::IdempotencyKey,
295) -> Result<()> {
296 billing
297 .cancel_at_provider(sofie::billing::Subscriber::new(deleted.organization), &key)
298 .await
299}
300
301#[cfg(feature = "billing")]
302const SEATS_TAKEN: RuleText = RuleText::new(
303 "tenancy.rule.seats_taken",
304 "Every seat of the organization's plan is taken; add seats or remove a member first.",
305);
306
307pub(crate) struct Held<T> {
308 organization: OrganizationId,
309 kept: Kept,
310 change: T,
311 invited: Option<Invited>,
312 demoted: Option<Demoted>,
313}
314
315pub(crate) struct Demoted {
317 pub(crate) account: AccountId,
318 pub(crate) role: OrganizationRole,
319 pub(crate) now: sofie::core::Instant,
320}
321
322pub(crate) struct Invited {
323 email: Email,
324 now: sofie::core::Instant,
325 by: AccountId,
326 role: OrganizationRole,
327}
328
329impl<T> Held<T> {
330 pub(crate) fn new(organization: OrganizationId, kept: Kept, change: T) -> Self {
331 Self {
332 organization,
333 kept,
334 change,
335 invited: None,
336 demoted: None,
337 }
338 }
339
340 pub(crate) fn demoting(mut self, demoted: Demoted) -> Self {
342 self.demoted = Some(demoted);
343 self
344 }
345
346 pub(crate) fn inviting(mut self, invited: Invited) -> Self {
349 self.invited = Some(invited);
350 self
351 }
352}
353
354impl<T: Outcome> Outcome for Held<T> {
355 fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
356 plan.push_as::<Organization>(taken(plan, self.organization, None));
357 self.change.plan(plan)?;
358 let Some((mut condition, mut params)) = condition(plan, &self.kept) else {
359 return Ok(());
360 };
361 if let Some(invited) = &self.invited {
362 condition.push_str(&invited.rule(plan, &mut params));
363 }
364 if let Some(demoted) = &self.demoted {
365 condition.push_str(&demoted.rule(plan, &mut params));
366 }
367 plan.push_as::<Organization>(taken(plan, self.organization, Some((condition, params))));
368 Ok(())
369 }
370
371 fn committed(&mut self, stamp: Stamp) {
372 self.change.committed(stamp);
373 }
374
375 fn reply(self) -> Result<Reply> {
376 self.change.reply()
377 }
378}
379
380fn taken(
381 plan: &Plan<'_>,
382 organization: OrganizationId,
383 condition: Option<(String, Vec<SqlValue>)>,
384) -> Step {
385 let dialect = plan.dialect();
386 let version = dialect.quote("version");
387 let mut sql = format!(
388 "UPDATE {} SET {version} = {version} WHERE {} = {} AND {} IS NULL",
389 dialect.qualified(Organization::MODULE, <Organization as Aggregate>::NAME),
390 dialect.quote("id"),
391 dialect.placeholder(1),
392 dialect.quote("deleted_at"),
393 );
394 let mut params = vec![SqlValue::Uuid(organization.uuid())];
395 let Some((condition, more)) = condition else {
396 return Step::Statement {
397 sql,
398 params,
399 expect_rows: Some(ExpectedRows::or_not_found(1)),
400 };
401 };
402 sql.push_str(" AND ");
403 sql.push_str(&condition);
404 params.extend(more);
405 Step::statement(sql, params, Some(1))
406}
407
408impl Demoted {
409 fn rule(&self, plan: &Plan<'_>, params: &mut Vec<SqlValue>) -> String {
410 let dialect = plan.dialect();
411 let first = params.len() + 2;
412 params.push(SqlValue::Uuid(self.account.uuid()));
413 self.now.encode(params);
414 let mut refused = Vec::new();
415 for role in [
416 OrganizationRole::Owner,
417 OrganizationRole::Admin,
418 OrganizationRole::Member,
419 ] {
420 if self.role.managing(role == OrganizationRole::Owner).is_err() {
421 role.encode(params);
422 refused.push(dialect.placeholder(params.len() + 1));
423 }
424 }
425 if refused.is_empty() {
426 return String::new();
427 }
428 format!(
429 " AND NOT EXISTS (SELECT 1 FROM {} WHERE {} = {} AND {} = {} AND {} IS NULL AND {} > {} \
430 AND {} IN ({}))",
431 dialect.qualified(crate::Invitation::MODULE, crate::Invitation::NAME),
432 dialect.quote("organization"),
433 dialect.placeholder(1),
434 dialect.quote("invited_by"),
435 dialect.placeholder(first),
436 dialect.quote("accepted_at"),
437 dialect.quote("expires_at"),
438 dialect.placeholder(first + 1),
439 dialect.quote("role"),
440 refused.join(", "),
441 )
442 }
443}
444
445impl Invited {
446 pub(crate) fn of(invitation: &crate::Invitation, now: sofie::core::Instant) -> Self {
447 Self {
448 email: invitation.email().clone(),
449 now,
450 by: invitation.invited_by(),
451 role: invitation.role(),
452 }
453 }
454
455 fn rule(&self, plan: &Plan<'_>, params: &mut Vec<SqlValue>) -> String {
457 let dialect = plan.dialect();
458 let first = params.len() + 2;
459 params.push(SqlValue::Text(self.email.as_str().to_owned()));
460 self.now.encode(params);
461 params.push(SqlValue::Uuid(self.by.uuid()));
462 let mut allowed = Vec::new();
463 for role in [OrganizationRole::Owner, OrganizationRole::Admin] {
464 if role.managing(self.role == OrganizationRole::Owner).is_ok() {
465 role.encode(params);
466 allowed.push(dialect.placeholder(params.len() + 1));
467 }
468 }
469 format!(
470 " AND (SELECT count(*) FROM {} WHERE {} = {} AND {} = {} AND {} IS NULL AND {} > {}) <= 1 \
471 AND (SELECT {} FROM {} WHERE {} = {} AND {} = {} AND {} IS NULL) IN ({})",
472 dialect.qualified(crate::Invitation::MODULE, crate::Invitation::NAME),
473 dialect.quote("organization"),
474 dialect.placeholder(1),
475 dialect.quote("email"),
476 dialect.placeholder(first),
477 dialect.quote("accepted_at"),
478 dialect.quote("expires_at"),
479 dialect.placeholder(first + 1),
480 dialect.quote("role"),
481 dialect.qualified(OrganizationMember::MODULE, OrganizationMember::NAME),
482 dialect.quote("organization"),
483 dialect.placeholder(1),
484 dialect.quote("account"),
485 dialect.placeholder(first + 2),
486 dialect.quote("deleted_at"),
487 allowed.join(", "),
488 )
489 }
490}
491
492fn condition(plan: &Plan<'_>, kept: &Kept) -> Option<(String, Vec<SqlValue>)> {
493 let dialect = plan.dialect();
494 let members = format!(
495 "SELECT count(*) FROM {} WHERE {} = {} AND {} IS NULL",
496 dialect.qualified(OrganizationMember::MODULE, OrganizationMember::NAME),
497 dialect.quote("organization"),
498 dialect.placeholder(1),
499 dialect.quote("deleted_at"),
500 );
501 match kept {
502 Kept::AnOwner => {
503 let mut params = Vec::new();
504 OrganizationRole::Owner.encode(&mut params);
505 let owners = format!(
506 "({members} AND {} = {}) > 0",
507 dialect.quote("role"),
508 dialect.placeholder(2)
509 );
510 Some((owners, params))
511 }
512 Kept::Nothing => None,
513 #[cfg(feature = "billing")]
514 Kept::Seats { at_most, now } => {
515 let mut params = Vec::new();
516 now.encode(&mut params);
517 params.push(SqlValue::Int(i64::from(*at_most)));
518 let seated = format!(
519 "({members}) + (SELECT count(*) FROM {} WHERE {} = {} AND {} IS NULL AND {} > {}) <= {}",
520 dialect.qualified(crate::Invitation::MODULE, crate::Invitation::NAME),
521 dialect.quote("organization"),
522 dialect.placeholder(1),
523 dialect.quote("accepted_at"),
524 dialect.quote("expires_at"),
525 dialect.placeholder(2),
526 dialect.placeholder(3),
527 );
528 Some((seated, params))
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::OrganizationRole::{Admin, Member, Owner};
536
537 #[test]
538 fn an_accepted_invitation_raises_a_role_but_never_lowers_one() {
539 assert!(Owner.outranks(Admin) && Admin.outranks(Member));
540 assert!(!Member.outranks(Admin) && !Admin.outranks(Admin));
541 }
542}