//! 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_router::screen::{Act, Cell, Cells, Column, Field, Tag}; use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; 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)) } /// The panel wrapped in its region, optionally carrying something to say. fn pane(members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>) -> Node { let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(members, owner_split, project, said) { slot = slot.with(node); } Node::Region(slot) } /// The panel's contents, in order. fn body( members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>, ) -> Vec { let mut out = vec![ Node::Link { text: "Docs: Collaborators".into(), action: Action::get("/docs/splits").navigating(), }, Node::section("Members & Payouts"), Node::text( "Add collaborators and set their share of revenue. The project owner receives the \ remainder.", ), Node::stats([ quasi_router::screen::Figure::new(format!("{owner_split}%"), "Owner's share"), quasi_router::screen::Figure::new(members.len().to_string(), "Members"), ]), ]; if let Some(said) = said { out.push(Node::toast(layout::Tone::Success, said)); } out.push(add_form(project)); if members.is_empty() { out.push(Node::empty( "No collaborators yet. Add team members above to share revenue automatically. \ The project owner receives 100% until splits are configured.", )); return out; } out.push(table(members, project)); // 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. if members.iter().any(|m| !m.accepted) { out.push(Node::text( "An invited collaborator's percentage is reserved, but earns nothing until they \ accept.", )); } out } /// The Add Member form, behind the disclosure the template gave it. fn add_form(project: &str) -> Node { // 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". Node::Region( Slot::new("project-members-add", RegionKind::Group) .with(Node::Region( Slot::new("project-members-add-body", RegionKind::Pane) .label("Add Member") .with(Node::Form { action: Action::post(format!("{NEST}/{project}")).awaiting(), submit: "Add".into(), fields: vec![ hint( Field::new(layout::FieldKind::Text, "username", "Username") .required(), "Enter username", ), bounded( Field::new(layout::FieldKind::Number, "split_percent", "Split %") .required() .value("50"), 1, 99, ), hint( Field::new(layout::FieldKind::Text, "role", "Role (optional)"), "e.g. Producer, Artist, Engineer", ), ], }), )) .showing_at_most_one(None), ) } /// Set a field's placeholder. fn hint(mut field: Field, text: &str) -> Field { field.placeholder = Some(text.to_owned()); field } /// Bound a numeric field, matching the `min`/`max` the API validates against. fn bounded(mut field: Field, min: i32, max: i32) -> Field { field.min = Some(min.to_string()); field.max = Some(max.to_string()); field } /// Who is on the project. fn table(members: &[ProjectMemberRow], project: &str) -> Node { Node::Table { columns: vec![ Column::new("Member") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Role").width(layout::Width::Content), Column::new("Split").width(layout::Width::Content), Column::new("Stripe").width(layout::Width::Content), Column::new("Added") .width(layout::Width::Content) .priority(layout::Priority::Optional), Column::new("").width(layout::Width::Content), ], rows: members.iter().map(|m| row(m, project)).collect(), more: None, } } /// One collaborator. fn row(member: &ProjectMemberRow, project: &str) -> Cells { let shown = member.display_name.as_deref().unwrap_or(&member.username); // The template packed the percentage, the badge and the sentence into one // ``. Three facts, said as three. let mut split = Cell::new(format!("{}%", member.split_percent)); if !member.accepted { let mut invited = Tag::badge("Invited"); invited.tone = layout::Tone::Warning; split = split.token(invited); } let mut stripe = Tag::badge(if member.stripe_connected { "Connected" } else { "Not connected" }); stripe.tone = if member.stripe_connected { layout::Tone::Success } else { layout::Tone::Warning }; Cells::new([ // 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::new(format!("{shown} (@{})", member.username)) .activate(Action::get(format!("/u/{}", member.username)).navigating()), Cell::new(member.role.clone()), split, Cell::new(String::new()).token(stripe), Cell::new(member.added_at.clone()), Cell::new(String::new()).act( Act::new( "Remove", Action::delete(format!("{NEST}/{project}/{}", member.user_id)), ) .tone(layout::Tone::Danger) .confirm(format!("Remove {shown} 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("