Skip to main content

max / makenotwork

17.5 KB · 444 lines History Blame Raw
1 //! The library's Contacts tab, described.
2 //!
3 //! The second authenticated screen through the description layer, and the first
4 //! of S4's batches. Chosen by measurement rather than by the plan's original
5 //! pick: it is one of six tab partials in the tree that hold a table and no
6 //! client-side JavaScript at all, and of those it is the one whose route a
7 //! reader can actually reach from a nav.
8 //!
9 //! Compare `routes::pages::public::landing::library_tab_contacts`, which answers
10 //! the same address from Askama when the screen is switched off.
11 //!
12 //! # What it exercises
13 //!
14 //! Every table member the vocabulary has grown, and nothing that is still
15 //! unnamed. Two tables; a value that is a link, in two columns and two flavours
16 //! (a route this server answers, and a `mailto:` that leaves); a destructive
17 //! per-row `DELETE`; and three states, since either table can be empty on its
18 //! own and both empty is a third screen again.
19 //!
20 //! # It renders the same page, unlike S3's
21 //!
22 //! The SSH-keys tab could not be diffed against its Askama original because the
23 //! Askama version lazy-loads two lists and the described version renders them
24 //! inline. This one has no `hx-trigger="load"` anywhere: the Askama handler
25 //! already runs both queries and renders both tables in the response, so the two
26 //! renderings are comparable and the parity harness applies. That was the open
27 //! question hanging over S4's safety argument, and picking a screen that renders
28 //! its own data is the answer for this batch.
29
30 use makeover_layout as layout;
31 use quasi_router::screen::{Act, Cell, Cells, Column};
32 use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
33 use quasi_webview::Webview;
34
35 use super::Viewer;
36 use crate::db;
37
38 /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26`
39 /// deleted the flag; it survives as the marker the tab strips read.
40 pub const SCREEN: &str = "library_contacts";
41
42 /// The address this screen answers, and the one the Askama route gives up.
43 pub const PATH: &str = "/library/tabs/contacts";
44
45 /// The region the answer replaces: the pane the library's tab nav targets.
46 pub const REGION: &str = "library-contacts";
47
48 /// The address a revoke calls, relative to this screen's own nest.
49 const REVOKE: &str = "/revoke/{seller_id}";
50
51 /// The writes this screen serves. Registered under its nest by `super::mount`.
52 pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Delete, REVOKE, revoke)];
53
54 /// One buyer who chose to share their email, as the screen needs it.
55 pub struct BuyerView {
56 username: String,
57 email: String,
58 purchases: String,
59 spent: String,
60 last_purchase: String,
61 }
62
63 /// One creator this reader has shared their own email with.
64 pub struct SharedView {
65 seller_id: String,
66 username: String,
67 name: String,
68 }
69
70 /// The tab.
71 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
72 let user_id = viewer.reader()?.id;
73
74 let shared = viewer
75 .block_on(db::transactions::get_shared_creators(
76 &viewer.app.db,
77 user_id,
78 ))
79 .map_err(|_| RouteError::internal("your contacts could not be read"))?;
80
81 // A reader who cannot create projects has no buyers, so the second query is
82 // skipped rather than answered with an empty set. The Askama handler makes
83 // the same choice and it matters more here: every one of these round trips
84 // holds a blocking-pool thread. See the module header on `super`.
85 let profile = viewer
86 .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
87 .map_err(|_| RouteError::internal("your account could not be read"))?
88 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
89 let buyers = if profile.can_create_projects {
90 viewer
91 .block_on(db::transactions::get_seller_contacts(
92 &viewer.app.db,
93 user_id,
94 ))
95 .map_err(|_| RouteError::internal("your buyers could not be read"))?
96 } else {
97 Vec::new()
98 };
99
100 let currency = viewer.reader()?.settlement_currency;
101 let buyers: Vec<BuyerView> = buyers
102 .into_iter()
103 .map(|c| BuyerView {
104 username: c.username,
105 email: c.email,
106 purchases: c.total_purchases.to_string(),
107 spent: crate::formatting::format_revenue(c.total_spent_cents, currency),
108 last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
109 })
110 .collect();
111 let shared: Vec<SharedView> = shared
112 .into_iter()
113 .map(|creator| SharedView {
114 seller_id: creator.seller_id.to_string(),
115 name: creator
116 .display_name
117 .clone()
118 .unwrap_or_else(|| creator.username.clone()),
119 username: creator.username,
120 })
121 .collect();
122
123 Ok(Response::fragment(REGION, pane(&buyers, &shared)))
124 }
125
126 /// Revoke sharing with one creator, and answer with the tab as it now stands.
127 ///
128 /// The screen's own route rather than `DELETE /api/contacts/{id}`, which the
129 /// Askama version calls and which answers 204. htmx never swaps a 204, so the
130 /// described control appeared to do nothing: the revoke landed and the row
131 /// stayed until the reader left the tab and came back. Answering the whole pane
132 /// is what a described write is for, and it is one query more than the API route
133 /// runs, on an action a reader takes once.
134 pub fn revoke(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
135 // Taken by value because the handler signature is quasi's.
136 let captures = request.captures;
137 let seller: crate::db::UserId = captures
138 .get("seller_id")
139 .and_then(|id| id.parse().ok())
140 .ok_or_else(|| RouteError::not_found("no such creator"))?;
141
142 viewer
143 .block_on(db::transactions::revoke_contact_sharing(
144 &viewer.app.db,
145 viewer.reader()?.id,
146 seller,
147 ))
148 .map_err(|_| RouteError::internal("that sharing could not be revoked"))?;
149
150 screen(viewer, Request::get(PATH))
151 }
152
153 /// Everything inside the tab pane.
154 ///
155 /// Split from the handler so a test can build it without a database, the same
156 /// split `ssh_keys` uses and the reason a described screen is testable at all.
157 fn pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node {
158 let mut slot = Slot::new(REGION, RegionKind::Pane);
159
160 // Both empty is its own screen and not two empty tables. The Askama version
161 // says this in a third `{% if %}` over the same two conditions.
162 if buyers.is_empty() && shared.is_empty() {
163 return Node::Region(slot.with(Node::empty("No contacts yet.")));
164 }
165
166 if !buyers.is_empty() {
167 slot = slot
168 .with(Node::section(format!("Your Buyers ({})", buyers.len())))
169 .with(Node::text(
170 "Buyers who opted to share their email with you at purchase time.",
171 ))
172 .with(buyers_table(buyers));
173 }
174
175 if !shared.is_empty() {
176 slot = slot
177 .with(Node::section("Shared With"))
178 .with(Node::text(
179 "You've shared your email with these creators. You can revoke sharing at any time.",
180 ))
181 .with(shared_table(shared));
182 }
183
184 Node::Region(slot)
185 }
186
187 /// The buyers who shared an email.
188 fn buyers_table(buyers: &[BuyerView]) -> Node {
189 Node::Table {
190 columns: vec![
191 Column::new("Username")
192 .width(layout::Width::Content)
193 .priority(layout::Priority::Essential),
194 Column::new("Email")
195 .width(layout::Width::Fill)
196 .priority(layout::Priority::Essential),
197 Column::new("Purchases").width(layout::Width::Content),
198 Column::new("Total Spent").width(layout::Width::Content),
199 Column::new("Last Purchase")
200 .width(layout::Width::Content)
201 .priority(layout::Priority::Optional),
202 ],
203 rows: buyers
204 .iter()
205 .map(|buyer| {
206 Cells::new([
207 // The two flavours of a linked value in one row. A profile
208 // is a route this server answers, so it stays inside the
209 // app; an address is not, so it leaves.
210 //
211 // Navigating (`00ee7af5`): a profile is a whole document,
212 // not a region, so the anchor is the whole of it and the
213 // swap is dropped. Without it htmx would morph a
214 // `<!doctype html>` page over the cell it was clicked in.
215 Cell::new(buyer.username.clone())
216 .activate(Action::get(format!("/u/{}", buyer.username)).navigating()),
217 Cell::new(buyer.email.clone())
218 .activate(Action::external(format!("mailto:{}", buyer.email))),
219 Cell::new(buyer.purchases.clone()),
220 Cell::new(buyer.spent.clone()),
221 Cell::new(buyer.last_purchase.clone()),
222 ])
223 })
224 .collect(),
225 // No paging described here: every one of these tables is a
226 // whole set the handler already counted.
227 more: None,
228 }
229 }
230
231 /// The creators this reader has shared an email with.
232 fn shared_table(shared: &[SharedView]) -> Node {
233 Node::Table {
234 columns: vec![
235 Column::new("Creator")
236 .width(layout::Width::Fill)
237 .priority(layout::Priority::Essential),
238 Column::new("")
239 .width(layout::Width::Content)
240 .priority(layout::Priority::Essential),
241 ],
242 rows: shared
243 .iter()
244 .map(|creator| {
245 Cells::new([
246 // The display name is what the row reads as and the username
247 // is where it goes, which is the pairing the template made
248 // with a nested `{% if let %}` inside the anchor.
249 Cell::new(creator.name.clone())
250 .activate(Action::get(format!("/u/{}", creator.username)).navigating()),
251 Cell::acts([Act::new(
252 "Revoke",
253 // This screen's own route, under its own nest. The API's
254 // answers 204, which htmx never swaps, so the row stayed
255 // after a successful revoke. See `revoke`.
256 Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)).awaiting(),
257 )
258 // The template asked with hx-confirm. Said here, a
259 // terminal host asks in its own way and no host can
260 // forget to ask.
261 .confirm(format!("Revoke contact sharing with {}?", creator.username))
262 .tone(layout::Tone::Danger)]),
263 ])
264 })
265 .collect(),
266 // No paging described here: every one of these tables is a
267 // whole set the handler already counted.
268 more: None,
269 }
270 }
271
272 /// The renderer this screen is drawn with.
273 pub fn renderer(viewer: &Viewer) -> Webview {
274 Webview::new().with_shell(viewer.shell())
275 }
276
277 #[cfg(test)]
278 mod tests {
279 use super::*;
280 use quasi_axum::Serves;
281
282 fn buyer(username: &str) -> BuyerView {
283 BuyerView {
284 username: username.into(),
285 email: format!("{username}@example.com"),
286 purchases: "3".into(),
287 spent: "$42.00".into(),
288 last_purchase: "Aug 10, 2026".into(),
289 }
290 }
291
292 fn creator(id: &str, username: &str, name: &str) -> SharedView {
293 SharedView {
294 seller_id: id.into(),
295 username: username.into(),
296 name: name.into(),
297 }
298 }
299
300 fn render(node: &Node) -> String {
301 Webview::new().fragment(node)
302 }
303
304 #[test]
305 fn the_region_matches_the_panel_the_library_strip_gives_this_tab() {
306 // The router says what it changed, through HX-Retarget. If this and the
307 // strip's panel id ever disagree the tab swaps into nothing, and that
308 // failure is invisible to every other test.
309 //
310 // Read off the strip rather than off `library.html`, which stopped
311 // holding the nav when the strip was described (`6b24f2df`). Both halves
312 // are Rust now, so the id is shared rather than transcribed -- and this
313 // still earns its keep, because `library_tabs` names the panel and this
314 // module names the region and nothing but this asserts they agree.
315 let strip = crate::quasi::library_tabs::html("", true, true);
316 assert!(
317 strip.contains(&format!("id=\"{REGION}\"")),
318 "the library strip has no panel called {REGION}:\n{strip}"
319 );
320 assert!(strip.contains(&format!("hx-get=\"{PATH}\"")), "{strip}");
321 }
322
323 #[test]
324 fn a_reader_with_no_contacts_gets_one_sentence_and_no_tables() {
325 let html = render(&pane(&[], &[]));
326 assert!(html.contains("No contacts yet."));
327 assert!(!html.contains("role=\"table\""), "{html}");
328 }
329
330 #[test]
331 fn each_table_appears_only_when_it_has_rows() {
332 // Three screens, not one with two empty tables. A buyer with no shared
333 // creators is the common case for a creator account, and the reverse is
334 // the common case for everyone else.
335 let buyers_only = render(&pane(&[buyer("ada")], &[]));
336 assert!(buyers_only.contains("Your Buyers (1)"));
337 assert!(!buyers_only.contains("Shared With"));
338
339 let shared_only = render(&pane(&[], &[creator("s1", "grace", "Grace H")]));
340 assert!(!shared_only.contains("Your Buyers"));
341 assert!(shared_only.contains("Shared With"));
342 }
343
344 #[test]
345 fn a_buyers_name_goes_to_their_profile_and_their_address_leaves() {
346 let html = render(&buyers_table(&[buyer("ada")]));
347
348 // A read of a route this server answers: an anchor with a real href, so
349 // middle-click and copy-link work and it stays in the app. Navigating,
350 // so the anchor is all of it: a verb here would swap a whole document
351 // into the cell.
352 assert!(html.contains("href=\"/u/ada\""), "{html}");
353 assert!(
354 !html.contains("hx-get=\"/u/ada\""),
355 "a navigation carries no verb: {html}"
356 );
357 assert!(
358 !html.contains("hx-get=\"mailto"),
359 "no htmx on a mailto: {html}"
360 );
361 assert!(
362 html.contains("href=\"mailto:ada@example.com\""),
363 "the address is a link: {html}"
364 );
365 assert!(html.contains("target=\"_blank\""), "{html}");
366 }
367
368 #[test]
369 fn revoking_asks_first_and_every_row_asks_about_itself() {
370 let html = render(&shared_table(&[
371 creator("s1", "grace", "Grace H"),
372 creator("s2", "alan", "Alan T"),
373 ]));
374
375 assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
376 assert!(
377 html.contains("Revoke contact sharing with grace?"),
378 "{html}"
379 );
380 // `b279b9eb`: asking first and being destructive are marked separately,
381 // so a row that asks is not thereby dangerous and this act has to say
382 // both. The tone is what a terminal host colours by; without it the
383 // renderer would be back to reading the prompt string for a hint.
384 assert_eq!(
385 html.matches(r#"data-tone="danger""#).count(),
386 2,
387 "revoking is destructive and says so: {html}"
388 );
389 // Per row rather than one shared endpoint, which is the mistake a loop
390 // over rows makes when the id is read outside it.
391 assert!(
392 html.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")),
393 "{html}"
394 );
395 assert!(
396 html.contains(&format!("hx-delete=\"{PATH}/revoke/s2\"")),
397 "{html}"
398 );
399 }
400
401 #[test]
402 fn the_row_addresses_are_the_ones_the_api_actually_answers() {
403 // The conversion's real risk, and the one that already shipped once: a
404 // described control addressing a route registered nowhere renders fine
405 // and answers 404 when pressed. S3 shipped exactly that.
406 // The revoke is this screen's own route now, so the check is that the
407 // control and the registration agree rather than that an API path
408 // exists. They are three lines apart and still drifted once.
409 assert!(
410 WRITES
411 .iter()
412 .any(|(method, path, _)| *method == Method::Delete && *path == REVOKE),
413 "the revoke route is registered"
414 );
415 let control = render(&shared_table(&[creator("s1", "grace", "Grace H")]));
416 assert!(
417 control.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")),
418 "{control}"
419 );
420
421 // The linked value is the same risk with no button to press: a title
422 // that navigates nowhere is a dead link rather than a 404 on a write,
423 // and nothing else in the suite would notice.
424 let pages = include_str!("../routes/pages/public/mod.rs");
425 assert!(
426 pages.contains("\"/u/{username}\""),
427 "a buyer's name goes to a registered route"
428 );
429 }
430
431 #[test]
432 fn a_name_a_reader_chose_cannot_smuggle_markup() {
433 // A display name comes from a profile form, and it is the value of a
434 // cell that is also a link, which is the newest of the paths a string
435 // takes to the page.
436 let html = render(&shared_table(&[creator(
437 "s1",
438 "grace",
439 "<script>x()</script>",
440 )]));
441 assert!(!html.contains("<script>x()"), "{html}");
442 }
443 }
444