//! The project dashboard's Members & Payouts panel, described.
//!
//! The first panel on the writes-only nest (`03c0977b`, ruled 2026-08-26 by
//! Max, option (a)), 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.user.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.user.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.user.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"))?;
answer(viewer, project, Some(&invited(viewer, &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(viewer: &Viewer, member: &db::DbUser, split: i16) -> String {
let owner_currency = viewer.user.settlement_currency;
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(" | |