Skip to main content

max / makenotwork

20.8 KB · 586 lines History Blame Raw
1 //! The project dashboard's Members & Payouts panel, described.
2 //!
3 //! The first panel on the writes-only nest, and the reason that mechanism
4 //! exists.
5 //!
6 //! # Two routers, one panel, and why
7 //!
8 //! `project_tab_members` answers a conditional GET through
9 //! `resolve_project_etag`, and [`super::mount`] cannot say "304 if the cache
10 //! generation has not moved". So the **read** stays on its Askama handler and
11 //! this module is its fill, exactly as [`super::project_overview`] is.
12 //!
13 //! A fill has no nest, so before `03c0977b` it had nowhere to put its
14 //! **writes**, and its two controls addressed API routes that answer a toast
15 //! and cannot name the region they changed. What patched over that was
16 //! `data-after="reset refresh"` with the panel's address and target passed
17 //! positionally in `data-arg` and `data-arg2` -- the private dispatcher
18 //! vocabulary in `frontend/src/core/dispatch.ts` that this conversion exists to
19 //! retire.
20 //!
21 //! So: the read is a fill on the Askama route, and the writes are
22 //! [`super::writes_only`] under [`NEST`]. Both render through [`body`], so the
23 //! panel a write answers with is the same panel the read draws.
24 //!
25 //! # The API routes stay, and are not what this addresses
26 //!
27 //! `POST /api/projects/{id}/members` and
28 //! `DELETE /api/projects/{project_id}/members/{user_id}` keep working for API
29 //! consumers. This panel's controls no longer call them, the same way
30 //! `ssh_keys` stopped calling `/api/users/me/ssh-keys/{id}` without deleting
31 //! it.
32 //!
33 //! The ids live in the inner paths because a nest is mounted at a fixed prefix.
34 //! See [`super::writes_only`].
35 //!
36 //! # The add form keeps its sentence, and it is worth keeping
37 //!
38 //! `add_project_member` answers a toast that is not decoration: when the
39 //! collaborator settles in a different currency from the project, it says so,
40 //! and says Stripe's conversion comes out of their share. That is the only
41 //! moment the owner is in a position to hear it before money moves. A described
42 //! answer carries it as [`Node::toast`] in the fragment rather than losing it
43 //! with the endpoint.
44 //!
45 //! # What the description says that the markup did not
46 //!
47 //! The split cell packed three facts into one `<td>`: the percentage, an
48 //! "Invited" badge, and a sentence explaining what invited means. Said as a
49 //! cell with a token and a meta line, the sentence stops being markup that only
50 //! appears inside a conditional inside a table cell.
51
52 use makeover_layout as layout;
53 use quasi_declare::declare;
54 use quasi_router::screen::{Figure, Tag};
55 use quasi_router::{Method, Request, Response, RouteError};
56 use quasi_webview::Webview;
57
58 use super::Viewer;
59 use crate::db;
60 use crate::types::ProjectMemberRow;
61
62 /// The region the answer replaces, keeping the id the page already used.
63 pub const REGION: &str = "project-members";
64
65 /// Where the writes live. A fixed prefix; the ids are in the inner paths.
66 pub const NEST: &str = "/dashboard/described/project-members";
67
68 /// Adding a collaborator, relative to [`NEST`].
69 const ADD: &str = "/{project}";
70
71 /// Removing one, relative to [`NEST`].
72 const REMOVE: &str = "/{project}/{user}";
73
74 /// The writes this panel serves. Registered under [`NEST`] by
75 /// [`super::writes_only`].
76 pub const WRITES: &[(Method, &str, super::Screen)] =
77 &[(Method::Post, ADD, add), (Method::Delete, REMOVE, remove)];
78
79 /// The section, carrying its own region id.
80 ///
81 /// One entry point rather than the `fill`/`fragment` pair the other converted
82 /// panels have, and the composite is why. `project_monetization.html` includes
83 /// this section rather than a strip drawing a region around it, so nothing else
84 /// emits the id: this does, for the first render and for a write's answer
85 /// alike. Two spellings of one id is how they drift.
86 #[must_use]
87 pub fn section(members: &[ProjectMemberRow], owner_split: i64, project: &str) -> String {
88 use quasi_axum::Serves as _;
89
90 Webview::new().fragment(&pane(members, owner_split, project, None))
91 }
92
93 declare! {
94 /// The panel wrapped in its region, optionally carrying something to say.
95 shape pane(
96 members: &[ProjectMemberRow],
97 owner_split: i64,
98 project: &str,
99 said: Option<&str>,
100 ) -> Node;
101
102 region REGION as Pane {
103 for node in body(members, owner_split, project, said) {
104 include node;
105 }
106 }
107 }
108
109 /// Whether anybody on the project has yet to accept.
110 ///
111 /// A supplier because `any` takes a closure, and it hands back a `bool`, which
112 /// is the smallest type that works and keeps it out of the population.
113 fn any_invited(members: &[ProjectMemberRow]) -> bool {
114 members.iter().any(|member| !member.accepted)
115 }
116
117 declare! {
118 /// The panel's contents, in order.
119 shape body(
120 members: &[ProjectMemberRow],
121 owner_split: i64,
122 project: &str,
123 said: Option<&str>,
124 ) -> Vec<Node>;
125
126 link "Docs: Collaborators" to get "/docs/splits" navigating;
127 section "Members & Payouts";
128 text "Add collaborators and set their share of revenue. The project owner receives the \
129 remainder.";
130 stats [
131 Figure::new("{owner_split}%", "Owner's share"),
132 Figure::new(members.len().to_string(), "Members")
133 ];
134
135 // What a write answers with, when it has something to say. An `Option` is
136 // an iterator of at most one, and `.into_iter()` is the method step that
137 // says so.
138 for note in said.into_iter() {
139 toast layout::Tone::Success note;
140 }
141
142 include add_form(project);
143
144 empty "No collaborators yet. Add team members above to share revenue automatically. \
145 The project owner receives 100% until splits are configured."
146 when members.is_empty();
147 include table(members, project) unless members.is_empty();
148
149 // The template repeated this inside every unaccepted row's split cell. A
150 // cell is a run of leaves and has no second line, and saying it once under
151 // the table is better anyway: it is one fact about the Invited badge, not a
152 // fact about each person wearing it.
153 text "An invited collaborator's percentage is reserved, but earns nothing until they \
154 accept."
155 when any_invited(members);
156 }
157
158 declare! {
159 /// The Add Member form, behind the disclosure the template gave it.
160 ///
161 /// The disclosure shape, exactly: a region showing at most one frame whose
162 /// single frame is a labelled sub-region. See wiki
163 /// `mnw-server-conversion-plan`, "How to say a disclosure".
164 shape add_form(project: &str) -> Node;
165
166 region "project-members-add" as Group {
167 region "project-members-add-body" as Pane {
168 label "Add Member";
169 form post "{NEST}/{project}" awaiting {
170 submit "Add";
171 field Text "username" "Username" {
172 required;
173 placeholder "Enter username";
174 }
175 // The bounds the API validates against, said once here.
176 field Number "split_percent" "Split %" {
177 required;
178 value "50";
179 within "1" "99";
180 }
181 field Text "role" "Role (optional)" {
182 placeholder "e.g. Producer, Artist, Engineer";
183 }
184 }
185 }
186 showing_at_most_one None;
187 }
188 }
189
190 declare! {
191 /// Who is on the project.
192 ///
193 /// The columns are declared here and the cells are built in [`row`], a
194 /// function away. Position is only safe when both lists are in front of you
195 /// at once, so the cells name their columns and this list is the only place
196 /// the order is decided.
197 shape table(members: &[ProjectMemberRow], project: &str) -> Node;
198
199 table {
200 column "Member" {
201 width Fill;
202 priority Essential;
203 }
204 column "Role" {
205 width Content;
206 }
207 column "Split" {
208 width Content;
209 }
210 column "Stripe" {
211 width Content;
212 }
213 column "Added" {
214 width Content;
215 priority Optional;
216 }
217 // The last column carries no heading, because the button says what it
218 // does. An empty name is still the name a cell has to match.
219 column "" {
220 width Content;
221 }
222
223 for member in members.iter() {
224 include row(member, project);
225 }
226 }
227 }
228
229 /// What to call a collaborator: their display name, or their handle.
230 fn shown(member: &ProjectMemberRow) -> &str {
231 member.display_name.as_deref().unwrap_or(&member.username)
232 }
233
234 /// What the Stripe badge reads.
235 fn stripe_label(member: &ProjectMemberRow) -> &'static str {
236 if member.stripe_connected {
237 "Connected"
238 } else {
239 "Not connected"
240 }
241 }
242
243 /// How it is toned. A collaborator who cannot be paid is a warning.
244 fn stripe_tone(member: &ProjectMemberRow) -> layout::Tone {
245 if member.stripe_connected {
246 layout::Tone::Success
247 } else {
248 layout::Tone::Warning
249 }
250 }
251
252 declare! {
253 /// One collaborator.
254 ///
255 /// Each cell names the column it belongs to. The headings live in
256 /// [`table`], so counting to a position here would be counting against a
257 /// list this function cannot see.
258 shape row(member: &ProjectMemberRow, project: &str) -> Row;
259
260 cells {
261 // The template drew the display name and `@username` as two lines in
262 // one `<td>`. A cell is a run of leaves and has no second line, so the
263 // handle rides in the value where a reader still sees it.
264 cell at "Member" "{shown(member)} (@{member.username})" {
265 activate to get "/u/{member.username}" navigating;
266 }
267 cell at "Role" member.role.clone();
268 // The template packed the percentage, the badge and the sentence into
269 // one `<td>`. Three facts, said as three: the sentence moved under the
270 // table, and the badge is the cell's token.
271 cell at "Split" "{member.split_percent}%" {
272 token Tag::badge("Invited").tone(layout::Tone::Warning) unless member.accepted;
273 }
274 cell at "Stripe" "" {
275 token Tag::badge(stripe_label(member)).tone(stripe_tone(member));
276 }
277 cell at "Added" member.added_at.clone();
278 cell at "" "" {
279 act "Remove" to delete "{NEST}/{project}/{member.user_id}" {
280 tone Danger;
281 confirm "Remove {shown(member)} from this project?";
282 }
283 }
284 }
285 }
286
287 /// The project this write is about, and the reader's right to touch it.
288 fn owned(viewer: &Viewer, captures: &quasi_router::Params) -> Result<db::ProjectId, RouteError> {
289 let project: db::ProjectId = captures
290 .get("project")
291 .and_then(|id| id.parse::<uuid::Uuid>().ok())
292 .ok_or_else(|| RouteError::not_found("no such project"))?
293 .into();
294
295 // The same check `routes::api::verify_project_ownership` makes, and it is
296 // not optional here: a nest is authenticated but says nothing about which
297 // projects this reader owns. Answered as not-found rather than denied, so
298 // the nest does not confirm that a project id exists to someone who does
299 // not own it.
300 let owned = viewer
301 .block_on(db::projects::get_project_by_id(&viewer.app.db, project))
302 .map_err(|_| RouteError::internal("that project could not be read"))?
303 .ok_or_else(|| RouteError::not_found("no such project"))?;
304
305 if owned.user_id != viewer.reader()?.id {
306 return Err(RouteError::not_found("no such project"));
307 }
308 Ok(project)
309 }
310
311 /// The panel as it now stands, for a write to answer with.
312 fn answer(
313 viewer: &Viewer,
314 project: db::ProjectId,
315 said: Option<&str>,
316 ) -> Result<Response, RouteError> {
317 let members = viewer
318 .block_on(db::project_members::get_project_members(
319 &viewer.app.db,
320 project,
321 ))
322 .map_err(|_| RouteError::internal("the members could not be read"))?;
323 let total = viewer
324 .block_on(db::project_members::get_total_split_percent(
325 &viewer.app.db,
326 project,
327 ))
328 .map_err(|_| RouteError::internal("the splits could not be read"))?;
329
330 let rows: Vec<ProjectMemberRow> = members
331 .iter()
332 .map(|m| ProjectMemberRow {
333 id: m.id.to_string(),
334 user_id: m.user_id.to_string(),
335 username: m.username.clone(),
336 display_name: m.display_name.clone(),
337 role: m.role.to_string(),
338 split_percent: m.split_percent,
339 stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
340 accepted: m.is_accepted(),
341 added_at: m.added_at.format("%Y-%m-%d").to_string(),
342 })
343 .collect();
344
345 Ok(Response::fragment(
346 REGION,
347 pane(&rows, 100 - total, &project.to_string(), said),
348 ))
349 }
350
351 /// Add a collaborator, and answer with the panel as it now stands.
352 pub fn add(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
353 let captures = request.captures;
354 let payload = request.payload;
355 let project = owned(viewer, &captures)?;
356
357 let split: i16 = payload
358 .get("split_percent")
359 .and_then(|s| s.parse().ok())
360 .ok_or_else(|| RouteError::conflict("that split is not a number"))?;
361 if !(1..=99).contains(&split) {
362 return Err(RouteError::conflict("Split must be between 1% and 99%"));
363 }
364
365 let username = payload
366 .get("username")
367 .ok_or_else(|| RouteError::conflict("a username is needed"))?;
368 let username =
369 db::Username::new(username).map_err(|_| RouteError::conflict("that is not a username"))?;
370
371 let member = viewer
372 .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
373 .map_err(|_| RouteError::internal("that user could not be read"))?
374 .ok_or_else(|| RouteError::conflict("no user by that name"))?;
375
376 if member.id == viewer.reader()?.id {
377 return Err(RouteError::conflict("You are already the project owner"));
378 }
379
380 let role = payload
381 .get("role")
382 .filter(|r| !r.is_empty())
383 .and_then(|r| r.parse().ok())
384 .unwrap_or(db::ProjectRole::Member);
385
386 viewer
387 .block_on(db::project_members::add_project_member(
388 &viewer.app.db,
389 project,
390 member.id,
391 role,
392 split,
393 viewer.reader()?.id,
394 ))
395 .map_err(|_| RouteError::internal("that collaborator could not be added"))?;
396
397 viewer
398 .block_on(db::projects::bump_cache_generation(&viewer.app.db, project))
399 .map_err(|_| RouteError::internal("the project could not be marked changed"))?;
400
401 let owner_currency = viewer.reader()?.settlement_currency;
402 answer(
403 viewer,
404 project,
405 Some(&invited(owner_currency, &member, split)),
406 )
407 }
408
409 /// What the owner is told, which depends on whose money crosses a currency.
410 ///
411 /// The sentence `add_project_member` answered with, kept rather than lost with
412 /// the endpoint. See the module header.
413 fn invited(
414 owner_currency: crate::currency::SettlementCurrency,
415 member: &db::DbUser,
416 split: i16,
417 ) -> String {
418 if member.settlement_currency == owner_currency {
419 format!(
420 "Invited @{} to a {split}% split. Their share starts when they accept.",
421 member.username
422 )
423 } else {
424 format!(
425 "Invited @{} to a {split}% split. This project sells in {owner_currency}, but @{} is \
426 paid in {}, so Stripe converts their share when it reaches them and the conversion \
427 comes out of it. They will see that before they accept.",
428 member.username, member.username, member.settlement_currency
429 )
430 }
431 }
432
433 /// Remove a collaborator, and answer with the panel as it now stands.
434 pub fn remove(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
435 let captures = request.captures;
436 let project = owned(viewer, &captures)?;
437
438 let user: db::UserId = captures
439 .get("user")
440 .and_then(|id| id.parse::<uuid::Uuid>().ok())
441 .ok_or_else(|| RouteError::not_found("no such member"))?
442 .into();
443
444 let removed = viewer
445 .block_on(db::project_members::remove_project_member(
446 &viewer.app.db,
447 project,
448 user,
449 ))
450 .map_err(|_| RouteError::internal("that collaborator could not be removed"))?;
451 if !removed {
452 return Err(RouteError::not_found("no such member"));
453 }
454
455 viewer
456 .block_on(db::projects::bump_cache_generation(&viewer.app.db, project))
457 .map_err(|_| RouteError::internal("the project could not be marked changed"))?;
458
459 answer(viewer, project, Some("Member removed"))
460 }
461
462 /// The renderer this panel's writes are drawn with.
463 pub fn renderer(viewer: &Viewer) -> Webview {
464 Webview::new().with_shell(viewer.shell())
465 }
466
467 #[cfg(test)]
468 mod tests {
469 use super::*;
470
471 fn member(username: &str, accepted: bool) -> ProjectMemberRow {
472 ProjectMemberRow {
473 id: "m1".into(),
474 user_id: "00000000-0000-0000-0000-000000000001".into(),
475 username: username.into(),
476 display_name: Some("Ada Lovelace".into()),
477 role: "Producer".into(),
478 split_percent: 30,
479 stripe_connected: true,
480 accepted,
481 added_at: "2026-08-01".into(),
482 }
483 }
484
485 fn render(members: &[ProjectMemberRow]) -> String {
486 section(members, 70, "p1")
487 }
488
489 #[test]
490 fn the_section_carries_the_region_its_writes_answer_into() {
491 // Nothing else emits this id: the composite includes this section
492 // rather than drawing a region around it. If the write answered a
493 // different region it would land nowhere.
494 let html = render(&[member("ada", true)]);
495 assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}");
496 }
497
498 #[test]
499 fn both_controls_address_the_nest_and_not_the_api_route() {
500 let html = render(&[member("ada", true)]);
501
502 assert!(html.contains(&format!("hx-post=\"{NEST}/p1\"")), "{html}");
503 assert!(
504 html.contains(&format!(
505 "hx-delete=\"{NEST}/p1/00000000-0000-0000-0000-000000000001\""
506 )),
507 "{html}"
508 );
509 // The API routes stay registered and are simply not what this panel
510 // calls any more.
511 assert!(!html.contains("/api/projects/"), "{html}");
512 }
513
514 #[test]
515 fn nothing_here_goes_through_the_dispatcher() {
516 // The whole point of `03c0977b`. These two sites were
517 // `data-after="reset refresh"` and `data-after="refresh"`, each with
518 // the panel's address and target passed positionally.
519 let html = render(&[member("ada", false)]);
520
521 assert!(!html.contains("data-after"), "{html}");
522 assert!(!html.contains("data-arg"), "{html}");
523 assert!(!html.contains("data-action"), "{html}");
524 }
525
526 #[test]
527 fn the_invited_sentence_is_said_once_rather_than_per_row() {
528 let pending = render(&[member("ada", false), member("bob", false)]);
529 let settled = render(&[member("ada", true)]);
530
531 assert_eq!(
532 pending.matches("earns nothing until they accept").count(),
533 1
534 );
535 assert!(
536 !settled.contains("earns nothing until they accept"),
537 "{settled}"
538 );
539 // The badge is still per row.
540 assert_eq!(pending.matches(">Invited<").count(), 2, "{pending}");
541 }
542
543 #[test]
544 fn an_empty_project_offers_the_form_and_no_table() {
545 let html = render(&[]);
546
547 assert!(html.contains("No collaborators yet."), "{html}");
548 assert!(!html.contains("role=\"table\""), "{html}");
549 // The add form is not part of the empty state; it is always offered.
550 assert!(html.contains("Add Member"), "{html}");
551 }
552
553 #[test]
554 fn the_add_form_is_a_closed_disclosure() {
555 let html = render(&[]);
556
557 assert!(html.contains("aria-expanded=\"false\""), "{html}");
558 assert!(!html.contains("data-shows=\"next\""), "{html}");
559 }
560
561 #[test]
562 fn the_split_field_carries_the_bounds_the_route_validates() {
563 // The route refuses anything outside 1..=99. A form that lets a reader
564 // type 150 before refusing it is worse than one that stops them.
565 let html = render(&[]);
566 assert!(html.contains("min=\"1\""), "{html}");
567 assert!(html.contains("max=\"99\""), "{html}");
568 }
569
570 #[test]
571 fn a_display_name_cannot_smuggle_markup() {
572 let mut hostile = member("ada", true);
573 hostile.display_name = Some("<script>x()</script>".into());
574 assert!(!render(&[hostile]).contains("<script>x()"));
575 }
576
577 #[test]
578 fn the_owners_remainder_is_shown() {
579 let html = render(&[member("ada", true)]);
580 assert!(html.contains("70%"), "{html}");
581 // Escaped, because the renderer escapes text and an apostrophe is one
582 // of the five characters that matter in an attribute value.
583 assert!(html.contains("Owner&#39;s share"), "{html}");
584 }
585 }
586