Skip to main content

max / makenotwork

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