max / makenotwork
6 files changed,
+170 insertions,
-98 deletions
| @@ -20,7 +20,12 @@ You can follow three kinds of things: | |||
| 20 | 20 | |-------------|--------------------------| | |
| 21 | 21 | | **Creator** | Everything they publish, across all their projects | | |
| 22 | 22 | | **Project** | New items added to that specific project | | |
| 23 | - | | **Tag** | New items from any creator tagged with that tag | | |
| 23 | + | | **Tag** | New items from any creator tagged with that tag, or anything under it | | |
| 24 | + | ||
| 25 | + | Following a tag follows its whole branch. Follow `audio` and you get everything | |
| 26 | + | tagged anywhere beneath it, down to the most specific tag. Follow | |
| 27 | + | `audio.genre.techno` and you get only that. Pick the rung that matches how wide | |
| 28 | + | you want the feed to be. | |
| 24 | 29 | ||
| 25 | 30 | Unfollow the same way: click the Follow button again. | |
| 26 | 31 |
| @@ -74,15 +74,27 @@ pub async fn is_following( | |||
| 74 | 74 | Ok(row.0) | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | /// Get recent public items from all users and projects this user follows. | |
| 78 | - | /// Returns up to 50 items, newest first. | |
| 79 | - | #[tracing::instrument(skip_all)] | |
| 80 | - | pub async fn get_followed_items( | |
| 81 | - | pool: &PgPool, | |
| 82 | - | follower_id: UserId, | |
| 83 | - | ) -> Result<Vec<super::models::DbItem>> { | |
| 84 | - | let items = sqlx::query_as::<_, super::models::DbItem>( | |
| 85 | - | r#" | |
| 77 | + | /// The set of item ids visible in a follower's feed: everything from a followed | |
| 78 | + | /// user, a followed project, or a followed tag. `$1` is the follower. | |
| 79 | + | /// | |
| 80 | + | /// Tag following is HIERARCHICAL — following a branch follows everything under | |
| 81 | + | /// it, matching how the discover tag filter has always behaved | |
| 82 | + | /// (`db::discover::TAG_CLAUSE`). The two used to disagree while sitting next to | |
| 83 | + | /// each other in the drill-down: the filter expanded via `path LIKE`, following | |
| 84 | + | /// joined `follows.target_id = item_tags.tag_id` exactly. Since `item_tags` | |
| 85 | + | /// holds only directly-assigned tags and the write path enforces `depth >= 3`, | |
| 86 | + | /// following any depth-1 or depth-2 tag matched nothing, forever. | |
| 87 | + | /// | |
| 88 | + | /// The descendant match is `t.path LIKE ft.path || '.%'` rather than a | |
| 89 | + | /// `tagtree::like_descendant_pattern` bind, because the followed paths are not | |
| 90 | + | /// known host-side here — they come out of `follows` in the same query. That is | |
| 91 | + | /// safe only because `tagtree` validation restricts a path to lowercase | |
| 92 | + | /// alphanumerics, hyphens, and dots, so `escape_like` is a no-op on every value | |
| 93 | + | /// that can reach this column. If that charset ever widens, this must become a | |
| 94 | + | /// bound pattern built by `tagtree::like_descendant_pattern`. | |
| 95 | + | /// | |
| 96 | + | /// Shared verbatim by the three feed queries below so the three cannot drift. | |
| 97 | + | const FOLLOWED_ITEM_IDS_CTE: &str = r#" | |
| 86 | 98 | WITH followed_item_ids AS ( | |
| 87 | 99 | SELECT i.id FROM items i | |
| 88 | 100 | JOIN projects p ON i.project_id = p.id | |
| @@ -100,15 +112,29 @@ pub async fn get_followed_items( | |||
| 100 | 112 | JOIN projects p ON i.project_id = p.id | |
| 101 | 113 | JOIN users u ON u.id = p.user_id | |
| 102 | 114 | JOIN item_tags it ON it.item_id = i.id | |
| 103 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 104 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 115 | + | JOIN tags t ON t.id = it.tag_id | |
| 116 | + | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' | |
| 117 | + | JOIN tags ft ON ft.id = f.target_id | |
| 118 | + | WHERE (t.id = ft.id OR t.path LIKE ft.path || '.%') | |
| 119 | + | AND i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 105 | 120 | ) | |
| 121 | + | "#; | |
| 122 | + | ||
| 123 | + | /// Get recent public items from all users and projects this user follows. | |
| 124 | + | /// Returns up to 50 items, newest first. | |
| 125 | + | #[tracing::instrument(skip_all)] | |
| 126 | + | pub async fn get_followed_items( | |
| 127 | + | pool: &PgPool, | |
| 128 | + | follower_id: UserId, | |
| 129 | + | ) -> Result<Vec<super::models::DbItem>> { | |
| 130 | + | let items = sqlx::query_as::<_, super::models::DbItem>(&format!( | |
| 131 | + | r#"{FOLLOWED_ITEM_IDS_CTE} | |
| 106 | 132 | SELECT i.* FROM items i | |
| 107 | 133 | JOIN followed_item_ids fi ON fi.id = i.id | |
| 108 | 134 | ORDER BY i.created_at DESC | |
| 109 | 135 | LIMIT 50 | |
| 110 | - | "#, | |
| 111 | - | ) | |
| 136 | + | "# | |
| 137 | + | )) | |
| 112 | 138 | .bind(follower_id) | |
| 113 | 139 | .fetch_all(pool) | |
| 114 | 140 | .await?; | |
| @@ -125,28 +151,8 @@ pub async fn get_followed_feed_items( | |||
| 125 | 151 | limit: i64, | |
| 126 | 152 | offset: i64, | |
| 127 | 153 | ) -> Result<Vec<super::models::DbDiscoverItemRow>> { | |
| 128 | - | let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>( | |
| 129 | - | r#" | |
| 130 | - | WITH followed_item_ids AS ( | |
| 131 | - | SELECT i.id FROM items i | |
| 132 | - | JOIN projects p ON i.project_id = p.id | |
| 133 | - | JOIN users u ON u.id = p.user_id | |
| 134 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id | |
| 135 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 136 | - | UNION | |
| 137 | - | SELECT i.id FROM items i | |
| 138 | - | JOIN projects p ON i.project_id = p.id | |
| 139 | - | JOIN users u ON u.id = p.user_id | |
| 140 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id | |
| 141 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 142 | - | UNION | |
| 143 | - | SELECT i.id FROM items i | |
| 144 | - | JOIN projects p ON i.project_id = p.id | |
| 145 | - | JOIN users u ON u.id = p.user_id | |
| 146 | - | JOIN item_tags it ON it.item_id = i.id | |
| 147 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 148 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 149 | - | ) | |
| 154 | + | let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(&format!( | |
| 155 | + | r#"{FOLLOWED_ITEM_IDS_CTE} | |
| 150 | 156 | SELECT | |
| 151 | 157 | i.id, | |
| 152 | 158 | i.title, | |
| @@ -170,8 +176,8 @@ pub async fn get_followed_feed_items( | |||
| 170 | 176 | LEFT JOIN tags pt ON pt.id = pit.tag_id | |
| 171 | 177 | ORDER BY i.created_at DESC | |
| 172 | 178 | LIMIT $2 OFFSET $3 | |
| 173 | - | "#, | |
| 174 | - | ) | |
| 179 | + | "# | |
| 180 | + | )) | |
| 175 | 181 | .bind(follower_id) | |
| 176 | 182 | .bind(limit) | |
| 177 | 183 | .bind(offset) | |
| @@ -187,31 +193,11 @@ pub async fn count_followed_feed_items( | |||
| 187 | 193 | pool: &PgPool, | |
| 188 | 194 | follower_id: UserId, | |
| 189 | 195 | ) -> Result<i64> { | |
| 190 | - | let count: i64 = sqlx::query_scalar( | |
| 191 | - | r#" | |
| 192 | - | WITH followed_item_ids AS ( | |
| 193 | - | SELECT i.id FROM items i | |
| 194 | - | JOIN projects p ON i.project_id = p.id | |
| 195 | - | JOIN users u ON u.id = p.user_id | |
| 196 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id | |
| 197 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 198 | - | UNION | |
| 199 | - | SELECT i.id FROM items i | |
| 200 | - | JOIN projects p ON i.project_id = p.id | |
| 201 | - | JOIN users u ON u.id = p.user_id | |
| 202 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id | |
| 203 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 204 | - | UNION | |
| 205 | - | SELECT i.id FROM items i | |
| 206 | - | JOIN projects p ON i.project_id = p.id | |
| 207 | - | JOIN users u ON u.id = p.user_id | |
| 208 | - | JOIN item_tags it ON it.item_id = i.id | |
| 209 | - | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 210 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 211 | - | ) | |
| 196 | + | let count: i64 = sqlx::query_scalar(&format!( | |
| 197 | + | r#"{FOLLOWED_ITEM_IDS_CTE} | |
| 212 | 198 | SELECT COUNT(*) FROM followed_item_ids | |
| 213 | - | "#, | |
| 214 | - | ) | |
| 199 | + | "# | |
| 200 | + | )) | |
| 215 | 201 | .bind(follower_id) | |
| 216 | 202 | .fetch_one(pool) | |
| 217 | 203 | .await?; |
| @@ -113,16 +113,14 @@ async fn build_sidebar( | |||
| 113 | 113 | let drill_rows = | |
| 114 | 114 | db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?; | |
| 115 | 115 | ||
| 116 | - | // Follow state, for the rungs that can carry a follow control. Scoped to | |
| 117 | - | // the assignable rows rather than the viewer's whole followed set (fuzz | |
| 118 | - | // 2026-07-06 C5-1), and skipped entirely for anonymous viewers. | |
| 116 | + | // Follow state for every rung on screen. Still scoped to the rendered | |
| 117 | + | // rows rather than the viewer's whole followed set (fuzz 2026-07-06 | |
| 118 | + | // C5-1), and skipped entirely for anonymous viewers — but no longer | |
| 119 | + | // narrowed to assignable rows, since following is hierarchical and a | |
| 120 | + | // branch is now a legitimate follow target. | |
| 119 | 121 | let followed: std::collections::HashSet<db::TagId> = match viewer_id { | |
| 120 | 122 | Some(uid) => { | |
| 121 | - | let ids: Vec<_> = drill_rows | |
| 122 | - | .iter() | |
| 123 | - | .filter(|r| r.assignable) | |
| 124 | - | .map(|r| r.tag_id) | |
| 125 | - | .collect(); | |
| 123 | + | let ids: Vec<_> = drill_rows.iter().map(|r| r.tag_id).collect(); | |
| 126 | 124 | db::follows::following_subset(db, uid, &ids).await? | |
| 127 | 125 | } | |
| 128 | 126 | None => std::collections::HashSet::new(), |
| @@ -136,11 +136,24 @@ | |||
| 136 | 136 | <span>{{ row.label }}</span> | |
| 137 | 137 | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> | |
| 138 | 138 | </label> | |
| 139 | + | {% endif %} | |
| 140 | + | {% if row.has_children %} | |
| 141 | + | <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}" | |
| 142 | + | aria-label="Browse inside {{ row.label }}"> | |
| 143 | + | {% if !row.assignable %}<span>{{ row.label }}</span> | |
| 144 | + | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %} | |
| 145 | + | <span class="tag-drill-chevron" aria-hidden="true">›</span> | |
| 146 | + | </a> | |
| 147 | + | {% endif %} | |
| 139 | 148 | {# Follow is a SIBLING of the label, never inside it: | |
| 140 | 149 | a control nested in a label would toggle the | |
| 141 | - | checkbox when clicked. Offered only on assignable | |
| 142 | - | leaves, because following is exact-match and a | |
| 143 | - | follow on a category would match nothing. #} | |
| 150 | + | checkbox when clicked. Offered on EVERY rung, not | |
| 151 | + | just assignable leaves: following is hierarchical, | |
| 152 | + | so a follow on a branch matches its whole subtree | |
| 153 | + | (see db::follows::FOLLOWED_ITEM_IDS_CTE). It is the | |
| 154 | + | last child so it right-aligns identically whether | |
| 155 | + | the row leads with a checkbox label or a drill-in | |
| 156 | + | link, both of which take flex: 1. #} | |
| 144 | 157 | {% if sidebar.viewer_authenticated %} | |
| 145 | 158 | {% if row.following %} | |
| 146 | 159 | <button class="tag-follow-btn is-selected" | |
| @@ -156,15 +169,6 @@ | |||
| 156 | 169 | data-action="noop" data-stop>Follow</button> | |
| 157 | 170 | {% endif %} | |
| 158 | 171 | {% endif %} | |
| 159 | - | {% endif %} | |
| 160 | - | {% if row.has_children %} | |
| 161 | - | <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}" | |
| 162 | - | aria-label="Browse inside {{ row.label }}"> | |
| 163 | - | {% if !row.assignable %}<span>{{ row.label }}</span> | |
| 164 | - | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %} | |
| 165 | - | <span class="tag-drill-chevron" aria-hidden="true">›</span> | |
| 166 | - | </a> | |
| 167 | - | {% endif %} | |
| 168 | 172 | </li> | |
| 169 | 173 | {% endfor %} | |
| 170 | 174 | </ul> |
| @@ -528,36 +528,38 @@ async fn price_filter_round_trips_into_its_inputs() { | |||
| 528 | 528 | // Tag following. | |
| 529 | 529 | // --------------------------------------------------------------------------- | |
| 530 | 530 | ||
| 531 | - | /// The drill-down offers a follow control, and only where following works. | |
| 531 | + | /// The drill-down offers a follow control on every rung. | |
| 532 | 532 | /// | |
| 533 | - | /// Following is exact-match: `follows.target_id` joins `item_tags.tag_id` | |
| 534 | - | /// directly, with no descendant expansion. Items carry depth-3+ leaves only, so | |
| 535 | - | /// a follow on a category would match nothing forever. The control is therefore | |
| 536 | - | /// offered on assignable rungs and withheld on navigational ones, even though | |
| 537 | - | /// the tag filter beside it does expand to descendants. | |
| 533 | + | /// Inverted 2026-07-20. Following used to be exact-match — `follows.target_id` | |
| 534 | + | /// joined `item_tags.tag_id` directly, with no descendant expansion — and since | |
| 535 | + | /// items carry depth-3+ leaves only, a follow on a category matched nothing | |
| 536 | + | /// forever. The control was therefore withheld on navigational rungs, even | |
| 537 | + | /// though the tag filter beside it did expand to descendants. Following is now | |
| 538 | + | /// hierarchical too, so the two agree and every rung is followable. | |
| 538 | 539 | #[tokio::test] | |
| 539 | - | async fn follow_control_appears_only_on_assignable_rungs() { | |
| 540 | + | async fn follow_control_appears_on_every_rung() { | |
| 540 | 541 | let mut h = TestHarness::new().await; | |
| 541 | 542 | h.signup("followui", "followui@example.com", "password123").await; | |
| 542 | 543 | ||
| 543 | - | // Root: every rung is a depth-1 type, none assignable, so no follow control. | |
| 544 | + | // Root: depth-1 categories, navigational rather than assignable. Followable | |
| 545 | + | // now that a follow on a branch covers its subtree. | |
| 544 | 546 | let root = h.client.get("/discover?mode=items").await; | |
| 545 | 547 | assert!(root.status.is_success(), "{}", root.status); | |
| 546 | 548 | assert!( | |
| 547 | - | !root.text.contains("tag-follow-btn"), | |
| 548 | - | "categories cannot be followed meaningfully, so offer no control" | |
| 549 | + | root.text.contains("tag-follow-btn"), | |
| 550 | + | "a category follow now covers its whole subtree, so offer the control" | |
| 551 | + | ); | |
| 552 | + | assert!( | |
| 553 | + | root.text.contains("/api/follow/tag/"), | |
| 554 | + | "the control must address the tag follow endpoint" | |
| 549 | 555 | ); | |
| 550 | 556 | ||
| 551 | - | // Drilled to leaves: the control appears. | |
| 557 | + | // Drilled to leaves: the control is still there. | |
| 552 | 558 | let leaves = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 553 | 559 | assert!(leaves.status.is_success(), "{}", leaves.status); | |
| 554 | 560 | assert!( | |
| 555 | 561 | leaves.text.contains("tag-follow-btn"), | |
| 556 | - | "assignable leaves should offer a follow control" | |
| 557 | - | ); | |
| 558 | - | assert!( | |
| 559 | - | leaves.text.contains("/api/follow/tag/"), | |
| 560 | - | "the control must address the tag follow endpoint" | |
| 562 | + | "assignable leaves should still offer a follow control" | |
| 561 | 563 | ); | |
| 562 | 564 | } | |
| 563 | 565 |
| @@ -340,6 +340,83 @@ async fn following_a_tag_changes_the_feed() { | |||
| 340 | 340 | ); | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | + | /// Resolve a tag id by slug. Ancestors are created by `item_tagged_with` on the | |
| 344 | + | /// way down to the leaf, but only the leaf id is returned. | |
| 345 | + | async fn tag_id_for(h: &TestHarness, slug: &str) -> uuid::Uuid { | |
| 346 | + | sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1") | |
| 347 | + | .bind(slug) | |
| 348 | + | .fetch_one(&h.db) | |
| 349 | + | .await | |
| 350 | + | .expect("tag by slug") | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /// Following a BRANCH surfaces items tagged with a descendant leaf. | |
| 354 | + | /// | |
| 355 | + | /// This is the whole point of making following hierarchical, and nothing else | |
| 356 | + | /// catches a broken subtree expansion: `item_tags` holds only directly-assigned | |
| 357 | + | /// tags, and the write path enforces depth >= 3, so before this change a follow | |
| 358 | + | /// on a depth-1 or depth-2 tag matched nothing forever. | |
| 359 | + | #[tokio::test] | |
| 360 | + | async fn following_a_parent_tag_surfaces_descendant_items() { | |
| 361 | + | let mut h = TestHarness::new().await; | |
| 362 | + | let (_item, leaf) = | |
| 363 | + | item_tagged_with(&mut h, "branchcreator", "Deep Release", "audio.genre.techno").await; | |
| 364 | + | let branch = tag_id_for(&h, "audio.genre").await; | |
| 365 | + | assert_ne!(branch, leaf, "the followed branch must not be the leaf itself"); | |
| 366 | + | h.client.post_form("/logout", "").await; | |
| 367 | + | ||
| 368 | + | h.signup("branchfollower", "branchfollower@test.com", "password123").await; | |
| 369 | + | ||
| 370 | + | let before = h.client.get("/feed").await; | |
| 371 | + | assert!( | |
| 372 | + | !before.text.contains("Deep Release"), | |
| 373 | + | "an unfollowed branch must not put items in the feed" | |
| 374 | + | ); | |
| 375 | + | ||
| 376 | + | let follow = h | |
| 377 | + | .client | |
| 378 | + | .post_form(&format!("/api/follow/tag/{branch}"), "") | |
| 379 | + | .await; | |
| 380 | + | assert!(follow.status.is_success(), "follow failed: {}", follow.status); | |
| 381 | + | ||
| 382 | + | let after = h.client.get("/feed").await; | |
| 383 | + | assert!(after.status.is_success(), "{}", after.status); | |
| 384 | + | assert!( | |
| 385 | + | after.text.contains("Deep Release"), | |
| 386 | + | "following audio.genre must surface an item tagged audio.genre.techno; body was: {}", | |
| 387 | + | &after.text[..after.text.len().min(600)] | |
| 388 | + | ); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /// The same expansion has to hold in `get_followed_items`, which backs the | |
| 392 | + | /// signed RSS feed and is a separate query from the feed page's. | |
| 393 | + | #[tokio::test] | |
| 394 | + | async fn following_a_parent_tag_surfaces_descendant_items_in_rss() { | |
| 395 | + | let mut h = TestHarness::new().await; | |
| 396 | + | let (_item, _leaf) = | |
| 397 | + | item_tagged_with(&mut h, "branchrss", "Deep Syndicated", "audio.genre.dub").await; | |
| 398 | + | let branch = tag_id_for(&h, "audio").await; | |
| 399 | + | h.client.post_form("/logout", "").await; | |
| 400 | + | ||
| 401 | + | let follower = h.signup("branchrssfollower", "branchrssf@test.com", "password123").await; | |
| 402 | + | h.client | |
| 403 | + | .post_form(&format!("/api/follow/tag/{branch}"), "") | |
| 404 | + | .await; | |
| 405 | + | ||
| 406 | + | let url = makenotwork::crypto::generate_feed_url( | |
| 407 | + | "", | |
| 408 | + | follower, | |
| 409 | + | 0, | |
| 410 | + | "test-signing-secret-for-integration-tests", | |
| 411 | + | ); | |
| 412 | + | let resp = h.client.get(&url).await; | |
| 413 | + | assert!(resp.status.is_success(), "RSS feed: {} {}", resp.status, url); | |
| 414 | + | assert!( | |
| 415 | + | resp.text.contains("Deep Syndicated"), | |
| 416 | + | "following the depth-1 root must reach a depth-3 leaf's item over RSS" | |
| 417 | + | ); | |
| 418 | + | } | |
| 419 | + | ||
| 343 | 420 | /// A followed tag reaches the signed RSS feed too. | |
| 344 | 421 | /// | |
| 345 | 422 | /// The RSS handler uses get_followed_items, a different query from the /feed |