Skip to main content

sofie_tenancy/
organization.rs

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/// 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
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/// Raised when an account joins an organization, by founding it or by accepting an invitation.
111#[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/// Raised when a member leaves, is removed, or closes its account.
126#[derive(Event)]
127pub struct MemberLeft {
128    pub organization: Uuid,
129    pub account: Uuid,
130}
131
132/// Removed softly, so the record of who belonged stays.
133#[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    /// For the organization's deletion, which every member leaves.
214    Nothing,
215    #[cfg(feature = "billing")]
216    Seats {
217        at_most: u32,
218        now: sofie::core::Instant,
219    },
220}
221
222/// Taking the organization's row first makes concurrent changes of its members count each
223/// other, and finds a deletion committed meanwhile; a broken `Kept` rolls back as `Conflict`,
224/// and the retried request says why.
225/// One more seat for an invitation, or none for one accepted, whose seat its invitation held;
226/// refused when the plan's seats are filled, as after the plan shrank.
227#[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/// A checkout started before the deletion may complete after it, leaving a plan nobody can
258/// manage or cancel; the provider is told to cancel it.
259#[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
295/// A member whose new role sends fewer invitations than its old one.
296pub(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    /// An invitation the member sent while its demotion was read is found under the lock.
321    pub(crate) fn demoting(mut self, demoted: Demoted) -> Self {
322        self.demoted = Some(demoted);
323        self
324    }
325
326    /// Two invitations of one address at once: the later one finds the first under the lock;
327    /// an inviter demoted or removed meanwhile is found there too.
328    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(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(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    /// Placeholder 1 is the organization; the rest follow `params`.
436    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}