Skip to main content

max / makenotwork

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