1use std::collections::HashMap;
5
6use sofie::core::{FieldError, Instant, RuleText};
7use sofie::db::{Queued, in_select};
8use sofie::http::{ListedRoute, Rate, Routes, Shipped, SignedUrls};
9use sofie::mail::{Address, Mail, Mailer, Template};
10use sofie::prelude::*;
11use sofie::serde_json::json;
12
13use sofie_identity::{Account, AccountId, MemberArea, Unscoped};
14
15use crate::acting::Acting;
16use crate::invitation::{self, Invitation, VALID_FOR};
17use crate::organization::{
18 Demoted, Held, Invited, Kept, Organization, OrganizationArea, OrganizationId,
19 OrganizationMember, OrganizationRole, membership,
20};
21use crate::organizations::{AccountEmail, invitation_path};
22
23const INVITATION_MAIL: Template = Template::new(
24 "sofie_tenancy/invitation",
25 sofie::db::embed!(
26 "src/mails/invitation.html",
27 compiled = "mails/sofie_tenancy/invitation.sft"
28 ),
29);
30
31const MEMBERS: &str = r#"<Section id="members" title="Members">
32 <Table id="member-list" of={data.members} columns="email role" as="member">
33 <Action post="members/{member.id}/admin" label="Make admin" target="members" />
34 <Action post="members/{member.id}/member" label="Make member" target="members" />
35 <Action post="members/{member.id}/remove" label="Remove" target="members" />
36 <Action post="members/{member.id}/handover" label="Hand over ownership" target="members" />
37 </Table>
38 <Table id="invitations" of={data.invitations} columns="email role expires_at" as="invitation">
39 <Action post="members/invitations/{invitation.id}/withdraw" label="Withdraw" target="members" />
40 </Table>
41 <Form id="invite" post="members/invite" target="members">
42 <Submit label="Invite" />
43 </Form>
44 <Form id="rename" post="organization/rename" target="members">
45 <Submit label="Rename the organization" />
46 </Form>
47 <form method="post" action="members/leave"><button type="submit">Leave the organization</button></form>
48 <form method="post" action="organization/delete"><button type="submit">Delete the organization</button></form>
49</Section>"#;
50
51const INVITING: Rate = Rate::per_minute(10);
53
54#[rustfmt::skip]
55pub const ROUTES: &[ListedRoute] = &[
56 ListedRoute::new("organization_routes", "GET", "/members", "list_members"),
57 ListedRoute::new("organization_routes", "POST", "/members/invite", "invite_member"),
58 ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/owner", "make_owner"),
59 ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/admin", "make_admin"),
60 ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/member", "make_member"),
61 ListedRoute::new("organization_routes", "POST", "/members/invitations/{invitation}/withdraw", "withdraw_invitation"),
62 ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/remove", "remove_member"),
63 ListedRoute::new("organization_routes", "POST", "/members/{organization_member}/handover", "hand_over_organization"),
64 ListedRoute::new("organization_routes", "POST", "/members/leave", "leave_organization"),
65 ListedRoute::new("organization_routes", "POST", "/organization/rename", "rename_organization"),
66 ListedRoute::new("organization_routes", "POST", "/organization/delete", "delete_organization"),
67];
68
69pub fn routes<O: OrganizationArea, M: MemberArea>(routes: &mut Routes<O>) {
71 let describe: sofie::http::Describe = |screens, key| {
72 screens.describe_form::<Inviting>(key, "invite")?;
73 screens.describe_form::<Renaming>(key, "rename")
74 };
75 routes
76 .shipped("/members", "members", (MEMBERS, Some(describe)), list::<O>)
77 .name("list_members");
78 routes
79 .post("/members/invite", invite::<O, M>)
80 .name("invite_member")
81 .throttle(INVITING)
82 .then_screen("members", "members");
83 for (role, path, handler) in [
84 (OrganizationRole::Owner, "owner", "make_owner"),
85 (OrganizationRole::Admin, "admin", "make_admin"),
86 (OrganizationRole::Member, "member", "make_member"),
87 ] {
88 let assigning =
89 move |acting: Acting, member: OrganizationMember, read: Read, clock: Clock| {
90 assign(acting, member, read, clock, role)
91 };
92 routes
93 .post(
94 &format!("/members/{{organization_member}}/{path}"),
95 assigning,
96 )
97 .name(handler)
98 .then_screen("members", "members");
99 }
100 routes
101 .post("/members/invitations/{invitation}/withdraw", withdraw)
102 .name("withdraw_invitation")
103 .then_screen("members", "members");
104 routes
105 .post("/members/{organization_member}/remove", remove::<O>)
106 .name("remove_member")
107 .then_screen("members", "members");
108 routes
109 .post("/members/{organization_member}/handover", hand_over)
110 .name("hand_over_organization")
111 .then_screen("members", "members");
112 routes
113 .post("/members/leave", leave::<O>)
114 .name("leave_organization")
115 .then_redirect(M::HOME);
116 routes
117 .post("/organization/rename", rename::<O>)
118 .name("rename_organization")
119 .then_screen("members", "members");
120 routes
121 .post("/organization/delete", delete::<O>)
122 .name("delete_organization")
123 .then_redirect(M::HOME);
124}
125
126#[derive(ReadModel)]
127#[read(from = OrganizationMember)]
128struct Membership {
129 id: Id<OrganizationMember>,
130 account: AccountId,
131 role: OrganizationRole,
132}
133
134#[derive(ReadModel)]
135#[read(from = Invitation)]
136struct Pending {
137 id: Id<Invitation>,
138 email: Email,
139 role: OrganizationRole,
140 expires_at: Instant,
141}
142
143#[derive(ReadModel)]
144struct Listed {
145 id: Id<OrganizationMember>,
146 email: Option<Email>,
147 role: OrganizationRole,
148}
149
150#[derive(ReadModel)]
151struct Members {
152 members: Vec<Listed>,
153 invitations: Vec<Pending>,
154}
155
156async fn list<O: OrganizationArea>(
158 _acting: Acting,
159 organization: Principal<O>,
160 read: Read,
161 unscoped: Unscoped,
162 clock: Clock,
163) -> Result<Members> {
164 let organization = organization.id();
165 let memberships = Membership::query()
166 .where_(OrganizationMember::ORGANIZATION.eq(organization))
167 .all(&read)
168 .await?;
169 let addresses: HashMap<AccountId, Email> = AccountEmail::query()
170 .where_(in_select(
171 Account::ID,
172 OrganizationMember::ACCOUNT,
173 OrganizationMember::ORGANIZATION.eq(organization),
174 ))
175 .all(&unscoped.0.read())
176 .await?
177 .into_iter()
178 .map(|address| (address.id, address.email))
179 .collect();
180 let members = memberships
181 .into_iter()
182 .map(|member| Listed {
183 id: member.id,
184 email: addresses.get(&member.account).cloned(),
185 role: member.role,
186 })
187 .collect();
188 let invitations = Pending::query()
189 .scope(|query| invitation::pending(query, organization, clock.now()))
190 .all(&read)
191 .await?;
192 Ok(Members {
193 members,
194 invitations,
195 })
196}
197
198#[derive(Input)]
199pub struct Inviting {
200 email: Email,
201 role: OrganizationRole,
202}
203
204#[expect(
205 clippy::too_many_arguments,
206 reason = "each is a separate resolved argument"
207)]
208async fn invite<O: OrganizationArea, M: MemberArea>(
209 acting: Acting,
210 organization: Principal<O>,
211 inviting: Inviting,
212 read: Read,
213 unscoped: Unscoped,
214 mailer: Mailer,
215 urls: SignedUrls,
216 clock: Clock,
217 #[cfg(feature = "billing")] billing: sofie::billing::Billing,
218) -> Result<Held<(Invitation, Queued)>> {
219 acting
220 .role()
221 .managing(inviting.role == OrganizationRole::Owner)?;
222 let organization = organization.id();
223 let now = clock.now();
224 let pending = Invitation::query().scope(|query| invitation::pending(query, organization, now));
225 if pending
226 .clone()
227 .where_(Invitation::EMAIL.eq(inviting.email.clone()))
228 .exists(&read)
229 .await?
230 {
231 return Err(refused_address(
232 "tenancy.invitation.invited_already",
233 "is invited already",
234 ));
235 }
236 let addressed = AccountEmail::query()
237 .where_(Account::EMAIL.eq(inviting.email.clone()))
238 .first(&unscoped.0.read())
239 .await?;
240 if let Some(addressed) = addressed
241 && membership(&read, organization, addressed.id)
242 .await?
243 .is_some()
244 {
245 return Err(refused_address(
246 "tenancy.invitation.member_already",
247 "is a member already",
248 ));
249 }
250 #[cfg(feature = "billing")]
251 let kept = crate::organization::seats_kept(&read, &billing, organization, now, 1).await?;
252 #[cfg(not(feature = "billing"))]
253 let kept = Kept::AnOwner;
254 let name = stored(&read, organization).await?;
255 let inviter = AccountEmail::query()
256 .where_(Account::ID.eq(acting.member().account()))
257 .first(&unscoped.0.read())
258 .await?
259 .ok_or(Error::NotFound)?;
260 let invitation = Invitation::new(
261 organization,
262 inviting.email.clone(),
263 inviting.role,
264 inviter.id,
265 now,
266 )?;
267 let link = urls.sign(&invitation_path::<M>(invitation.id()), VALID_FOR)?;
268 let body = mailer.body_from(
269 &INVITATION_MAIL,
270 &json!({
271 "inviter": inviter.email.as_str(),
272 "organization": name.name(),
273 "link": mailer.link(&link)?,
274 }),
275 )?;
276 let subject = mailer.text(
277 "tenancy.invitation_mail.subject",
278 &[("organization", name.name().to_owned())],
279 &format!("Join {}", name.name()),
280 );
281 let mail = Mail::new(Address::new(&inviting.email)?, &subject, body)?;
282 let once = Invited::of(&invitation, now);
283 Ok(Held::new(organization, kept, (invitation, mailer.send(mail)?)).inviting(once))
284}
285
286fn refused_address(key: &'static str, english: &str) -> Error {
287 Error::Invalid(vec![FieldError::new("email", key, english)])
288}
289
290async fn withdraw(acting: Acting, mut invitation: Invitation, clock: Clock) -> Result<Invitation> {
291 acting
292 .role()
293 .managing(invitation.role() == OrganizationRole::Owner)?;
294 invitation.withdraw(clock.now())?;
295 Ok(invitation)
296}
297
298async fn not_the_last_owner(read: &Read, member: &OrganizationMember) -> Result<()> {
299 if member.role() != OrganizationRole::Owner {
300 return Ok(());
301 }
302 let owners = OrganizationMember::query()
303 .where_(OrganizationMember::ORGANIZATION.eq(member.organization()))
304 .where_(OrganizationMember::ROLE.eq(OrganizationRole::Owner))
305 .count(read)
306 .await?;
307 if owners <= 1 {
308 return Err(LAST_OWNER.into());
309 }
310 Ok(())
311}
312
313async fn assign(
315 acting: Acting,
316 mut member: OrganizationMember,
317 read: Read,
318 clock: Clock,
319 role: OrganizationRole,
320) -> Result<Held<(OrganizationMember, Vec<Invitation>)>> {
321 acting
322 .role()
323 .managing(role == OrganizationRole::Owner || member.role() == OrganizationRole::Owner)?;
324 if role != OrganizationRole::Owner {
325 not_the_last_owner(&read, &member).await?;
326 }
327 member.assign(role)?;
328 let now = clock.now();
329 let withdrawn = beyond_role(&read, &member, now).await?;
330 let demoted = Demoted {
331 account: member.account(),
332 role,
333 now,
334 };
335 Ok(Held::new(member.organization(), Kept::AnOwner, (member, withdrawn)).demoting(demoted))
336}
337
338async fn beyond_role(
340 read: &Read,
341 member: &OrganizationMember,
342 now: Instant,
343) -> Result<Vec<Invitation>> {
344 let mut withdrawn = sent_by(read, member, now).await?;
345 withdrawn.retain(|invitation| {
346 member
347 .role()
348 .managing(invitation.role() == OrganizationRole::Owner)
349 .is_err()
350 });
351 for invitation in &mut withdrawn {
352 invitation.withdraw(now)?;
353 }
354 Ok(withdrawn)
355}
356
357async fn sent_by(
358 read: &Read,
359 member: &OrganizationMember,
360 now: Instant,
361) -> Result<Vec<Invitation>> {
362 Invitation::query()
363 .scope(|query| invitation::pending(query, member.organization(), now))
364 .where_(Invitation::INVITED_BY.eq(member.account()))
365 .all(read)
366 .await
367}
368
369async fn remove<O: OrganizationArea>(
370 acting: Acting,
371 member: OrganizationMember,
372 read: Read,
373 unscoped: Unscoped,
374 clock: Clock,
375) -> Result<()> {
376 if member.id() != acting.member().id() {
377 acting
378 .role()
379 .managing(member.role() == OrganizationRole::Owner)?;
380 }
381 removing::<O>(member, &read, &unscoped, clock.now()).await
382}
383
384async fn leave<O: OrganizationArea>(
385 acting: Acting,
386 read: Read,
387 unscoped: Unscoped,
388 clock: Clock,
389) -> Result<()> {
390 removing::<O>(acting.into_member(), &read, &unscoped, clock.now()).await
391}
392
393async fn removing<O: OrganizationArea>(
397 mut member: OrganizationMember,
398 read: &Read,
399 unscoped: &Unscoped,
400 now: Instant,
401) -> Result<()> {
402 not_the_last_owner(read, &member).await?;
403 member.leave()?;
404 let organization = member.organization();
405 let ended = ending_for::<O>(unscoped, organization, &[member.account()], now).await?;
406 let mut withdrawn = sent_by(read, &member, now).await?;
407 for invitation in &mut withdrawn {
408 invitation.withdraw(now)?;
409 }
410 unscoped
411 .0
412 .commit(&mut Held::new(
413 organization,
414 Kept::AnOwner,
415 (member, ended, withdrawn),
416 ))
417 .await
418}
419
420async fn ending_for<O: OrganizationArea>(
421 unscoped: &Unscoped,
422 organization: OrganizationId,
423 accounts: &[AccountId],
424 now: Instant,
425) -> Result<sofie_identity::sessions::Ended> {
426 sofie_identity::sessions::ending_as(
427 &unscoped.0.read(),
428 accounts,
429 O::NAME,
430 organization.uuid(),
431 now,
432 )
433 .await
434}
435
436async fn hand_over(
438 acting: Acting,
439 mut member: OrganizationMember,
440 read: Read,
441 clock: Clock,
442) -> Result<Held<(OrganizationMember, OrganizationMember, Vec<Invitation>)>> {
443 acting.owning()?;
444 if member.id() == acting.member().id() {
445 return Err(HAND_OVER_TO_ANOTHER.into());
446 }
447 member.assign(OrganizationRole::Owner)?;
448 let mut owner = acting.into_member();
449 owner.assign(OrganizationRole::Admin)?;
450 let now = clock.now();
451 let withdrawn = beyond_role(&read, &owner, now).await?;
452 let demoted = Demoted {
453 account: owner.account(),
454 role: OrganizationRole::Admin,
455 now,
456 };
457 Ok(Held::new(
458 member.organization(),
459 Kept::AnOwner,
460 (member, owner, withdrawn),
461 )
462 .demoting(demoted))
463}
464
465const LAST_OWNER: RuleText = RuleText::new(
466 "tenancy.rule.last_owner",
467 "An organization keeps at least one owner; make another member owner first.",
468);
469const HAND_OVER_TO_ANOTHER: RuleText = RuleText::new(
470 "tenancy.rule.hand_over_to_another",
471 "Hand the organization over to another of its members.",
472);
473
474#[derive(Input)]
475pub struct Renaming {
476 #[max(100)]
477 name: String,
478}
479
480async fn rename<O: OrganizationArea>(
481 acting: Acting,
482 organization: Principal<O>,
483 renaming: Renaming,
484 read: Read,
485) -> Result<Organization> {
486 acting.owning()?;
487 let mut organization = stored(&read, organization.id()).await?;
488 organization.rename(&renaming.name)?;
489 Ok(organization)
490}
491
492async fn delete<O: OrganizationArea>(
495 acting: Acting,
496 organization: Principal<O>,
497 read: Read,
498 unscoped: Unscoped,
499 clock: Clock,
500 #[cfg(feature = "billing")] billing: sofie::billing::Billing,
501) -> Result<()> {
502 acting.owning()?;
503 let id = organization.id();
504 #[cfg(feature = "billing")]
505 if billing
506 .entitlements(sofie::billing::Subscriber::from(id))
507 .await?
508 .standing()
509 .is_some_and(sofie::billing::Standing::renews_at_provider)
510 {
511 return Err(PLAN_RUNNING.into());
512 }
513 let mut organization = stored(&read, id).await?;
514 organization.dissolve()?;
515 let now = clock.now();
516 let mut members = OrganizationMember::query()
517 .where_(OrganizationMember::ORGANIZATION.eq(id))
518 .all(&read)
519 .await?;
520 for member in &mut members {
521 member.leave()?;
522 }
523 let mut withdrawn = Invitation::query()
524 .scope(|query| invitation::pending(query, id, now))
525 .all(&read)
526 .await?;
527 for invitation in &mut withdrawn {
528 invitation.withdraw(now)?;
529 }
530 let accounts: Vec<AccountId> = members.iter().map(OrganizationMember::account).collect();
531 let ended = ending_for::<O>(&unscoped, id, &accounts, now).await?;
532 unscoped
533 .0
534 .commit(&mut Held::new(
535 id,
536 Kept::Nothing,
537 (organization, members, withdrawn, ended),
538 ))
539 .await
540}
541
542#[cfg(feature = "billing")]
543const PLAN_RUNNING: RuleText = RuleText::new(
544 "tenancy.rule.plan_running",
545 "Cancel the organization's plan before deleting it.",
546);
547
548async fn stored(read: &Read, id: OrganizationId) -> Result<Organization> {
549 Organization::query()
550 .where_(Organization::ID.eq(id))
551 .first(read)
552 .await?
553 .ok_or(Error::NotFound)
554}