Skip to main content

max / makenotwork

15.8 KB · 413 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::{Shell, Webview};
34
35 use super::Viewer;
36 use crate::db;
37
38 /// The conversion switch's name for this screen. `QUASI_SCREENS=library_contacts`.
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 const REGION: &str = "tab-content";
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 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 struct SharedView {
64 seller_id: String,
65 username: String,
66 name: String,
67 }
68
69 /// The tab.
70 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
71 let user_id = viewer.user.id;
72
73 let shared = viewer
74 .block_on(db::transactions::get_shared_creators(
75 &viewer.app.db,
76 user_id,
77 ))
78 .map_err(|_| RouteError::internal("your contacts could not be read"))?;
79
80 // A reader who cannot create projects has no buyers, so the second query is
81 // skipped rather than answered with an empty set. The Askama handler makes
82 // the same choice and it matters more here: every one of these round trips
83 // holds a blocking-pool thread. See the module header on `super`.
84 let profile = viewer
85 .block_on(db::users::get_user_by_id(&viewer.app.db, user_id))
86 .map_err(|_| RouteError::internal("your account could not be read"))?
87 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
88 let buyers = if profile.can_create_projects {
89 viewer
90 .block_on(db::transactions::get_seller_contacts(
91 &viewer.app.db,
92 user_id,
93 ))
94 .map_err(|_| RouteError::internal("your buyers could not be read"))?
95 } else {
96 Vec::new()
97 };
98
99 let buyers: Vec<BuyerView> = buyers
100 .into_iter()
101 .map(|c| BuyerView {
102 username: c.username,
103 email: c.email,
104 purchases: c.total_purchases.to_string(),
105 spent: crate::formatting::format_revenue(
106 c.total_spent_cents,
107 viewer.user.settlement_currency,
108 ),
109 last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(),
110 })
111 .collect();
112 let shared: Vec<SharedView> = shared
113 .into_iter()
114 .map(|creator| SharedView {
115 seller_id: creator.seller_id.to_string(),
116 name: creator
117 .display_name
118 .clone()
119 .unwrap_or_else(|| creator.username.clone()),
120 username: creator.username,
121 })
122 .collect();
123
124 Ok(Response::fragment(REGION, pane(&buyers, &shared)))
125 }
126
127 /// Revoke sharing with one creator, and answer with the tab as it now stands.
128 ///
129 /// The screen's own route rather than `DELETE /api/contacts/{id}`, which the
130 /// Askama version calls and which answers 204. htmx never swaps a 204, so the
131 /// described control appeared to do nothing: the revoke landed and the row
132 /// stayed until the reader left the tab and came back. Answering the whole pane
133 /// is what a described write is for, and it is one query more than the API route
134 /// runs, on an action a reader takes once.
135 pub fn revoke(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
136 // Taken by value because the handler signature is quasi's.
137 let captures = request.captures;
138 let seller: crate::db::UserId = captures
139 .get("seller_id")
140 .and_then(|id| id.parse().ok())
141 .ok_or_else(|| RouteError::not_found("no such creator"))?;
142
143 viewer
144 .block_on(db::transactions::revoke_contact_sharing(
145 &viewer.app.db,
146 viewer.user.id,
147 seller,
148 ))
149 .map_err(|_| RouteError::internal("that sharing could not be revoked"))?;
150
151 screen(viewer, Request::get(PATH))
152 }
153
154 /// Everything inside the tab pane.
155 ///
156 /// Split from the handler so a test can build it without a database, the same
157 /// split `ssh_keys` uses and the reason a described screen is testable at all.
158 fn pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node {
159 let mut slot = Slot::new(REGION, RegionKind::Pane);
160
161 // Both empty is its own screen and not two empty tables. The Askama version
162 // says this in a third `{% if %}` over the same two conditions.
163 if buyers.is_empty() && shared.is_empty() {
164 return Node::Region(slot.with(Node::empty("No contacts yet.")));
165 }
166
167 if !buyers.is_empty() {
168 slot = slot
169 .with(Node::section(format!("Your Buyers ({})", buyers.len())))
170 .with(Node::text(
171 "Buyers who opted to share their email with you at purchase time.",
172 ))
173 .with(buyers_table(buyers));
174 }
175
176 if !shared.is_empty() {
177 slot = slot
178 .with(Node::section("Shared With"))
179 .with(Node::text(
180 "You've shared your email with these creators. You can revoke sharing at any time.",
181 ))
182 .with(shared_table(shared));
183 }
184
185 Node::Region(slot)
186 }
187
188 /// The buyers who shared an email.
189 fn buyers_table(buyers: &[BuyerView]) -> Node {
190 Node::Table {
191 columns: vec![
192 Column::new("Username")
193 .width(layout::Width::Content)
194 .priority(layout::Priority::Essential),
195 Column::new("Email")
196 .width(layout::Width::Fill)
197 .priority(layout::Priority::Essential),
198 Column::new("Purchases").width(layout::Width::Content),
199 Column::new("Total Spent").width(layout::Width::Content),
200 Column::new("Last Purchase")
201 .width(layout::Width::Content)
202 .priority(layout::Priority::Optional),
203 ],
204 rows: buyers
205 .iter()
206 .map(|buyer| {
207 Cells::new([
208 // The two flavours of a linked value in one row. A profile
209 // is a route this server answers, so it stays inside the
210 // app; an address is not, so it leaves.
211 Cell::new(buyer.username.clone())
212 .activate(Action::get(format!("/u/{}", buyer.username))),
213 Cell::new(buyer.email.clone())
214 .activate(Action::external(format!("mailto:{}", buyer.email))),
215 Cell::new(buyer.purchases.clone()),
216 Cell::new(buyer.spent.clone()),
217 Cell::new(buyer.last_purchase.clone()),
218 ])
219 })
220 .collect(),
221 }
222 }
223
224 /// The creators this reader has shared an email with.
225 fn shared_table(shared: &[SharedView]) -> Node {
226 Node::Table {
227 columns: vec![
228 Column::new("Creator")
229 .width(layout::Width::Fill)
230 .priority(layout::Priority::Essential),
231 Column::new("")
232 .width(layout::Width::Content)
233 .priority(layout::Priority::Essential),
234 ],
235 rows: shared
236 .iter()
237 .map(|creator| {
238 Cells::new([
239 // The display name is what the row reads as and the username
240 // is where it goes, which is the pairing the template made
241 // with a nested `{% if let %}` inside the anchor.
242 Cell::new(creator.name.clone())
243 .activate(Action::get(format!("/u/{}", creator.username))),
244 Cell::acts([Act::new(
245 "Revoke",
246 // This screen's own route, under its own nest. The API's
247 // answers 204, which htmx never swaps, so the row stayed
248 // after a successful revoke. See `revoke`.
249 Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)),
250 )
251 // The template asked with hx-confirm. Said here, a
252 // terminal host asks in its own way and no host can
253 // forget to ask.
254 .confirm(format!("Revoke contact sharing with {}?", creator.username))
255 .tone(layout::Tone::Danger)]),
256 ])
257 })
258 .collect(),
259 }
260 }
261
262 /// The renderer this screen is drawn with.
263 pub fn renderer(_viewer: &Viewer) -> Webview {
264 Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
265 }
266
267 #[cfg(test)]
268 mod tests {
269 use super::*;
270 use quasi_axum::Serves;
271
272 fn buyer(username: &str) -> BuyerView {
273 BuyerView {
274 username: username.into(),
275 email: format!("{username}@example.com"),
276 purchases: "3".into(),
277 spent: "$42.00".into(),
278 last_purchase: "Aug 10, 2026".into(),
279 }
280 }
281
282 fn creator(id: &str, username: &str, name: &str) -> SharedView {
283 SharedView {
284 seller_id: id.into(),
285 username: username.into(),
286 name: name.into(),
287 }
288 }
289
290 fn render(node: &Node) -> String {
291 Webview::new().fragment(node)
292 }
293
294 #[test]
295 fn the_region_matches_what_the_library_nav_targets() {
296 // The router says what it changed, through HX-Retarget. If this and the
297 // template's hx-target ever disagree the tab swaps into nothing, and
298 // that failure is invisible to every other test.
299 let nav = include_str!("../../templates/pages/library.html");
300 assert!(
301 nav.contains(&format!("hx-target=\"#{REGION}\"")),
302 "the library nav targets #{REGION}"
303 );
304 assert!(nav.contains(&format!("hx-get=\"{PATH}\"")));
305 }
306
307 #[test]
308 fn a_reader_with_no_contacts_gets_one_sentence_and_no_tables() {
309 let html = render(&pane(&[], &[]));
310 assert!(html.contains("No contacts yet."));
311 assert!(!html.contains("role=\"table\""), "{html}");
312 }
313
314 #[test]
315 fn each_table_appears_only_when_it_has_rows() {
316 // Three screens, not one with two empty tables. A buyer with no shared
317 // creators is the common case for a creator account, and the reverse is
318 // the common case for everyone else.
319 let buyers_only = render(&pane(&[buyer("ada")], &[]));
320 assert!(buyers_only.contains("Your Buyers (1)"));
321 assert!(!buyers_only.contains("Shared With"));
322
323 let shared_only = render(&pane(&[], &[creator("s1", "grace", "Grace H")]));
324 assert!(!shared_only.contains("Your Buyers"));
325 assert!(shared_only.contains("Shared With"));
326 }
327
328 #[test]
329 fn a_buyers_name_goes_to_their_profile_and_their_address_leaves() {
330 let html = render(&buyers_table(&[buyer("ada")]));
331
332 // A read of a route this server answers: an anchor with a real href, so
333 // middle-click and copy-link work and it stays in the app.
334 assert!(html.contains("href=\"/u/ada\""), "{html}");
335 assert!(
336 !html.contains("hx-get=\"mailto"),
337 "no htmx on a mailto: {html}"
338 );
339 assert!(
340 html.contains("href=\"mailto:ada@example.com\""),
341 "the address is a link: {html}"
342 );
343 assert!(html.contains("target=\"_blank\""), "{html}");
344 }
345
346 #[test]
347 fn revoking_asks_first_and_every_row_asks_about_itself() {
348 let html = render(&shared_table(&[
349 creator("s1", "grace", "Grace H"),
350 creator("s2", "alan", "Alan T"),
351 ]));
352
353 assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}");
354 assert!(
355 html.contains("Revoke contact sharing with grace?"),
356 "{html}"
357 );
358 // Per row rather than one shared endpoint, which is the mistake a loop
359 // over rows makes when the id is read outside it.
360 assert!(
361 html.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")),
362 "{html}"
363 );
364 assert!(
365 html.contains(&format!("hx-delete=\"{PATH}/revoke/s2\"")),
366 "{html}"
367 );
368 }
369
370 #[test]
371 fn the_row_addresses_are_the_ones_the_api_actually_answers() {
372 // The conversion's real risk, and the one that already shipped once: a
373 // described control addressing a route registered nowhere renders fine
374 // and answers 404 when pressed. S3 shipped exactly that.
375 // The revoke is this screen's own route now, so the check is that the
376 // control and the registration agree rather than that an API path
377 // exists. They are three lines apart and still drifted once.
378 assert!(
379 WRITES
380 .iter()
381 .any(|(method, path, _)| *method == Method::Delete && *path == REVOKE),
382 "the revoke route is registered"
383 );
384 let control = render(&shared_table(&[creator("s1", "grace", "Grace H")]));
385 assert!(
386 control.contains(&format!("hx-delete=\"{PATH}/revoke/s1\"")),
387 "{control}"
388 );
389
390 // The linked value is the same risk with no button to press: a title
391 // that navigates nowhere is a dead link rather than a 404 on a write,
392 // and nothing else in the suite would notice.
393 let pages = include_str!("../routes/pages/public/mod.rs");
394 assert!(
395 pages.contains("\"/u/{username}\""),
396 "a buyer's name goes to a registered route"
397 );
398 }
399
400 #[test]
401 fn a_name_a_reader_chose_cannot_smuggle_markup() {
402 // A display name comes from a profile form, and it is the value of a
403 // cell that is also a link, which is the newest of the paths a string
404 // takes to the page.
405 let html = render(&shared_table(&[creator(
406 "s1",
407 "grace",
408 "<script>x()</script>",
409 )]));
410 assert!(!html.contains("<script>x()"), "{html}");
411 }
412 }
413