1use std::collections::HashMap;
5
6use sofie::core::Instant;
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(Error::invalid("email", "is invited already"));
232 }
233 let addressed = AccountEmail::query()
234 .where_(Account::EMAIL.eq(inviting.email.clone()))
235 .first(&unscoped.0.read())
236 .await?;
237 if let Some(addressed) = addressed
238 && membership(&read, organization, addressed.id)
239 .await?
240 .is_some()
241 {
242 return Err(Error::invalid("email", "is a member already"));
243 }
244 #[cfg(feature = "billing")]
245 let kept = crate::organization::seats_kept(&read, &billing, organization, now, 1).await?;
246 #[cfg(not(feature = "billing"))]
247 let kept = Kept::AnOwner;
248 let name = stored(&read, organization).await?;
249 let inviter = AccountEmail::query()
250 .where_(Account::ID.eq(acting.member().account()))
251 .first(&unscoped.0.read())
252 .await?
253 .ok_or(Error::NotFound)?;
254 let invitation = Invitation::new(
255 organization,
256 inviting.email.clone(),
257 inviting.role,
258 inviter.id,
259 now,
260 )?;
261 let link = urls.sign(&invitation_path::<M>(invitation.id()), VALID_FOR)?;
262 let body = mailer.body_from(
263 &INVITATION_MAIL,
264 &json!({
265 "inviter": inviter.email.as_str(),
266 "organization": name.name(),
267 "link": mailer.link(&link)?,
268 }),
269 )?;
270 let subject = format!("Join {}", name.name());
271 let mail = Mail::new(Address::new(&inviting.email)?, &subject, body)?;
272 let once = Invited::of(&invitation, now);
273 Ok(Held::new(organization, kept, (invitation, mailer.send(mail)?)).inviting(once))
274}
275
276async fn withdraw(acting: Acting, mut invitation: Invitation, clock: Clock) -> Result<Invitation> {
277 acting
278 .role()
279 .managing(invitation.role() == OrganizationRole::Owner)?;
280 invitation.withdraw(clock.now())?;
281 Ok(invitation)
282}
283
284async fn not_the_last_owner(read: &Read, member: &OrganizationMember) -> Result<()> {
285 if member.role() != OrganizationRole::Owner {
286 return Ok(());
287 }
288 let owners = OrganizationMember::query()
289 .where_(OrganizationMember::ORGANIZATION.eq(member.organization()))
290 .where_(OrganizationMember::ROLE.eq(OrganizationRole::Owner))
291 .count(read)
292 .await?;
293 if owners <= 1 {
294 return Err(Error::Rule(
295 "An organization keeps at least one owner; make another member owner first.".to_owned(),
296 ));
297 }
298 Ok(())
299}
300
301async fn assign(
303 acting: Acting,
304 mut member: OrganizationMember,
305 read: Read,
306 clock: Clock,
307 role: OrganizationRole,
308) -> Result<Held<(OrganizationMember, Vec<Invitation>)>> {
309 acting
310 .role()
311 .managing(role == OrganizationRole::Owner || member.role() == OrganizationRole::Owner)?;
312 if role != OrganizationRole::Owner {
313 not_the_last_owner(&read, &member).await?;
314 }
315 member.assign(role)?;
316 let now = clock.now();
317 let withdrawn = beyond_role(&read, &member, now).await?;
318 let demoted = Demoted {
319 account: member.account(),
320 role,
321 now,
322 };
323 Ok(Held::new(member.organization(), Kept::AnOwner, (member, withdrawn)).demoting(demoted))
324}
325
326async fn beyond_role(
328 read: &Read,
329 member: &OrganizationMember,
330 now: Instant,
331) -> Result<Vec<Invitation>> {
332 let mut withdrawn = sent_by(read, member, now).await?;
333 withdrawn.retain(|invitation| {
334 member
335 .role()
336 .managing(invitation.role() == OrganizationRole::Owner)
337 .is_err()
338 });
339 for invitation in &mut withdrawn {
340 invitation.withdraw(now)?;
341 }
342 Ok(withdrawn)
343}
344
345async fn sent_by(
346 read: &Read,
347 member: &OrganizationMember,
348 now: Instant,
349) -> Result<Vec<Invitation>> {
350 Invitation::query()
351 .scope(|query| invitation::pending(query, member.organization(), now))
352 .where_(Invitation::INVITED_BY.eq(member.account()))
353 .all(read)
354 .await
355}
356
357async fn remove<O: OrganizationArea>(
358 acting: Acting,
359 member: OrganizationMember,
360 read: Read,
361 unscoped: Unscoped,
362 clock: Clock,
363) -> Result<()> {
364 if member.id() != acting.member().id() {
365 acting
366 .role()
367 .managing(member.role() == OrganizationRole::Owner)?;
368 }
369 removing::<O>(member, &read, &unscoped, clock.now()).await
370}
371
372async fn leave<O: OrganizationArea>(
373 acting: Acting,
374 read: Read,
375 unscoped: Unscoped,
376 clock: Clock,
377) -> Result<()> {
378 removing::<O>(acting.into_member(), &read, &unscoped, clock.now()).await
379}
380
381async fn removing<O: OrganizationArea>(
385 mut member: OrganizationMember,
386 read: &Read,
387 unscoped: &Unscoped,
388 now: Instant,
389) -> Result<()> {
390 not_the_last_owner(read, &member).await?;
391 member.leave()?;
392 let organization = member.organization();
393 let ended = ending_for::<O>(unscoped, organization, &[member.account()], now).await?;
394 let mut withdrawn = sent_by(read, &member, now).await?;
395 for invitation in &mut withdrawn {
396 invitation.withdraw(now)?;
397 }
398 unscoped
399 .0
400 .commit(&mut Held::new(
401 organization,
402 Kept::AnOwner,
403 (member, ended, withdrawn),
404 ))
405 .await
406}
407
408async fn ending_for<O: OrganizationArea>(
409 unscoped: &Unscoped,
410 organization: OrganizationId,
411 accounts: &[AccountId],
412 now: Instant,
413) -> Result<sofie_identity::sessions::Ended> {
414 sofie_identity::sessions::ending_as(
415 &unscoped.0.read(),
416 accounts,
417 O::NAME,
418 organization.uuid(),
419 now,
420 )
421 .await
422}
423
424async fn hand_over(
426 acting: Acting,
427 mut member: OrganizationMember,
428 read: Read,
429 clock: Clock,
430) -> Result<Held<(OrganizationMember, OrganizationMember, Vec<Invitation>)>> {
431 acting.owning()?;
432 if member.id() == acting.member().id() {
433 return Err(Error::Rule(HAND_OVER_TO_ANOTHER.to_owned()));
434 }
435 member.assign(OrganizationRole::Owner)?;
436 let mut owner = acting.into_member();
437 owner.assign(OrganizationRole::Admin)?;
438 let now = clock.now();
439 let withdrawn = beyond_role(&read, &owner, now).await?;
440 let demoted = Demoted {
441 account: owner.account(),
442 role: OrganizationRole::Admin,
443 now,
444 };
445 Ok(Held::new(
446 member.organization(),
447 Kept::AnOwner,
448 (member, owner, withdrawn),
449 )
450 .demoting(demoted))
451}
452
453const HAND_OVER_TO_ANOTHER: &str = "Hand the organization over to another of its members.";
454
455#[derive(Input)]
456pub struct Renaming {
457 #[max(100)]
458 name: String,
459}
460
461async fn rename<O: OrganizationArea>(
462 acting: Acting,
463 organization: Principal<O>,
464 renaming: Renaming,
465 read: Read,
466) -> Result<Organization> {
467 acting.owning()?;
468 let mut organization = stored(&read, organization.id()).await?;
469 organization.rename(&renaming.name)?;
470 Ok(organization)
471}
472
473async fn delete<O: OrganizationArea>(
476 acting: Acting,
477 organization: Principal<O>,
478 read: Read,
479 unscoped: Unscoped,
480 clock: Clock,
481 #[cfg(feature = "billing")] billing: sofie::billing::Billing,
482) -> Result<()> {
483 acting.owning()?;
484 let id = organization.id();
485 #[cfg(feature = "billing")]
486 if billing
487 .entitlements(sofie::billing::Subscriber::from(id))
488 .await?
489 .standing()
490 .is_some_and(sofie::billing::Standing::renews_at_provider)
491 {
492 return Err(Error::Rule(PLAN_RUNNING.to_owned()));
493 }
494 let mut organization = stored(&read, id).await?;
495 organization.dissolve()?;
496 let now = clock.now();
497 let mut members = OrganizationMember::query()
498 .where_(OrganizationMember::ORGANIZATION.eq(id))
499 .all(&read)
500 .await?;
501 for member in &mut members {
502 member.leave()?;
503 }
504 let mut withdrawn = Invitation::query()
505 .scope(|query| invitation::pending(query, id, now))
506 .all(&read)
507 .await?;
508 for invitation in &mut withdrawn {
509 invitation.withdraw(now)?;
510 }
511 let accounts: Vec<AccountId> = members.iter().map(OrganizationMember::account).collect();
512 let ended = ending_for::<O>(&unscoped, id, &accounts, now).await?;
513 unscoped
514 .0
515 .commit(&mut Held::new(
516 id,
517 Kept::Nothing,
518 (organization, members, withdrawn, ended),
519 ))
520 .await
521}
522
523#[cfg(feature = "billing")]
524const PLAN_RUNNING: &str = "Cancel the organization's plan before deleting it.";
525
526async fn stored(read: &Read, id: OrganizationId) -> Result<Organization> {
527 Organization::query()
528 .where_(Organization::ID.eq(id))
529 .first(read)
530 .await?
531 .ok_or(Error::NotFound)
532}