//! The library's Contacts tab, described. //! //! The second authenticated screen through the description layer, and the first //! of S4's batches. Chosen by measurement rather than by the plan's original //! pick: it is one of six tab partials in the tree that hold a table and no //! client-side JavaScript at all, and of those it is the one whose route a //! reader can actually reach from a nav. //! //! Compare `routes::pages::public::landing::library_tab_contacts`, which answers //! the same address from Askama when the screen is switched off. //! //! # What it exercises //! //! Every table member the vocabulary has grown, and nothing that is still //! unnamed. Two tables; a value that is a link, in two columns and two flavours //! (a route this server answers, and a `mailto:` that leaves); a destructive //! per-row `DELETE`; and three states, since either table can be empty on its //! own and both empty is a third screen again. //! //! # It renders the same page, unlike S3's //! //! The SSH-keys tab could not be diffed against its Askama original because the //! Askama version lazy-loads two lists and the described version renders them //! inline. This one has no `hx-trigger="load"` anywhere: the Askama handler //! already runs both queries and renders both tables in the response, so the two //! renderings are comparable and the parity harness applies. That was the open //! question hanging over S4's safety argument, and picking a screen that renders //! its own data is the answer for this batch. use quasi_declare::declare; use quasi_router::{Method, Request, Response, RouteError}; use quasi_webview::Webview; use super::Viewer; use crate::db; /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26` /// deleted the flag; it survives as the marker the tab strips read. pub const SCREEN: &str = "library_contacts"; /// The address this screen answers, and the one the Askama route gives up. pub const PATH: &str = "/library/tabs/contacts"; /// The region the answer replaces: the pane the library's tab nav targets. pub const REGION: &str = "library-contacts"; /// The address a revoke calls, relative to this screen's own nest. const REVOKE: &str = "/revoke/{seller_id}"; /// The writes this screen serves. Registered under its nest by `super::mount`. pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Delete, REVOKE, revoke)]; /// One buyer who chose to share their email, as the screen needs it. pub(crate) struct BuyerView { username: String, email: String, purchases: String, spent: String, last_purchase: String, } /// One creator this reader has shared their own email with. pub(crate) struct SharedView { seller_id: String, username: String, name: String, } /// The tab. /// The one read this tab makes, for the mount that serves it from a residual. /// /// Both lists together, because the pane is one region and reading them apart /// would be two round trips where the screen has one. pub(crate) fn reading(viewer: &Viewer) -> Result<(Vec, Vec), RouteError> { let user_id = viewer.reader()?.id; let shared = viewer .block_on(db::transactions::get_shared_creators( &viewer.app.db, user_id, )) .map_err(|_| RouteError::internal("your contacts could not be read"))?; // A reader who cannot create projects has no buyers, so the second query is // skipped rather than answered with an empty set. The Askama handler makes // the same choice and it matters more here: every one of these round trips // holds a blocking-pool thread. See the module header on `super`. let profile = viewer .block_on(db::users::get_user_by_id(&viewer.app.db, user_id)) .map_err(|_| RouteError::internal("your account could not be read"))? .ok_or_else(|| RouteError::not_found("that account is gone"))?; let buyers = if profile.can_create_projects { viewer .block_on(db::transactions::get_seller_contacts( &viewer.app.db, user_id, )) .map_err(|_| RouteError::internal("your buyers could not be read"))? } else { Vec::new() }; let currency = viewer.reader()?.settlement_currency; let buyers: Vec = buyers .into_iter() .map(|c| BuyerView { username: c.username, email: c.email, purchases: c.total_purchases.to_string(), spent: crate::formatting::format_revenue(c.total_spent_cents, currency), last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(), }) .collect(); let shared: Vec = shared .into_iter() .map(|creator| SharedView { seller_id: creator.seller_id.to_string(), name: creator .display_name .clone() .unwrap_or_else(|| creator.username.clone()), username: creator.username, }) .collect(); Ok((buyers, shared)) } pub fn screen(viewer: &Viewer, _request: Request) -> Result { let (buyers, shared) = reading(viewer)?; Ok(Response::fragment(REGION, pane(&buyers, &shared))) } /// Revoke sharing with one creator, and answer with the tab as it now stands. /// /// The screen's own route rather than `DELETE /api/contacts/{id}`, which the /// Askama version calls and which answers 204. htmx never swaps a 204, so the /// described control appeared to do nothing: the revoke landed and the row /// stayed until the reader left the tab and came back. Answering the whole pane /// is what a described write is for, and it is one query more than the API route /// runs, on an action a reader takes once. pub fn revoke(viewer: &Viewer, request: Request) -> Result { // Taken by value because the handler signature is quasi's. let captures = request.captures; let seller: crate::db::UserId = captures .get("seller_id") .and_then(|id| id.parse().ok()) .ok_or_else(|| RouteError::not_found("no such creator"))?; viewer .block_on(db::transactions::revoke_contact_sharing( &viewer.app.db, viewer.reader()?.id, seller, )) .map_err(|_| RouteError::internal("that sharing could not be revoked"))?; screen(viewer, Request::get(PATH)) } declare! { /// Everything inside the tab pane. /// /// Split from the handler so a test can build it without a database, the /// same split `ssh_keys` uses and the reason a described screen is testable /// at all. /// /// The Askama version says "both empty" in a third `{% if %}` over the same /// two conditions and returns early. Said as guards, the two halves each /// carry their own, and the both-empty case is the one where neither is /// placed and the empty line is. #[staged] pub(crate) shape pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node; region REGION as Pane { empty "No contacts yet." when buyers.is_empty() and shared.is_empty(); section "Your Buyers ({buyers.len()})" unless buyers.is_empty(); text "Buyers who opted to share their email with you at purchase time." unless buyers.is_empty(); include buyers_table(buyers) unless buyers.is_empty(); section "Shared With" unless shared.is_empty(); text "You've shared your email with these creators. You can revoke sharing at any time." unless shared.is_empty(); include shared_table(shared) unless shared.is_empty(); } } declare! { /// The buyers who shared an email. /// /// Positional cells, deliberately: the headings and the row are one /// declaration and every buyer contributes the same five cells, so naming /// each column would buy nothing a reader cannot already see. Nothing is /// paged, so no `more`: this is a whole set the handler already counted. #[staged] shape buyers_table(buyers: &[BuyerView]) -> Node; table { column "Username" { width Content; priority Essential; } column "Email" { width Fill; priority Essential; } column "Purchases" { width Content; } column "Total Spent" { width Content; } column "Last Purchase" { width Content; priority Optional; } for buyer in buyers.iter() { cells { // The two flavours of a linked value in one row. A profile is a // route this server answers, so it stays inside the app; an // address is not, so it leaves. // // Navigating (`00ee7af5`): a profile is a whole document, not a // region, so the anchor is the whole of it and the swap is // dropped. Without it htmx would morph a `` page // over the cell it was clicked in. cell buyer.username.clone() { activate to get "/u/{buyer.username}" navigating; } cell buyer.email.clone() { activate to external "mailto:{buyer.email}"; } cell buyer.purchases.clone(); cell buyer.spent.clone(); cell buyer.last_purchase.clone(); } } } } declare! { /// The creators this reader has shared an email with. /// /// Positional, and here it is the only sensible reading: the act column has /// no heading to name, so a named cell would have to spell the empty string /// and the pairing would be less obvious than the order already makes it. /// Both cells are drawn for every creator. Nothing is paged, so no `more`: /// this is a whole set the handler already counted. #[staged] shape shared_table(shared: &[SharedView]) -> Node; table { column "Creator" { width Fill; priority Essential; } column "" { width Content; priority Essential; } for creator in shared.iter() { cells { // The display name is what the row reads as and the username is // where it goes, which is the pairing the template made with a // nested `{% if let %}` inside the anchor. cell creator.name.clone() { activate to get "/u/{creator.username}" navigating; } // An empty cell holding one control is the same value as // `Cell::acts`, which `Cell::new`'s own doc says. cell "" { // This screen's own route, under its own nest. The API // answers 204, which htmx never swaps, so the row stayed // after a successful revoke. See `revoke`. act "Revoke" to delete "{PATH}/revoke/{creator.seller_id}" awaiting { // The template asked with hx-confirm. Said here, a // terminal host asks in its own way and no host can // forget to ask. confirm "Revoke contact sharing with {creator.username}?"; tone Danger; } } } } } } /// The renderer this screen is drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } /// One buyer as the tests draw it. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// one too, and these are this module's own types. Test-only. #[cfg(test)] pub(crate) fn sample_buyer(username: &str) -> BuyerView { BuyerView { username: username.into(), email: format!("{username}@example.com"), purchases: "3".into(), spent: "$42.00".into(), last_purchase: "Aug 10, 2026".into(), } } /// One creator this reader shares an address with, as the tests draw it. #[cfg(test)] pub(crate) fn sample_creator(id: &str, username: &str, name: &str) -> SharedView { SharedView { seller_id: id.into(), username: username.into(), name: name.into(), } } #[cfg(test)] mod tests { use super::*; use quasi_axum::Serves; use super::{sample_buyer as buyer, sample_creator as creator}; fn render(node: &quasi_router::Node) -> String { Webview::new().fragment(node) } #[test] fn the_region_matches_the_panel_the_library_strip_gives_this_tab() { // The router says what it changed, through HX-Retarget. If this and the // strip's panel id ever disagree the tab swaps into nothing, and that // failure is invisible to every other test. // // Read off the strip rather than off `library.html`, which stopped // holding the nav when the strip was described (`6b24f2df`). Both halves // are Rust now, so the id is shared rather than transcribed -- and this // still earns its keep, because `library_tabs` names the panel and this // module names the region and nothing but this asserts they agree. let strip = crate::quasi::library_tabs::html("", true, true); assert!( strip.contains(&format!("id=\"{REGION}\"")), "the library strip has no panel called {REGION}:\n{strip}" ); assert!(strip.contains(&format!("hx-get=\"{PATH}\"")), "{strip}"); } #[test] fn a_reader_with_no_contacts_gets_one_sentence_and_no_tables() { let html = render(&pane(&[], &[])); assert!(html.contains("No contacts yet.")); assert!(!html.contains("role=\"table\""), "{html}"); } #[test] fn each_table_appears_only_when_it_has_rows() { // Three screens, not one with two empty tables. A buyer with no shared // creators is the common case for a creator account, and the reverse is // the common case for everyone else. let buyers_only = render(&pane(&[buyer("ada")], &[])); assert!(buyers_only.contains("Your Buyers (1)")); assert!(!buyers_only.contains("Shared With")); let shared_only = render(&pane(&[], &[creator("s1", "grace", "Grace H")])); assert!(!shared_only.contains("Your Buyers")); assert!(shared_only.contains("Shared With")); } #[test] fn a_buyers_name_goes_to_their_profile_and_their_address_leaves() { let html = render(&buyers_table(&[buyer("ada")])); // A read of a route this server answers: an anchor with a real href, so // middle-click and copy-link work and it stays in the app. Navigating, // so the anchor is all of it: a verb here would swap a whole document // into the cell. assert!(html.contains("href=\"/u/ada\""), "{html}"); assert!( !html.contains("hx-get=\"/u/ada\""), "a navigation carries no verb: {html}" ); assert!( !html.contains("hx-get=\"mailto"), "no htmx on a mailto: {html}" ); assert!( html.contains("href=\"mailto:ada@example.com\""), "the address is a link: {html}" ); assert!(html.contains("target=\"_blank\""), "{html}"); } #[test] fn revoking_asks_first_and_every_row_asks_about_itself() { let html = render(&shared_table(&[ creator("s1", "grace", "Grace H"), creator("s2", "alan", "Alan T"), ])); assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}"); assert!( html.contains("Revoke contact sharing with grace?"), "{html}" ); // `b279b9eb`: asking first and being destructive are marked separately, // so a row that asks is not thereby dangerous and this act has to say // both. The tone is what a terminal host colours by; without it the // renderer would be back to reading the prompt string for a hint. assert_eq!( html.matches(r#"data-tone="danger""#).count(), 2, "revoking is destructive and says so: {html}" ); // Per row rather than one shared endpoint, which is the mistake a loop // over rows makes when the id is read outside it. assert!( html.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")), "{html}" ); assert!( html.contains(&format!("hx-delete=\"{PATH}/revoke/s2\"")), "{html}" ); } #[test] fn the_row_addresses_are_the_ones_the_api_actually_answers() { // The conversion's real risk, and the one that already shipped once: a // described control addressing a route registered nowhere renders fine // and answers 404 when pressed. S3 shipped exactly that. // The revoke is this screen's own route now, so the check is that the // control and the registration agree rather than that an API path // exists. They are three lines apart and still drifted once. assert!( WRITES .iter() .any(|(method, path, _)| *method == Method::Delete && *path == REVOKE), "the revoke route is registered" ); let control = render(&shared_table(&[creator("s1", "grace", "Grace H")])); assert!( control.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")), "{control}" ); // The linked value is the same risk with no button to press: a title // that navigates nowhere is a dead link rather than a 404 on a write, // and nothing else in the suite would notice. let pages = include_str!("../routes/pages/public/mod.rs"); assert!( pages.contains("\"/u/{username}\""), "a buyer's name goes to a registered route" ); } #[test] fn a_name_a_reader_chose_cannot_smuggle_markup() { // A display name comes from a profile form, and it is the value of a // cell that is also a link, which is the newest of the paths a string // takes to the page. let html = render(&shared_table(&[creator( "s1", "grace", "", )])); assert!(!html.contains("