Skip to main content

max / makenotwork

server: give the user-level SyncKit tab a Settings section Max ruled (a) on 47e67540: a Settings section, not a dashboard tab and not deleted. The surface has always existed and has never been reachable. Cloud Sync joins settings_tabs as a seventh section, gated on owning at least one sync app, so it is absent rather than empty on nearly every account. The gate is a new db::synckit::creator_has_sync_apps EXISTS query: the settings tab asks on every open and does not want the rows. It is fillable, which is the point. dashboard_tab_synckit splits into build_synckit the way the creator tab splits into build_creator, so ?tab=settings&section=synckit renders the apps inline at first paint. synckit_return_url's unlinked half points there now; it was ?tab=settings, which opened on Profile and left the developer to find the app they had just been billed for. The four gates became a named Gates struct rather than a fifth positional bool.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-27 21:55 UTC
Signed with PGP, not checked
Commit: c501687a5e45656c7a7f067cd4fbc6f74ca22347
Parent: 26c5543
9 files changed, +283 insertions, -101 deletions
M server/Cargo.lock +16 -16
@@ -10759,6 +10759,22 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "quasi-immediate"
10764 + version = "0.70.0"
10765 +
10766 + [[patch.unused]]
10767 + name = "quasi-notifs"
10768 + version = "0.70.0"
10769 +
10770 + [[patch.unused]]
10771 + name = "quasi-store"
10772 + version = "0.1.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-tauri"
10776 + version = "0.70.0"
10777 +
10762 10778 [[patch.unused]]
10763 10779 name = "kberg"
10764 10780 version = "0.1.0"
@@ -10778,19 +10794,3 @@
10778 10794 [[patch.unused]]
10779 10795 name = "synckit-config"
10780 10796 version = "0.2.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "quasi-immediate"
10784 - version = "0.70.0"
10785 -
10786 - [[patch.unused]]
10787 - name = "quasi-notifs"
10788 - version = "0.70.0"
10789 -
10790 - [[patch.unused]]
10791 - name = "quasi-store"
10792 - version = "0.1.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "quasi-tauri"
10796 - version = "0.70.0"
@@ -291,7 +291,16 @@
291 291
292 292 // Read off the described strip since `6b24f2df` step 4, the same way the
293 293 // library half above is. The hand-written nav this used to read is gone.
294 - let settings = crate::quasi::settings_tabs::html(0, "", true, true, true);
294 + let settings = crate::quasi::settings_tabs::html(
295 + 0,
296 + "",
297 + crate::quasi::settings_tabs::Gates {
298 + has_media: true,
299 + git_enabled: true,
300 + has_mt_memberships: true,
301 + has_sync_apps: true,
302 + },
303 + );
295 304 assert!(
296 305 settings.contains(&format!("hx-get=\"{SETTINGS_PATH}\"")),
297 306 "{settings}"
@@ -26,7 +26,7 @@
26 26 //! like a constraint and is not one -- it is a renderer's habit written into a
27 27 //! doc comment. Worth correcting there rather than working around here.
28 28 //!
29 - //! # Two of the six sections are described screens
29 + //! # Two of the sections are described screens
30 30 //!
31 31 //! SSH Keys and Forums answer for themselves when `QUASI_SCREENS` names them, so
32 32 //! they name the region their answer replaces and this strip must not override
@@ -47,6 +47,16 @@
47 47 //! the project code tab. The last three spelled it as `/dashboard#tab-plan` or
48 48 //! `#tab-ssh-keys`, ids this page has never had, so they did nothing at all.
49 49 //!
50 + //! # The seventh section, and why it is here rather than a tab
51 + //!
52 + //! Cloud Sync (`47e67540`). The user-level SyncKit surface has always existed
53 + //! and has never been reachable: the strip above never carried a button for it
54 + //! and the `#tab-synckit` links that meant it named an id this page has never
55 + //! had. It is a section rather than a dashboard tab because account-level
56 + //! integrations live here, and it is gated on owning a sync app because Cloud
57 + //! Sync is for developers shipping their own software and would otherwise be an
58 + //! empty section on every account.
59 + //!
50 60 //! # What this retires
51 61 //!
52 62 //! `static/tab-user-settings.js` entirely, its `<script>` tag, the
@@ -75,6 +85,28 @@
75 85 Git,
76 86 /// Only where the Multithreaded integration is configured.
77 87 Forums,
88 + /// Only a reader who owns at least one sync app.
89 + ///
90 + /// Cloud Sync is for developers shipping their own software, so on nearly
91 + /// every account this section is absent rather than empty (`47e67540`).
92 + SyncKit,
93 + }
94 +
95 + /// What this reader is entitled to see, gathered once by the caller.
96 + ///
97 + /// Four gates were four positional `bool`s until the fifth section arrived, at
98 + /// which point every call site read as a row of anonymous `true`s. Naming them
99 + /// costs one struct and makes a transposed pair impossible.
100 + #[derive(Clone, Copy)]
101 + pub struct Gates {
102 + /// The reader can create projects, so they have media.
103 + pub has_media: bool,
104 + /// The server has a git repositories path.
105 + pub git_enabled: bool,
106 + /// The Multithreaded integration is configured.
107 + pub has_mt_memberships: bool,
108 + /// The reader owns at least one sync app.
109 + pub has_sync_apps: bool,
78 110 }
79 111
80 112 /// One section: what it is called, where its frame is, and what serves it.
@@ -141,6 +173,14 @@
141 173 route: super::forum_memberships::SETTINGS_PATH,
142 174 screen: Some(super::forum_memberships::SETTINGS_SCREEN),
143 175 },
176 + Section {
177 + label: "Cloud Sync",
178 + name: "synckit",
179 + gate: Gate::SyncKit,
180 + panel: "settings-synckit",
181 + route: "/dashboard/tabs/synckit",
182 + screen: None,
183 + },
144 184 ];
145 185
146 186 /// The sections whose contents the settings builder can render inline.
@@ -149,20 +189,24 @@
149 189 /// would open a blank frame rather than a slow one and answers Profile instead.
150 190 /// Profile is here because it is what the tab has always opened on; Creator Plan
151 191 /// and SSH Keys because five sites link to one of the two, measured 2026-08-19.
192 + /// Cloud Sync because `synckit_return_url` links to it: a developer coming back
193 + /// from Stripe must land on the app they were just billed for, which is the
194 + /// whole reason the section exists (`47e67540`).
152 195 /// Account, Media and Forums are pressed rather than linked to.
153 - const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys"];
196 + const FILLABLE: &[&str] = &["profile", "creator", "ssh-keys", "synckit"];
154 197
155 198 /// The sections this reader sees, in strip order.
156 199 ///
157 200 /// Never empty: Profile and Account are [`Gate::Always`].
158 - fn visible(has_media: bool, git_enabled: bool, has_mt_memberships: bool) -> Vec<&'static Section> {
201 + fn visible(gates: Gates) -> Vec<&'static Section> {
159 202 SECTIONS
160 203 .iter()
161 204 .filter(|section| match section.gate {
162 205 Gate::Always => true,
163 - Gate::Media => has_media,
164 - Gate::Git => git_enabled,
165 - Gate::Forums => has_mt_memberships,
206 + Gate::Media => gates.has_media,
207 + Gate::Git => gates.git_enabled,
208 + Gate::Forums => gates.has_mt_memberships,
209 + Gate::SyncKit => gates.has_sync_apps,
166 210 })
167 211 .collect()
168 212 }
@@ -180,17 +224,12 @@
180 224 /// switch exists to replace. The link opens Profile there and the section is a
181 225 /// press away. Unset, which is every deployment today, it fills.
182 226 #[must_use]
183 - pub fn shown_at(
184 - asked: Option<&str>,
185 - has_media: bool,
186 - git_enabled: bool,
187 - has_mt_memberships: bool,
188 - ) -> usize {
227 + pub fn shown_at(asked: Option<&str>, gates: Gates) -> usize {
189 228 let Some(asked) = asked else { return 0 };
190 229 if !FILLABLE.contains(&asked) {
191 230 return 0;
192 231 }
193 - visible(has_media, git_enabled, has_mt_memberships)
232 + visible(gates)
194 233 .iter()
195 234 .position(|section| section.name == asked && section.screen.is_none())
196 235 .unwrap_or(0)
@@ -198,13 +237,8 @@
198 237
199 238 /// The name of a section by index, so the caller knows which one to render.
200 239 #[must_use]
201 - pub fn section_at(
202 - shown: usize,
203 - has_media: bool,
204 - git_enabled: bool,
205 - has_mt_memberships: bool,
206 - ) -> &'static str {
207 - let sections = visible(has_media, git_enabled, has_mt_memberships);
240 + pub fn section_at(shown: usize, gates: Gates) -> &'static str {
241 + let sections = visible(gates);
208 242 sections
209 243 .get(shown)
210 244 .map_or(sections[0].name, |section| section.name)
@@ -216,14 +250,8 @@
216 250 /// the `{% include %}` did for Profile. The settings tab has never fetched on
217 251 /// open and still does not.
218 252 #[must_use]
219 - pub fn html(
220 - shown: usize,
221 - section: &str,
222 - has_media: bool,
223 - git_enabled: bool,
224 - has_mt_memberships: bool,
225 - ) -> String {
226 - let sections = visible(has_media, git_enabled, has_mt_memberships);
253 + pub fn html(shown: usize, section: &str, gates: Gates) -> String {
254 + let sections = visible(gates);
227 255 let shown = shown.min(sections.len() - 1);
228 256
229 257 let mut strip = Slot::new(STRIP, RegionKind::TabGroup)
@@ -257,8 +285,31 @@
257 285 mod tests {
258 286 use super::*;
259 287
260 - fn strip(media: bool, git: bool, forums: bool) -> String {
261 - html(0, "<p>your profile</p>", media, git, forums)
288 + /// A reader every gate opens for.
289 + const ALL: Gates = Gates {
290 + has_media: true,
291 + git_enabled: true,
292 + has_mt_memberships: true,
293 + has_sync_apps: true,
294 + };
295 +
296 + /// A reader no gate opens for: Profile, Account and Creator Plan only.
297 + const NONE: Gates = Gates {
298 + has_media: false,
299 + git_enabled: false,
300 + has_mt_memberships: false,
301 + has_sync_apps: false,
302 + };
303 +
304 + /// The reader the older tests were written against: the three gates that
305 + /// existed before Cloud Sync, all open.
306 + const NO_SYNC: Gates = Gates {
307 + has_sync_apps: false,
308 + ..ALL
309 + };
310 +
311 + fn strip(gates: Gates) -> String {
312 + html(0, "<p>your profile</p>", gates)
262 313 }
263 314
264 315 #[test]
@@ -266,7 +317,7 @@
266 317 // The nav included its first section rather than fetching it, and the
267 318 // described strip keeps that: the shown frame arrives filled and the
268 319 // other five are fetched on a press.
269 - let html = strip(true, true, true);
320 + let html = strip(NO_SYNC);
270 321
271 322 assert!(!html.contains("hx-trigger=\"load\""), "{html}");
272 323 assert!(html.contains("<p>your profile</p>"), "{html}");
@@ -275,7 +326,7 @@
275 326
276 327 #[test]
277 328 fn every_unshown_section_says_where_its_answer_lands() {
278 - let html = strip(true, true, true);
329 + let html = strip(NO_SYNC);
279 330
280 331 // The Askama sections. `settings-ssh-keys` and `settings-forums` are
281 332 // deliberately absent: both are described and name their own region, so
@@ -302,7 +353,7 @@
302 353 // than the section, because with a screen off the Askama route answered
303 354 // and named nothing; `64b33b26` deleted the switch, so being described
304 355 // is the whole test now.
305 - let html = html(0, "<p>your profile</p>", true, true, true);
356 + let html = strip(NO_SYNC);
306 357
307 358 for panel in ["settings-ssh-keys", "settings-forums"] {
308 359 assert!(
@@ -331,11 +382,12 @@
331 382 // The whole of `3a7de032`: `?tab=settings&section=creator` opens the
332 383 // Creator Plan filled rather than opening Profile and making the reader
333 384 // find it.
334 - let shown = shown_at(Some("creator"), true, true, true);
385 + let all = NO_SYNC;
386 + let shown = shown_at(Some("creator"), all);
335 387 assert_eq!(shown, 2);
336 - assert_eq!(section_at(shown, true, true, true), "creator");
388 + assert_eq!(section_at(shown, all), "creator");
337 389
338 - let html = html(shown, "<p>your plan</p>", true, true, true);
390 + let html = html(shown, "<p>your plan</p>", all);
339 391 assert!(html.contains("<p>your plan</p>"), "{html}");
340 392 assert!(!html.contains("hx-target=\"#settings-creator\""), "{html}");
341 393 // Profile is a press now, and it is the one the strip used to fill.
@@ -346,22 +398,45 @@
346 398 #[test]
347 399 fn a_section_the_page_cannot_fill_answers_profile() {
348 400 // Real sections nothing links to, so nothing pays to render them inline.
349 - assert_eq!(shown_at(Some("account"), true, true, true), 0);
350 - assert_eq!(shown_at(Some("media"), true, true, true), 0);
351 - assert_eq!(shown_at(Some("forums"), true, true, true), 0);
401 + let all = ALL;
402 + assert_eq!(shown_at(Some("account"), all), 0);
403 + assert_eq!(shown_at(Some("media"), all), 0);
404 + assert_eq!(shown_at(Some("forums"), all), 0);
352 405 // Not a section at all, and no section asked for.
353 - assert_eq!(shown_at(Some("nonsense"), true, true, true), 0);
354 - assert_eq!(shown_at(None, true, true, true), 0);
406 + assert_eq!(shown_at(Some("nonsense"), all), 0);
407 + assert_eq!(shown_at(None, all), 0);
355 408 // A gated section asked for by a reader who cannot see it. The index
356 409 // shifts under the gate, so the answer has to be read as a name.
357 - assert_eq!(shown_at(Some("ssh-keys"), true, false, true), 0);
358 - assert_eq!(section_at(0, true, false, true), "profile");
410 + assert_eq!(
411 + shown_at(
412 + Some("ssh-keys"),
413 + Gates {
414 + git_enabled: false,
415 + ..NO_SYNC
416 + }
417 + ),
418 + 0
419 + );
420 + assert_eq!(
421 + section_at(
422 + 0,
423 + Gates {
424 + git_enabled: false,
425 + ..NO_SYNC
426 + }
427 + ),
428 + "profile"
429 + );
359 430 // ssh-keys is a described section, so `shown_at` answers profile for it
360 431 // whether or not the reader can see it -- see the test below. The index
361 432 // shift is still asserted, through `section_at`, which is what actually
362 433 // does the reading.
363 - assert_eq!(shown_at(Some("ssh-keys"), false, true, false), 0);
364 - assert_eq!(section_at(3, false, true, false), "ssh-keys");
434 + let git_only = Gates {
435 + git_enabled: true,
436 + ..NONE
437 + };
438 + assert_eq!(shown_at(Some("ssh-keys"), git_only), 0);
439 + assert_eq!(section_at(3, git_only), "ssh-keys");
365 440 }
366 441
367 442 #[test]
@@ -375,27 +450,34 @@
375 450 // for the switch being unset, where it filled at index 4. `64b33b26`
376 451 // deleted the switch and the Askama rendering with it, so that case has
377 452 // no way to arise.
378 - assert_eq!(shown_at(Some("ssh-keys"), true, true, true), 0);
453 + assert_eq!(shown_at(Some("ssh-keys"), NO_SYNC), 0);
379 454 }
380 455
381 456 #[test]
382 457 fn a_shown_index_past_the_end_cannot_panic() {
383 458 // A caller that computed an index against a different membership must
384 459 // clamp rather than take the page down.
385 - let html = html(99, "<p>your profile</p>", false, false, false);
460 + let html = html(99, "<p>your profile</p>", NONE);
386 461 assert!(html.contains("<p>your profile</p>"), "{html}");
387 462 assert!(html.contains("data-shows=\"1\""), "{html}");
388 463 }
389 464
390 465 #[test]
391 - fn the_three_gated_sections_leave_when_their_test_fails() {
392 - let all = strip(true, true, true);
393 - for label in [">Media</button>", ">SSH Keys</button>", ">Forums</button>"] {
466 + fn the_four_gated_sections_leave_when_their_test_fails() {
467 + const GATED: [&str; 4] = [
468 + ">Media</button>",
469 + ">SSH Keys</button>",
470 + ">Forums</button>",
471 + ">Cloud Sync</button>",
472 + ];
473 +
474 + let all = strip(ALL);
475 + for label in GATED {
394 476 assert!(all.contains(label), "{all}");
395 477 }
396 478
397 - let none = strip(false, false, false);
398 - for label in [">Media</button>", ">SSH Keys</button>", ">Forums</button>"] {
479 + let none = strip(NONE);
480 + for label in GATED {
399 481 assert!(!none.contains(label), "{none}");
400 482 }
401 483 // The three ungated ones are still there, and the profile still arrives
@@ -404,4 +486,51 @@
404 486 assert!(none.contains("<p>your profile</p>"), "{none}");
405 487 assert!(none.contains("role=\"tablist\""), "{none}");
406 488 }
489 +
490 + #[test]
491 + fn cloud_sync_is_absent_for_a_reader_who_owns_no_sync_app() {
492 + // The gate is the whole point of `47e67540` choosing a settings section
493 + // over a dashboard tab: Cloud Sync is for a developer shipping their own
494 + // software, so on nearly every account it is not there at all rather
495 + // than there and empty.
496 + let without = strip(NO_SYNC);
497 + assert!(!without.contains(">Cloud Sync</button>"), "{without}");
498 + assert!(!without.contains("id=\"settings-synckit\""), "{without}");
499 +
500 + let with = strip(ALL);
501 + assert!(with.contains(">Cloud Sync</button>"), "{with}");
502 + assert!(with.contains("id=\"settings-synckit\""), "{with}");
503 + // Askama, not a described screen, so the strip tells it where to land.
504 + assert!(with.contains("hx-target=\"#settings-synckit\""), "{with}");
505 + assert!(
506 + with.contains("hx-get=\"/dashboard/tabs/synckit\""),
507 + "{with}"
508 + );
509 + }
510 +
511 + #[test]
512 + fn the_stripe_return_lands_on_cloud_sync_filled() {
513 + // `synckit_return_url` sends a developer back to `&section=synckit`
514 + // after billing, and the point of that link is landing on the app they
515 + // were just billed for rather than on Profile.
516 + let owner = ALL;
517 + let shown = shown_at(Some("synckit"), owner);
518 + assert_eq!(section_at(shown, owner), "synckit");
519 +
520 + let html = html(shown, "<p>your apps</p>", owner);
521 + assert!(html.contains("<p>your apps</p>"), "{html}");
522 + assert!(!html.contains("hx-target=\"#settings-synckit\""), "{html}");
523 + }
524 +
525 + #[test]
526 + fn asking_for_cloud_sync_without_one_answers_profile() {
527 + // The gate shifts the index, so the reader who cannot see the section
528 + // gets Profile rather than whatever sits at Cloud Sync's old position.
529 + let stranger = NO_SYNC;
530 + assert_eq!(shown_at(Some("synckit"), stranger), 0);
531 + assert_eq!(
532 + section_at(shown_at(Some("synckit"), stranger), stranger),
533 + "profile"
534 + );
535 + }
407 536 }
@@ -438,7 +438,16 @@
438 438 //
439 439 // Read off the described strip since `6b24f2df` step 4. It was the
440 440 // hand-written nav's `hx-target`, which no longer exists.
441 - let nav = crate::quasi::settings_tabs::html(0, "", true, true, true);
441 + let nav = crate::quasi::settings_tabs::html(
442 + 0,
443 + "",
444 + crate::quasi::settings_tabs::Gates {
445 + has_media: true,
446 + git_enabled: true,
447 + has_mt_memberships: true,
448 + has_sync_apps: true,
449 + },
450 + );
442 451 assert!(
443 452 nav.contains(&format!("id=\"{REGION}\"")),
444 453 "the settings strip draws #{REGION}:\n{nav}"
@@ -51,7 +51,9 @@
51 51 //! sections*, `#tab-plan` (twice) is the Creator Plan section under a name
52 52 //! nothing has ever spelled, `#tab-synckit` is a tab only the project dashboard
53 53 //! ever had, and `#tab-library` in a deletion email is `/library`, a page of its
54 - //! own. A hash restore that cannot find its button does nothing and says nothing,
54 + //! own. `#tab-synckit` has a live destination since `47e67540`, and it is still
55 + //! not a tab here: the user-level surface is the Cloud Sync settings section,
56 + //! reached as `?tab=settings&section=synckit`. A hash restore that cannot find its button does nothing and says nothing,
55 57 //! which is how six dead links sat in the tree.
56 58 //!
57 59 //! The three that mean a settings section became `?tab=settings`, which is
@@ -124,6 +124,24 @@
124 124 Ok(apps)
125 125 }
126 126
127 + /// Whether this creator owns any sync app at all.
128 + ///
129 + /// The gate on the Cloud Sync settings section (`47e67540`): the section is
130 + /// absent for a reader who has never made an app, which is nearly every reader.
131 + /// An `EXISTS` rather than a length check on
132 + /// [`get_sync_apps_by_creator`], because the settings tab asks this on every
133 + /// open and does not want the rows.
134 + #[tracing::instrument(skip_all)]
135 + pub async fn creator_has_sync_apps(pool: &PgPool, creator_id: UserId) -> Result<bool> {
136 + let exists: bool =
137 + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sync_apps WHERE creator_id = $1)")
138 + .bind(creator_id)
139 + .fetch_one(pool)
140 + .await?;
141 +
142 + Ok(exists)
143 + }
144 +
127 145 /// Get all sync apps linked to a specific project.
128 146 pub async fn get_sync_apps_by_project(
129 147 pool: &PgPool,
@@ -358,16 +358,22 @@
358 358
359 359 /// Build the Stripe `return_url` for billing portal sessions. Sends the
360 360 /// developer back to the SyncKit tab on the project dashboard when the app is
361 - /// linked to a project, or the user-dashboard SyncKit tab otherwise.
361 + /// linked to a project, or the Cloud Sync settings section otherwise.
362 362 ///
363 - /// The project half is a query rather than a hash (`6b24f2df`): that strip is
364 - /// described, so it is chosen server-side and the tab arrives already rendered.
365 - /// The user dashboard's strip is still hand-written and still restores its tab
366 - /// from the hash, so that half stays a hash until step 5 converts it.
363 + /// Both halves are queries rather than hashes (`6b24f2df`): each strip is
364 + /// described, so the destination is chosen server-side and arrives already
365 + /// rendered.
366 + ///
367 + /// The unlinked half is `&section=synckit` since `47e67540`. It was
368 + /// `?tab=settings`, which opened the settings tab on Profile and left the
369 + /// developer to find the app they had just been billed for; before that it was
370 + /// `/dashboard#tab-synckit`, an id that page has never had, which did nothing at
371 + /// all. The section is where an orphan app can be linked to a project after the
372 + /// fact, which is the case this return exists to serve.
367 373 fn synckit_return_url(config: &Config, app: &crate::db::DbSyncAppBilling) -> String {
368 374 match app.project_slug.as_deref() {
369 375 Some(slug) => format!("{}/dashboard/project/{}?tab=synckit", config.host_url, slug),
370 - None => format!("{}/dashboard?tab=settings", config.host_url),
376 + None => format!("{}/dashboard?tab=settings&section=synckit", config.host_url),
371 377 }
372 378 }
373 379
@@ -20,28 +20,40 @@
20 20 State(db): State<PgPool>,
21 21 AuthUser(session_user): AuthUser,
22 22 ) -> Result<impl IntoResponse> {
23 - let db_apps = db::synckit::get_sync_apps_by_creator(&db, session_user.id).await?;
24 - let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
23 + build_synckit(&db, &session_user).await
24 + }
25 +
26 + /// The Cloud Sync section's contents, without the transport around them.
27 + ///
28 + /// Split out for `47e67540`, the same shape `build_creator` has: the section is
29 + /// deep-linkable, so the settings tab renders it inline at first paint rather
30 + /// than fetching it, and a developer returning from Stripe lands on their apps.
31 + pub(in crate::routes::pages::dashboard) async fn build_synckit(
32 + db: &PgPool,
33 + session_user: &crate::auth::SessionUser,
34 + ) -> Result<UserSyncKitTabTemplate> {
35 + let db_apps = db::synckit::get_sync_apps_by_creator(db, session_user.id).await?;
36 + let db_projects = db::projects::get_projects_by_user(db, session_user.id).await?;
25 37
26 38 // Batch-fetch stats and item titles (no N+1)
27 - let stats_batch = db::synckit::get_sync_app_stats_batch(&db, session_user.id).await?;
39 + let stats_batch = db::synckit::get_sync_app_stats_batch(db, session_user.id).await?;
28 40 let stats_map: std::collections::HashMap<_, _> = stats_batch
29 41 .into_iter()
30 42 .map(|(id, devices, logs)| (id, (devices, logs)))
31 43 .collect();
32 44
33 45 let item_ids: Vec<db::ItemId> = db_apps.iter().filter_map(|a| a.item_id).collect();
34 - let item_titles_batch = db::items::get_item_titles_batch(&db, &item_ids).await?;
46 + let item_titles_batch = db::items::get_item_titles_batch(db, &item_ids).await?;
35 47 let item_title_map: std::collections::HashMap<_, _> = item_titles_batch.into_iter().collect();
36 48
37 49 let billing_batch =
38 - db::synckit_billing::get_apps_with_billing_by_creator(&db, session_user.id).await?;
50 + db::synckit_billing::get_apps_with_billing_by_creator(db, session_user.id).await?;
39 51 let billing_map: std::collections::HashMap<_, _> =
40 52 billing_batch.into_iter().map(|b| (b.id, b)).collect();
41 53
42 54 // For per_key-mode apps, fetch the most-loaded keys to render mini-gauges
43 55 // under the app-aggregate gauge. One batched query covers every app.
44 - let top_keys_map = build_top_keys_map(&db, &billing_map).await?;
56 + let top_keys_map = build_top_keys_map(db, &billing_map).await?;
45 57
46 58 let mut apps = Vec::with_capacity(db_apps.len());
47 59 for app in db_apps {
@@ -72,38 +72,35 @@
72 72 session_user: &crate::auth::SessionUser,
73 73 asked: Option<&str>,
74 74 ) -> Result<UserSettingsTabTemplate> {
75 - let has_media = session_user.can_create_projects;
76 - let git_enabled = config.build.git_repos_path.is_some();
77 - let has_mt_memberships = config.integrations.mt_base_url.is_some();
75 + // `has_sync_apps` is an EXISTS rather than a row count: the Cloud Sync
76 + // section is gated on owning one app, and nearly every reader owns none
77 + // (`47e67540`).
78 + let gates = crate::quasi::settings_tabs::Gates {
79 + has_media: session_user.can_create_projects,
80 + git_enabled: config.build.git_repos_path.is_some(),
81 + has_mt_memberships: config.integrations.mt_base_url.is_some(),
82 + has_sync_apps: db::synckit::creator_has_sync_apps(db, session_user.id).await?,
83 + };
78 84
79 - let shown =
80 - crate::quasi::settings_tabs::shown_at(asked, has_media, git_enabled, has_mt_memberships);
85 + let shown = crate::quasi::settings_tabs::shown_at(asked, gates);
81 86
82 87 // The shown section is rendered here rather than fetched, which is what the
83 88 // `{% include %}` did for Profile before the sub-nav was described.
84 89 // `6b24f2df`, nested by `3a7de032`. Only the shown one is built, so a reader
85 90 // opening on Profile pays nothing for the two the deep link can reach.
86 - let section = match crate::quasi::settings_tabs::section_at(
87 - shown,
88 - has_media,
89 - git_enabled,
90 - has_mt_memberships,
91 - ) {
91 + let section = match crate::quasi::settings_tabs::section_at(shown, gates) {
92 92 "creator" => creator::build_creator(db, config, payments, csrf_token, session_user)
93 93 .await?
94 94 .render(),
95 + "synckit" => integrations::build_synckit(db, session_user)
96 + .await?
97 + .render(),
95 98 _ => build_profile(db, config, session_user).await?.render(),
96 99 }
97 100 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
98 101
99 102 Ok(UserSettingsTabTemplate {
100 - sections: crate::quasi::settings_tabs::html(
101 - shown,
102 - &section,
103 - has_media,
104 - git_enabled,
105 - has_mt_memberships,
106 - ),
103 + sections: crate::quasi::settings_tabs::html(shown, &section, gates),
107 104 })
108 105 }
109 106