Skip to main content

max / makenotwork

Show the item count on a paywalled project page A paid project rendered ProjectPaywallTemplate with a hardcoded count of zero, so the page said nothing about how much was behind it. There is no reason to withhold that: a project with a lot in it sells better than a bare price, and the count is not the thing being sold, since the items stay gated. Reads the denormalized projects.item_count (migration 156), so it costs one indexed lookup and matches the discover card exactly. That also ends the contradiction where discover advertised "4 items" and the page it linked to mentioned none. Test asserts both halves: the count renders, and the item titles do not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:07 UTC
Signed with PGP, not checked
Commit: c6a6784e6d2b0ca153262d697e72caa7579a5797
Parent: 9652f8e
8 files changed, +290 insertions, -5 deletions
@@ -10465,10 +10465,6 @@
10465 10465 "pkg-config",
10466 10466 ]
10467 10467
10468 - [[patch.unused]]
10469 - name = "supernote-push"
10470 - version = "0.1.0"
10471 -
10472 10468 [[patch.unused]]
10473 10469 name = "synckit-client"
10474 10470 version = "0.6.0"
@@ -10477,6 +10473,10 @@
10477 10473 name = "synckit-config"
10478 10474 version = "0.1.2"
10479 10475
10476 + [[patch.unused]]
10477 + name = "supernote-push"
10478 + version = "0.1.0"
10479 +
10480 10480 [[patch.unused]]
10481 10481 name = "kberg"
10482 10482 version = "0.1.0"
@@ -21,3 +21,29 @@
21 21 ```
22 22
23 23 See `CONTRIBUTING.md` for coding patterns, macros, and conventions.
24 +
25 + ## Contributing
26 +
27 + This project is licensed under PolyForm Noncommercial 1.0.0. Make Creative, LLC owns
28 + the codebase and needs to hold clear title to all of it, so contributions work a
29 + little differently here than on a permissively licensed project.
30 +
31 + **Found a bug, or something small and obviously wrong?** Open a bug report rather
32 + than sending a patch. A clear report of what you did, what happened, and what you
33 + expected is worth more to us than a diff, and it saves you the paperwork below.
34 +
35 + **Want to build something substantial?** Write to info@makenot.work first. We are
36 + open to paying for contributions that matter, and we would rather agree on scope and
37 + terms before you spend your evenings on it than after.
38 +
39 + **Sending code directly?** Any code you submit is covered by the Contributor License
40 + Agreement in `CLA.md`, which assigns copyright in the contribution to Make Creative,
41 + LLC. Read it before you send anything. Add these lines to your commit message to
42 + record that you accept it:
43 +
44 + Make-Creative-CLA: 1.0.0
45 + Signed-off-by: Your Name <your@email>
46 +
47 + We may decline a contribution for any reason, including that it is not something we
48 + want to maintain.
49 +
@@ -3615,6 +3615,12 @@
3615 3615 text-align: left;
3616 3616 }
3617 3617
3618 + .project-paywall-page .paywall-item-count {
3619 + font-size: var(--text-body);
3620 + opacity: 0.7;
3621 + margin-bottom: var(--gap-peer);
3622 + }
3623 +
3618 3624 .project-paywall-page .paywall-tiers {
3619 3625 display: grid;
3620 3626 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -291,6 +291,24 @@
291 291 Ok(())
292 292 }
293 293
294 + /// Active item count for one project, read from the denormalized
295 + /// `projects.item_count` column (migration 156, trigger-maintained).
296 + ///
297 + /// "Active" is exactly the storefront and discover filter (`is_public AND listed
298 + /// AND deleted_at IS NULL`), so this number agrees with both the item list on the
299 + /// project page and the count on a discover card. Use it wherever a page needs
300 + /// the count without the rows, notably the paywall, which shows how much is
301 + /// behind it but not what.
302 + #[tracing::instrument(skip_all, fields(%project_id))]
303 + pub async fn get_active_item_count(pool: &PgPool, project_id: ProjectId) -> Result<i32> {
304 + let count = sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1")
305 + .bind(project_id)
306 + .fetch_one(pool)
307 + .await?;
308 +
309 + Ok(count)
310 + }
311 +
294 312 /// Get public projects with item counts in a single query (avoids N+1)
295 313 #[tracing::instrument(skip_all)]
296 314 pub async fn get_public_projects_with_item_counts(
@@ -37,6 +37,10 @@
37 37 <p class="paywall-description">{{ project.description }}</p>
38 38 {% endif %}
39 39
40 + {% if project.item_count > 0 %}
41 + <p class="paywall-item-count">{{ project.item_count }} item{% if project.item_count != 1 %}s{% endif %} included</p>
42 + {% endif %}
43 +
40 44 <div class="paywall-price">{{ price_display }}</div>
41 45
42 46 {% match checkout_type %}
@@ -296,3 +296,79 @@
296 296 );
297 297 }
298 298 }
299 +
300 + /// A paywalled project page tells a visitor how much is behind the paywall.
301 + ///
302 + /// Hiding the count sells the project worse than showing it: a lot of items is
303 + /// the argument for paying. The items themselves stay gated, so this leaks
304 + /// nothing that was being sold. Before 2026-08-05 the paywall was built with a
305 + /// hardcoded count of 0 and said nothing at all.
306 + #[tokio::test]
307 + async fn paywalled_project_page_shows_its_item_count() {
308 + let mut h = TestHarness::new().await;
309 +
310 + let user_id = h
311 + .signup("paywalltest", "paywalltest@example.com", "password123")
312 + .await;
313 + h.grant_creator(user_id).await;
314 + h.client.post_form("/logout", "").await;
315 + h.login("paywalltest", "password123").await;
316 +
317 + let resp = h
318 + .client
319 + .post_form("/api/projects", "slug=paywall-proj&title=Paywall+Project")
320 + .await;
321 + let project: Value = resp.json();
322 + let project_id = project["id"].as_str().unwrap();
323 +
324 + h.client
325 + .put_json(
326 + &format!("/api/projects/{project_id}"),
327 + r#"{"is_public": true}"#,
328 + )
329 + .await;
330 +
331 + for title in ["First+Piece", "Second+Piece"] {
332 + let resp = h
333 + .client
334 + .post_form(
335 + &format!("/api/projects/{project_id}/items"),
336 + &format!("title={title}&item_type=text&is_public=true&price_cents=0"),
337 + )
338 + .await;
339 + assert!(
340 + resp.status.is_success(),
341 + "create item failed: {}",
342 + resp.text
343 + );
344 + }
345 +
346 + // Price the project itself, which is what puts the page behind the paywall.
347 + let resp = h
348 + .client
349 + .put_json(
350 + &format!("/api/projects/{project_id}"),
351 + r#"{"pricing_model": "buy_once", "price_dollars": 9.0}"#,
352 + )
353 + .await;
354 + assert!(
355 + resp.status.is_success(),
356 + "set project pricing failed: {}",
357 + resp.text
358 + );
359 +
360 + h.client.post_form("/logout", "").await;
361 +
362 + let resp = h.client.get("/p/paywall-proj").await;
363 + assert_eq!(resp.status, 200, "paywalled project page should render");
364 + assert!(
365 + resp.text.contains("2 items included"),
366 + "paywall should show how much is behind it; body was:\n{}",
367 + resp.text
368 + );
369 + // Still a paywall: the item titles stay gated.
370 + assert!(
371 + !resp.text.contains("First Piece"),
372 + "paywall must not list the items themselves"
373 + );
374 + }
@@ -88,7 +88,22 @@
88 88 db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?;
89 89 let subscription_tiers: Vec<SubscriptionTier> =
90 90 db_tiers.iter().map(SubscriptionTier::from).collect();
91 - let project = Project::from_db(db_project, 0);
91 + // Show how much is behind the paywall. A project with a lot in it
92 + // sells better than a bare price, and the count is not the thing
93 + // being sold: the items themselves stay gated. Reads the
94 + // denormalized column, so it matches the discover card exactly and
95 + // costs one indexed lookup.
96 + let item_count = db::projects::get_active_item_count(db, db_project.id)
97 + .await
98 + .unwrap_or_else(|e| {
99 + tracing::warn!(
100 + project_id = %db_project.id,
101 + error = %e,
102 + "paywall item count lookup failed; rendering without a count"
103 + );
104 + 0
105 + });
106 + let project = Project::from_db(db_project, item_count.max(0) as u32);
92 107 return Ok(ProjectPaywallTemplate {
93 108 csrf_token,
94 109 session_user: maybe_user,
A server/CLA.md +140
@@ -1,0 +1,140 @@
1 + # Contributor License Agreement
2 +
3 + Read this before sending code. Most people should not need to: if you have found a
4 + bug, a clear bug report is more useful to us than a patch, and it saves you the
5 + paperwork below. See the contributing section of the README.
6 +
7 + This agreement is version 1.0.0. You agree to the version in effect on the day you
8 + send us code. A later revision does not change what you already agreed to.
9 +
10 + ## Make Creative Contributor License Agreement, version 1.0.0
11 +
12 + This agreement is between Make Creative, LLC, a Colorado limited liability company
13 + ("Make Creative", "we", "us"), and you, the person or entity submitting a
14 + Contribution ("you").
15 +
16 + ### 1. Definitions
17 +
18 + **Project** means any software project owned by Make Creative that distributes this
19 + agreement in its source repository.
20 +
21 + **Contribution** means any work of authorship you submit to a Project, in any form
22 + and through any channel: a patch sent by email, a diff attached to an issue, a pull
23 + or merge request on any repository or mirror, a commit pushed to a branch we grant
24 + you access to, or code pasted into a message. It covers source code, documentation,
25 + configuration, test data, and build files. It does not cover a bug report, a feature
26 + request, or a description of a problem that contains no code we could copy.
27 +
28 + **Submit** means to deliver a Contribution to us or to a Project by any of the means
29 + above.
30 +
31 + ### 2. Assignment
32 +
33 + You assign to Make Creative all right, title, and interest in and to your
34 + Contribution worldwide, including all copyrights and all rights to sue for past,
35 + present, and future infringement. The assignment is effective the moment you Submit,
36 + is irrevocable, and requires no further action by either of us.
37 +
38 + You will, at our request and our expense, sign any document reasonably needed to
39 + record or perfect the assignment.
40 +
41 + ### 3. Fallback license
42 +
43 + To the extent any part of the assignment in section 2 is unenforceable in your
44 + jurisdiction, or is later found ineffective, you instead grant Make Creative a
45 + perpetual, worldwide, irrevocable, exclusive, transferable, sublicensable,
46 + royalty-free license to reproduce, prepare derivative works of, publicly display,
47 + publicly perform, distribute, and otherwise exploit your Contribution, by any means
48 + and in any medium now known or later devised, for any purpose, commercial or
49 + noncommercial.
50 +
51 + To the extent permitted by law, you waive, and agree not to assert against Make
52 + Creative or anyone receiving the Contribution from us, any moral rights or rights of
53 + attribution or integrity in your Contribution. Where such a waiver is not permitted,
54 + you agree not to enforce those rights in a way that would interfere with our
55 + exercise of the rights granted here.
56 +
57 + ### 4. Patent license
58 +
59 + You grant Make Creative a perpetual, worldwide, irrevocable, transferable,
60 + sublicensable, royalty-free patent license to make, have made, use, offer to sell,
61 + sell, import, and otherwise transfer your Contribution and any Project incorporating
62 + it. The license covers only those patent claims you can license that are necessarily
63 + infringed by your Contribution alone or by its combination with the Project it was
64 + Submitted to.
65 +
66 + If you initiate patent litigation alleging that a Project or a Contribution
67 + infringes a patent, every license you have received from us under section 5
68 + terminates on the day the action is filed.
69 +
70 + ### 5. License back to you
71 +
72 + We grant you a perpetual, worldwide, irrevocable, non-exclusive, royalty-free
73 + license to use, reproduce, modify, and distribute your own Contribution, and
74 + derivative works of it, for any purpose, including commercially. This license covers
75 + your Contribution as you Submitted it. It does not extend to the rest of the
76 + Project, which remains available to you only under the Project's public license.
77 +
78 + ### 6. What you promise
79 +
80 + You represent that:
81 +
82 + 1. Each Contribution is your original work, or you have the right to Submit it and
83 + to grant the rights in this agreement.
84 + 2. If your employer has rights in work you produce, you have your employer's
85 + permission to Submit, or your employer has waived its rights in the Contribution.
86 + 3. Your Contribution does not contain, and is not derived from, code licensed under
87 + the GPL, LGPL, AGPL, or any other license that would impose conditions on how
88 + Make Creative may license the Project. If any part of your Contribution came from
89 + somewhere else, you identified the source and its license when you Submitted it.
90 + 4. If you used an automated tool to produce any part of your Contribution, you still
91 + hold or can grant the rights this agreement requires.
92 + 5. Your Contribution is not subject to any third-party agreement, license, or claim
93 + that conflicts with this agreement.
94 +
95 + If you learn that any of these is untrue, tell us at info@makenot.work promptly.
96 +
97 + ### 7. No warranty
98 +
99 + Except for section 6, you provide your Contribution as is, without warranty of any
100 + kind. You owe us no support, maintenance, or updates.
101 +
102 + ### 8. No obligation and no compensation
103 +
104 + We are not required to use, merge, distribute, or keep any Contribution. We may
105 + modify, relicense, or remove it. Submitting a Contribution creates no employment,
106 + partnership, agency, or joint venture between us.
107 +
108 + You are not entitled to compensation for a Contribution. If we agree to pay for
109 + one, that agreement will be separate and in writing, and it does not change the
110 + rights assigned here.
111 +
112 + ### 9. How you accept
113 +
114 + You accept this agreement when you Submit a Contribution. We also ask you to record
115 + that acceptance in each commit message:
116 +
117 + ```
118 + Make-Creative-CLA: 1.0.0
119 + Signed-off-by: Your Name <your@email>
120 + ```
121 +
122 + Leaving the line out does not undo your acceptance. It only means the record of it
123 + lives outside the repository.
124 +
125 + If you are Submitting on behalf of an entity, the person who Submits represents that
126 + they are authorized to bind that entity, and "you" means the entity.
127 +
128 + ### 10. Terms
129 +
130 + This agreement is governed by the laws of the State of Colorado, USA, without regard
131 + to its conflict of laws rules. Any dispute will be brought in the state or federal
132 + courts sitting in Colorado, and you consent to their jurisdiction.
133 +
134 + This is the entire agreement between us about Contributions, and it replaces any
135 + earlier understanding on the subject. If a provision is held unenforceable, the rest
136 + stays in force. Our failure to enforce a provision is not a waiver of it.
137 +
138 + We may publish a revised version of this agreement. A revision applies only to
139 + Contributions Submitted on or after its publication date. Your earlier Contributions
140 + stay governed by the version in effect when you Submitted them.