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