Skip to main content

sofie_tenancy/
organization.rs

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/// Owns itself, as an account does, so its area reaches it and what it owns. Deleted softly,
9/// so the records its members' histories name stay.
10#[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/// Raised when an owner deletes the organization, after every member left. What it owns in
61/// other modules stays until they erase it, listening with `#[on]`.
62#[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    /// Everything, the owners' own roles included; an organization keeps at least one.
74    Owner,
75    /// Invites, changes roles and removes members, other than owners.
76    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/// Raised when an account joins an organization, by founding it or by accepting an invitation.
116#[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/// Raised when a member leaves, is removed, or closes its account.
131#[derive(Event)]
132pub struct MemberLeft {
133    pub organization: Uuid,
134    pub account: Uuid,
135}
136
137/// Removed softly, so the record of who belonged stays.
138#[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    /// For the organization's deletion, which every member leaves.
219    Nothing,
220    #[cfg(feature = "billing")]
221    Seats {
222        at_most: u32,
223        now: sofie::core::Instant,
224    },
225}
226
227/// Taking the organization's row first makes concurrent changes of its members count each
228/// other, and finds a deletion committed meanwhile; a broken `Kept` rolls back as `Conflict`,
229/// and the retried request says why.
230/// One more seat for an invitation, or none for one accepted, whose seat its invitation held;
231/// refused when the plan's seats are filled, as after the plan shrank.
232#[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/// A checkout started before the deletion may complete after it, leaving a plan nobody can
263/// manage or cancel; the provider is told to cancel it.
264#[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/// A plan's change heard before the deletion committed found the organization still there,
289/// so the deletion cancels whatever plan runs by then.
290#[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
315/// A member whose new role sends fewer invitations than its old one.
316pub(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    /// An invitation the member sent while its demotion was read is found under the lock.
341    pub(crate) fn demoting(mut self, demoted: Demoted) -> Self {
342        self.demoted = Some(demoted);
343        self
344    }
345
346    /// Two invitations of one address at once: the later one finds the first under the lock;
347    /// an inviter demoted or removed meanwhile is found there too.
348    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    /// Placeholder 1 is the organization; the rest follow `params`.
456    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}