//! The project dashboard's Members & Payouts panel, described. //! //! The first panel on the writes-only nest, and the reason that mechanism //! exists. //! //! # Two routers, one panel, and why //! //! `project_tab_members` answers a conditional GET through //! `resolve_project_etag`, and [`super::mount`] cannot say "304 if the cache //! generation has not moved". So the **read** stays on its Askama handler and //! this module is its fill, exactly as [`super::project_overview`] is. //! //! A fill has no nest, so before `03c0977b` it had nowhere to put its //! **writes**, and its two controls addressed API routes that answer a toast //! and cannot name the region they changed. What patched over that was //! `data-after="reset refresh"` with the panel's address and target passed //! positionally in `data-arg` and `data-arg2` -- the private dispatcher //! vocabulary in `frontend/src/core/dispatch.ts` that this conversion exists to //! retire. //! //! So: the read is a fill on the Askama route, and the writes are //! [`super::writes_only`] under [`NEST`]. Both render through [`body`], so the //! panel a write answers with is the same panel the read draws. //! //! # The API routes stay, and are not what this addresses //! //! `POST /api/projects/{id}/members` and //! `DELETE /api/projects/{project_id}/members/{user_id}` keep working for API //! consumers. This panel's controls no longer call them, the same way //! `ssh_keys` stopped calling `/api/users/me/ssh-keys/{id}` without deleting //! it. //! //! The ids live in the inner paths because a nest is mounted at a fixed prefix. //! See [`super::writes_only`]. //! //! # The add form keeps its sentence, and it is worth keeping //! //! `add_project_member` answers a toast that is not decoration: when the //! collaborator settles in a different currency from the project, it says so, //! and says Stripe's conversion comes out of their share. That is the only //! moment the owner is in a position to hear it before money moves. A described //! answer carries it as [`Node::toast`] in the fragment rather than losing it //! with the endpoint. //! //! # What the description says that the markup did not //! //! The split cell packed three facts into one ``: the percentage, an //! "Invited" badge, and a sentence explaining what invited means. Said as a //! cell with a token and a meta line, the sentence stops being markup that only //! appears inside a conditional inside a table cell. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::{Figure, Tag}; use quasi_router::{Method, Request, Response, RouteError}; use quasi_webview::Webview; use super::Viewer; use crate::db; use crate::types::ProjectMemberRow; /// The region the answer replaces, keeping the id the page already used. pub const REGION: &str = "project-members"; /// Where the writes live. A fixed prefix; the ids are in the inner paths. pub const NEST: &str = "/dashboard/described/project-members"; /// Adding a collaborator, relative to [`NEST`]. const ADD: &str = "/{project}"; /// Removing one, relative to [`NEST`]. const REMOVE: &str = "/{project}/{user}"; /// The writes this panel serves. Registered under [`NEST`] by /// [`super::writes_only`]. pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Post, ADD, add), (Method::Delete, REMOVE, remove)]; /// The section, carrying its own region id. /// /// One entry point rather than the `fill`/`fragment` pair the other converted /// panels have, and the composite is why. `project_monetization.html` includes /// this section rather than a strip drawing a region around it, so nothing else /// emits the id: this does, for the first render and for a write's answer /// alike. Two spellings of one id is how they drift. #[must_use] pub fn section(members: &[ProjectMemberRow], owner_split: i64, project: &str) -> String { use quasi_axum::Serves as _; Webview::new().fragment(&pane(members, owner_split, project, None)) } declare! { /// The panel wrapped in its region, optionally carrying something to say. shape pane( members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>, ) -> Node; region REGION as Pane { for node in body(members, owner_split, project, said) { include node; } } } /// Whether anybody on the project has yet to accept. /// /// A supplier because `any` takes a closure, and it hands back a `bool`, which /// is the smallest type that works and keeps it out of the population. fn any_invited(members: &[ProjectMemberRow]) -> bool { members.iter().any(|member| !member.accepted) } declare! { /// The panel's contents, in order. shape body( members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>, ) -> Vec; link "Docs: Collaborators" to get "/docs/splits" navigating; section "Members & Payouts"; text "Add collaborators and set their share of revenue. The project owner receives the \ remainder."; stats [ Figure::new("{owner_split}%", "Owner's share"), Figure::new(members.len().to_string(), "Members") ]; // What a write answers with, when it has something to say. An `Option` is // an iterator of at most one, and `.into_iter()` is the method step that // says so. for note in said.into_iter() { toast layout::Tone::Success note; } include add_form(project); empty "No collaborators yet. Add team members above to share revenue automatically. \ The project owner receives 100% until splits are configured." when members.is_empty(); include table(members, project) unless members.is_empty(); // The template repeated this inside every unaccepted row's split cell. A // cell is a run of leaves and has no second line, and saying it once under // the table is better anyway: it is one fact about the Invited badge, not a // fact about each person wearing it. text "An invited collaborator's percentage is reserved, but earns nothing until they \ accept." when any_invited(members); } declare! { /// The Add Member form, behind the disclosure the template gave it. /// /// The disclosure shape, exactly: a region showing at most one frame whose /// single frame is a labelled sub-region. See wiki /// `mnw-server-conversion-plan`, "How to say a disclosure". shape add_form(project: &str) -> Node; region "project-members-add" as Group { region "project-members-add-body" as Pane { label "Add Member"; form post "{NEST}/{project}" awaiting { submit "Add"; field Text "username" "Username" { required; placeholder "Enter username"; } // The bounds the API validates against, said once here. field Number "split_percent" "Split %" { required; value "50"; within "1" "99"; } field Text "role" "Role (optional)" { placeholder "e.g. Producer, Artist, Engineer"; } } } showing_at_most_one None; } } declare! { /// Who is on the project. /// /// The columns are declared here and the cells are built in [`row`], a /// function away. Position is only safe when both lists are in front of you /// at once, so the cells name their columns and this list is the only place /// the order is decided. shape table(members: &[ProjectMemberRow], project: &str) -> Node; table { column "Member" { width Fill; priority Essential; } column "Role" { width Content; } column "Split" { width Content; } column "Stripe" { width Content; } column "Added" { width Content; priority Optional; } // The last column carries no heading, because the button says what it // does. An empty name is still the name a cell has to match. column "" { width Content; } for member in members.iter() { include row(member, project); } } } /// What to call a collaborator: their display name, or their handle. fn shown(member: &ProjectMemberRow) -> &str { member.display_name.as_deref().unwrap_or(&member.username) } /// What the Stripe badge reads. fn stripe_label(member: &ProjectMemberRow) -> &'static str { if member.stripe_connected { "Connected" } else { "Not connected" } } /// How it is toned. A collaborator who cannot be paid is a warning. fn stripe_tone(member: &ProjectMemberRow) -> layout::Tone { if member.stripe_connected { layout::Tone::Success } else { layout::Tone::Warning } } declare! { /// One collaborator. /// /// Each cell names the column it belongs to. The headings live in /// [`table`], so counting to a position here would be counting against a /// list this function cannot see. shape row(member: &ProjectMemberRow, project: &str) -> Row; cells { // The template drew the display name and `@username` as two lines in // one ``. A cell is a run of leaves and has no second line, so the // handle rides in the value where a reader still sees it. cell at "Member" "{shown(member)} (@{member.username})" { activate to get "/u/{member.username}" navigating; } cell at "Role" member.role.clone(); // The template packed the percentage, the badge and the sentence into // one ``. Three facts, said as three: the sentence moved under the // table, and the badge is the cell's token. cell at "Split" "{member.split_percent}%" { token Tag::badge("Invited").tone(layout::Tone::Warning) unless member.accepted; } cell at "Stripe" "" { token Tag::badge(stripe_label(member)).tone(stripe_tone(member)); } cell at "Added" member.added_at.clone(); cell at "" "" { act "Remove" to delete "{NEST}/{project}/{member.user_id}" { tone Danger; confirm "Remove {shown(member)} from this project?"; } } } } /// The project this write is about, and the reader's right to touch it. fn owned(viewer: &Viewer, captures: &quasi_router::Params) -> Result { let project: db::ProjectId = captures .get("project") .and_then(|id| id.parse::().ok()) .ok_or_else(|| RouteError::not_found("no such project"))? .into(); // The same check `routes::api::verify_project_ownership` makes, and it is // not optional here: a nest is authenticated but says nothing about which // projects this reader owns. Answered as not-found rather than denied, so // the nest does not confirm that a project id exists to someone who does // not own it. let owned = viewer .block_on(db::projects::get_project_by_id(&viewer.app.db, project)) .map_err(|_| RouteError::internal("that project could not be read"))? .ok_or_else(|| RouteError::not_found("no such project"))?; if owned.user_id != viewer.reader()?.id { return Err(RouteError::not_found("no such project")); } Ok(project) } /// The panel as it now stands, for a write to answer with. fn answer( viewer: &Viewer, project: db::ProjectId, said: Option<&str>, ) -> Result { let members = viewer .block_on(db::project_members::get_project_members( &viewer.app.db, project, )) .map_err(|_| RouteError::internal("the members could not be read"))?; let total = viewer .block_on(db::project_members::get_total_split_percent( &viewer.app.db, project, )) .map_err(|_| RouteError::internal("the splits could not be read"))?; let rows: Vec = members .iter() .map(|m| ProjectMemberRow { id: m.id.to_string(), user_id: m.user_id.to_string(), username: m.username.clone(), display_name: m.display_name.clone(), role: m.role.to_string(), split_percent: m.split_percent, stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled, accepted: m.is_accepted(), added_at: m.added_at.format("%Y-%m-%d").to_string(), }) .collect(); Ok(Response::fragment( REGION, pane(&rows, 100 - total, &project.to_string(), said), )) } /// Add a collaborator, and answer with the panel as it now stands. pub fn add(viewer: &Viewer, request: Request) -> Result { let captures = request.captures; let payload = request.payload; let project = owned(viewer, &captures)?; let split: i16 = payload .get("split_percent") .and_then(|s| s.parse().ok()) .ok_or_else(|| RouteError::conflict("that split is not a number"))?; if !(1..=99).contains(&split) { return Err(RouteError::conflict("Split must be between 1% and 99%")); } let username = payload .get("username") .ok_or_else(|| RouteError::conflict("a username is needed"))?; let username = db::Username::new(username).map_err(|_| RouteError::conflict("that is not a username"))?; let member = viewer .block_on(db::users::get_user_by_username(&viewer.app.db, &username)) .map_err(|_| RouteError::internal("that user could not be read"))? .ok_or_else(|| RouteError::conflict("no user by that name"))?; if member.id == viewer.reader()?.id { return Err(RouteError::conflict("You are already the project owner")); } let role = payload .get("role") .filter(|r| !r.is_empty()) .and_then(|r| r.parse().ok()) .unwrap_or(db::ProjectRole::Member); viewer .block_on(db::project_members::add_project_member( &viewer.app.db, project, member.id, role, split, viewer.reader()?.id, )) .map_err(|_| RouteError::internal("that collaborator could not be added"))?; viewer .block_on(db::projects::bump_cache_generation(&viewer.app.db, project)) .map_err(|_| RouteError::internal("the project could not be marked changed"))?; let owner_currency = viewer.reader()?.settlement_currency; answer( viewer, project, Some(&invited(owner_currency, &member, split)), ) } /// What the owner is told, which depends on whose money crosses a currency. /// /// The sentence `add_project_member` answered with, kept rather than lost with /// the endpoint. See the module header. fn invited( owner_currency: crate::currency::SettlementCurrency, member: &db::DbUser, split: i16, ) -> String { if member.settlement_currency == owner_currency { format!( "Invited @{} to a {split}% split. Their share starts when they accept.", member.username ) } else { format!( "Invited @{} to a {split}% split. This project sells in {owner_currency}, but @{} is \ paid in {}, so Stripe converts their share when it reaches them and the conversion \ comes out of it. They will see that before they accept.", member.username, member.username, member.settlement_currency ) } } /// Remove a collaborator, and answer with the panel as it now stands. pub fn remove(viewer: &Viewer, request: Request) -> Result { let captures = request.captures; let project = owned(viewer, &captures)?; let user: db::UserId = captures .get("user") .and_then(|id| id.parse::().ok()) .ok_or_else(|| RouteError::not_found("no such member"))? .into(); let removed = viewer .block_on(db::project_members::remove_project_member( &viewer.app.db, project, user, )) .map_err(|_| RouteError::internal("that collaborator could not be removed"))?; if !removed { return Err(RouteError::not_found("no such member")); } viewer .block_on(db::projects::bump_cache_generation(&viewer.app.db, project)) .map_err(|_| RouteError::internal("the project could not be marked changed"))?; answer(viewer, project, Some("Member removed")) } /// The renderer this panel's writes are drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } #[cfg(test)] mod tests { use super::*; fn member(username: &str, accepted: bool) -> ProjectMemberRow { ProjectMemberRow { id: "m1".into(), user_id: "00000000-0000-0000-0000-000000000001".into(), username: username.into(), display_name: Some("Ada Lovelace".into()), role: "Producer".into(), split_percent: 30, stripe_connected: true, accepted, added_at: "2026-08-01".into(), } } fn render(members: &[ProjectMemberRow]) -> String { section(members, 70, "p1") } #[test] fn the_section_carries_the_region_its_writes_answer_into() { // Nothing else emits this id: the composite includes this section // rather than drawing a region around it. If the write answered a // different region it would land nowhere. let html = render(&[member("ada", true)]); assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}"); } #[test] fn both_controls_address_the_nest_and_not_the_api_route() { let html = render(&[member("ada", true)]); assert!(html.contains(&format!("hx-post=\"{NEST}/p1\"")), "{html}"); assert!( html.contains(&format!( "hx-delete=\"{NEST}/p1/00000000-0000-0000-0000-000000000001\"" )), "{html}" ); // The API routes stay registered and are simply not what this panel // calls any more. assert!(!html.contains("/api/projects/"), "{html}"); } #[test] fn nothing_here_goes_through_the_dispatcher() { // The whole point of `03c0977b`. These two sites were // `data-after="reset refresh"` and `data-after="refresh"`, each with // the panel's address and target passed positionally. let html = render(&[member("ada", false)]); assert!(!html.contains("data-after"), "{html}"); assert!(!html.contains("data-arg"), "{html}"); assert!(!html.contains("data-action"), "{html}"); } #[test] fn the_invited_sentence_is_said_once_rather_than_per_row() { let pending = render(&[member("ada", false), member("bob", false)]); let settled = render(&[member("ada", true)]); assert_eq!( pending.matches("earns nothing until they accept").count(), 1 ); assert!( !settled.contains("earns nothing until they accept"), "{settled}" ); // The badge is still per row. assert_eq!(pending.matches(">Invited<").count(), 2, "{pending}"); } #[test] fn an_empty_project_offers_the_form_and_no_table() { let html = render(&[]); assert!(html.contains("No collaborators yet."), "{html}"); assert!(!html.contains("role=\"table\""), "{html}"); // The add form is not part of the empty state; it is always offered. assert!(html.contains("Add Member"), "{html}"); } #[test] fn the_add_form_is_a_closed_disclosure() { let html = render(&[]); assert!(html.contains("aria-expanded=\"false\""), "{html}"); assert!(!html.contains("data-shows=\"next\""), "{html}"); } #[test] fn the_split_field_carries_the_bounds_the_route_validates() { // The route refuses anything outside 1..=99. A form that lets a reader // type 150 before refusing it is worse than one that stops them. let html = render(&[]); assert!(html.contains("min=\"1\""), "{html}"); assert!(html.contains("max=\"99\""), "{html}"); } #[test] fn a_display_name_cannot_smuggle_markup() { let mut hostile = member("ada", true); hostile.display_name = Some("".into()); assert!(!render(&[hostile]).contains("