//! 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(" | |